diff --git a/addons/core/Configs/ERR/Content/Registry.conf b/addons/core/Configs/ERR/Content/Registry.conf new file mode 100644 index 0000000..9879164 --- /dev/null +++ b/addons/core/Configs/ERR/Content/Registry.conf @@ -0,0 +1,34 @@ +ERR_ContentDefinition { + Options { + ERR_ContentOption { + Id "rhs" + Label "RHS: Status Quo" + DefaultEnabled 1 + EquipmentGuids { "A1D5833FF10C6503" "695AAA77F84EB238" "333A794F0B55C019" "8D4BAA235F7EDBEA" } + } + ERR_ContentOption { + Id "rhs_usmc" + Label "RHS / US Marines" + Parent "rhs" + DefaultEnabled 1 + NativeFactionKey "RHS_USAF" + GuardGroup "{19843E954790DF28}Prefabs/Groups/BLUFOR/RHS_USAF/RHS_USAF_USMC_MEF/Group_USAF_USMC_MEF_FireTeam.et" + } + ERR_ContentOption { + Id "rhs_afrf" + Label "RHS / Russian Armed Forces" + Parent "rhs" + DefaultEnabled 1 + NativeFactionKey "RHS_AFRF" + GuardGroup "{AB905EF799E0A0AE}Prefabs/Groups/OPFOR/RHS_AFRF/MSV/Flora/Group_RHS_RF_MSV_Flora_FireGroup.et" + } + ERR_ContentOption { + Id "rhs_ion" + Label "RHS / ION" + Parent "rhs" + DefaultEnabled 1 + NativeFactionKey "RHS_ION" + GuardGroup "{516C8C7400DF2C4D}Prefabs/Groups/INDFOR/RHS_ION/RHS_ION_COY/Group_RHS_ION_COY_StaticSecurityTeam.et" + } + } +} diff --git a/addons/core/Configs/ERR/Content/Registry.conf.meta b/addons/core/Configs/ERR/Content/Registry.conf.meta new file mode 100644 index 0000000..d2a2d26 --- /dev/null +++ b/addons/core/Configs/ERR/Content/Registry.conf.meta @@ -0,0 +1,7 @@ +MetaFileClass { + Name "{64BF38F9F92CE016}Configs/ERR/Content/Registry.conf" + Configurations { + ResourceClass PC { } + ResourceClass HEADLESS : PC { } + } +} diff --git a/addons/core/Configs/ERR/EveronSites.conf b/addons/core/Configs/ERR/EveronSites.conf index acac538..3b3c6fd 100644 --- a/addons/core/Configs/ERR/EveronSites.conf +++ b/addons/core/Configs/ERR/EveronSites.conf @@ -1,15 +1,17 @@ ERR_ScenarioDefinition { MinimumRadioSources 2 + ScoutCount 10 Sites { ERR_SiteDefinition { Id "everon-prison-1" + RandomClear 1 Label "Prison" Kind 0 Prefab "{BE6B8D56349038A6}Prefabs/ERR/Prison.et" Position 6090 0 7160 Guarded 1 GuardOffset 35 0 0 - Container "{40986810D0B1F4AE}Prefabs/ERR/SupplyCache.et" + Container "{D93480F29E73C1AB}Prefabs/ERR/PrisonBackpack.et" ContainerOffset -4 0 0 Stock { ERR_SiteStock { Prefab "{C0F7DD85A86B2900}Prefabs/Weapons/Handguns/PM/Handgun_PM.et" Count 5 } @@ -19,6 +21,7 @@ ERR_ScenarioDefinition { } ERR_SiteDefinition { Id "everon-supply-1" + AllowFactionPool 1 Label "Southern supply cache" Kind 4 Prefab "{7C3D16C17C24D351}Prefabs/ERR/SupplySite.et" @@ -38,11 +41,20 @@ ERR_ScenarioDefinition { } ERR_SiteDefinition { Id "everon-radio-1" + RandomClear 1 + RootHeight 0.8 Label "Radio station" DiscoverRadius 8 Kind 1 - Prefab "{F7E7E3D6ACF0C860}Prefabs/ERR/Radio.et" + Prefab "{47D725F56A904ACE}Prefabs/ERR/RadioCamp.et" Position 6500 0 7200 + Props { + ERR_SiteProp { Prefab "{53506DCB398C7EBE}Prefabs/Props/Furniture/Table_01.et" Offset 0 0 0 } + ERR_SiteProp { Prefab "{B3949F6D215A9F78}Prefabs/Props/Military/Antennas/Antenna_FIA_02/Antenna_FIA_02_base.et" Offset -7 0 2 } + ERR_SiteProp { Prefab "{BE5D5CFA90224646}Prefabs/Structures/Military/Camps/TentUSSR_01/TentUSSR_01_base.et" Offset -1.5 0 0 } + ERR_SiteProp { Prefab "{CFFEB23FBEA00322}Prefabs/Props/Military/Sandbags/Sandbag_01_short_burlap.et" Offset -4.5 0 -6 } + ERR_SiteProp { Prefab "{CFFEB23FBEA00322}Prefabs/Props/Military/Sandbags/Sandbag_01_short_burlap.et" Offset 4.5 0 -6 } + } Guarded 1 GuardOffset 30 0 0 } @@ -51,44 +63,12 @@ ERR_ScenarioDefinition { Label "Initial helicopter pickup site" Kind 2 Prefab "{033503110AC34EAC}Prefabs/ERR/Extraction.et" - Position 7000 0 7500 + Position 4800.953 0 11487.412 + AircraftOffset 0 0 0 + RequireAirfield 1 Guarded 1 GuardOffset 30 0 0 } - ERR_SiteDefinition { - Id "everon-supply-2" - Label "Western supply cache (vehicle maintenance)" - Kind 4 - Prefab "{7C3D16C17C24D351}Prefabs/ERR/SupplySite.et" - Position 5960 0 7220 - Guarded 1 - GuardOffset 16 0 0 - Patrol { - ERR_SitePatrolPoint { Offset 18 0 0 } - ERR_SitePatrolPoint { Offset 18 0 24 } - ERR_SitePatrolPoint { Offset -6 0 24 } - ERR_SitePatrolPoint { Offset -6 0 0 } - } - Container "{40986810D0B1F4AE}Prefabs/ERR/SupplyCache.et" - DiscoverRadius 25 - RevealOnEscape 1 - Transport ERR_SiteTransport { - Prefab "{FA65287C19DE304B}Prefabs/ERR/Vehicles/SupplyUAZ.et" - Offset 0 0 -8 - Heading 180 - ClearanceRadius 4 - MaximumTerrainRange 1 - } - Stock { - ERR_SiteStock { Prefab "{C09C7C97EFA34909}Prefabs/ERR/Items/EmergencyRepairKit.et" Count 1 } - ERR_SiteStock { Prefab "{12D5AD21E383B768}Prefabs/Items/Fuel/Jerrycan_01/Jerrycan_01_item.et" Count 2 } - ERR_SiteStock { Prefab "{FA5C25BF66A53DCF}Prefabs/Weapons/Rifles/AK74/Rifle_AK74.et" Count 4 } - ERR_SiteStock { Prefab "{BBB50A815A2F916B}Prefabs/Weapons/Magazines/Magazine_545x39_AK_30rnd_Ball.et" Count 8 } - ERR_SiteStock { Prefab "{C3F1FA1E2EC2B345}Prefabs/Items/Medicine/FieldDressing_01/FieldDressing_USSR_01.et" Count 6 } - ERR_SiteStock { Prefab "{13772C903CB5E4F7}Prefabs/Items/Equipment/Maps/Map_Paper_01/PaperMap_01_folded.et" Count 1 } - ERR_SiteStock { Prefab "{554159A6740C3F3E}Prefabs/ERR/ObjectiveDocument.et" Count 2 TargetId "everon-radio-1" } - } - } ERR_SiteDefinition { Id "everon-medical-1" Label "Medical stash" @@ -99,8 +79,9 @@ ERR_ScenarioDefinition { GuardOffset 16 0 0 Container "{40986810D0B1F4AE}Prefabs/ERR/SupplyCache.et" DiscoverRadius 25 - RevealOnEscape 0 + RevealOnEscape 1 Stock { + ERR_SiteStock { Prefab "{554159A6740C3F3E}Prefabs/ERR/ObjectiveDocument.et" Count 2 TargetId "everon-radio-1" } ERR_SiteStock { Prefab "{C3F1FA1E2EC2B345}Prefabs/Items/Medicine/FieldDressing_01/FieldDressing_USSR_01.et" Count 12 } ERR_SiteStock { Prefab "{527D7C5D2E476BDC}Prefabs/Items/Medicine/SalineBag_01/SalineBag_USSR_01.et" Count 6 } ERR_SiteStock { Prefab "{0D9A5DCF89AE7AA9}Prefabs/Items/Medicine/MorphineInjection_01/MorphineInjection_01.et" Count 6 } @@ -136,5 +117,41 @@ ERR_ScenarioDefinition { ERR_SiteProp { Prefab "{4B6AC1D8701C35A1}Prefabs/ERR/FieldCover.et" Offset 4 0 0 Angles 90 0 0 } } } + ERR_SiteDefinition { + Id "everon-mortar-1" + Label "Mortar site 1" + Kind 5 + Prefab "{7C3D16C17C24D351}Prefabs/ERR/SupplySite.et" + Position 6000 0 7000 + RandomClear 1 + DiscoverRadius 35 + } + ERR_SiteDefinition { + Id "everon-mortar-2" + Label "Mortar site 2" + Kind 5 + Prefab "{7C3D16C17C24D351}Prefabs/ERR/SupplySite.et" + Position 4500 0 6500 + RandomClear 1 + DiscoverRadius 35 + } + ERR_SiteDefinition { + Id "everon-mortar-3" + Label "Mortar site 3" + Kind 5 + Prefab "{7C3D16C17C24D351}Prefabs/ERR/SupplySite.et" + Position 7000 0 9500 + RandomClear 1 + DiscoverRadius 35 + } + ERR_SiteDefinition { + Id "everon-mortar-4" + Label "Mortar site 4" + Kind 5 + Prefab "{7C3D16C17C24D351}Prefabs/ERR/SupplySite.et" + Position 4000 0 10500 + RandomClear 1 + DiscoverRadius 35 + } } } diff --git a/addons/core/Configs/ERR/Tests/LegacySites.conf b/addons/core/Configs/ERR/Tests/LegacySites.conf new file mode 100644 index 0000000..8ce639b --- /dev/null +++ b/addons/core/Configs/ERR/Tests/LegacySites.conf @@ -0,0 +1,152 @@ +ERR_ScenarioDefinition { + MinimumRadioSources 2 + Sites { + ERR_SiteDefinition { + Id "everon-prison-1" + RandomClear 1 + Label "Prison" + Kind 0 + Prefab "{BE6B8D56349038A6}Prefabs/ERR/Prison.et" + Position 6090 0 7160 + Guarded 1 + GuardOffset 35 0 0 + Container "{D93480F29E73C1AB}Prefabs/ERR/PrisonBackpack.et" + ContainerOffset -4 0 0 + Stock { + ERR_SiteStock { Prefab "{C0F7DD85A86B2900}Prefabs/Weapons/Handguns/PM/Handgun_PM.et" Count 5 } + ERR_SiteStock { Prefab "{8B853CDD11BA916E}Prefabs/Weapons/Magazines/Magazine_9x18_PM_8rnd_Ball.et" Count 8 } + ERR_SiteStock { Prefab "{13772C903CB5E4F7}Prefabs/Items/Equipment/Maps/Map_Paper_01/PaperMap_01_folded.et" Count 1 } + } + } + ERR_SiteDefinition { + Id "everon-supply-1" + AllowFactionPool 1 + Label "Southern supply cache" + Kind 4 + Prefab "{7C3D16C17C24D351}Prefabs/ERR/SupplySite.et" + Position 6150 0 7050 + Guarded 1 + GuardOffset 18 0 0 + Container "{40986810D0B1F4AE}Prefabs/ERR/SupplyCache.et" + ContainerOffset 0 0 0 + Stock { + ERR_SiteStock { Prefab "{FA5C25BF66A53DCF}Prefabs/Weapons/Rifles/AK74/Rifle_AK74.et" Count 8 } + ERR_SiteStock { Prefab "{BBB50A815A2F916B}Prefabs/Weapons/Magazines/Magazine_545x39_AK_30rnd_Ball.et" Count 16 } + ERR_SiteStock { Prefab "{C3F1FA1E2EC2B345}Prefabs/Items/Medicine/FieldDressing_01/FieldDressing_USSR_01.et" Count 12 } + ERR_SiteStock { Prefab "{13772C903CB5E4F7}Prefabs/Items/Equipment/Maps/Map_Paper_01/PaperMap_01_folded.et" Count 1 } + ERR_SiteStock { Prefab "{554159A6740C3F3E}Prefabs/ERR/ObjectiveDocument.et" Count 2 TargetId "everon-radio-1" } + } + RevealOnEscape 1 + } + ERR_SiteDefinition { + Id "everon-radio-1" + RandomClear 1 + RootHeight 0.8 + Label "Radio station" + DiscoverRadius 8 + Kind 1 + Prefab "{47D725F56A904ACE}Prefabs/ERR/RadioCamp.et" + Position 6500 0 7200 + Props { + ERR_SiteProp { Prefab "{53506DCB398C7EBE}Prefabs/Props/Furniture/Table_01.et" Offset 0 0 0 } + ERR_SiteProp { Prefab "{B3949F6D215A9F78}Prefabs/Props/Military/Antennas/Antenna_FIA_02/Antenna_FIA_02_base.et" Offset -4 0 2 } + ERR_SiteProp { Prefab "{01AE5FD77A9A4C21}Prefabs/Structures/Military/Camps/TentSmallUS_01/TentSmallUS_01.et" Offset 4 0 3 } + ERR_SiteProp { Prefab "{CFFEB23FBEA00322}Prefabs/Props/Military/Sandbags/Sandbag_01_short_burlap.et" Offset -3 0 -4 } + ERR_SiteProp { Prefab "{CFFEB23FBEA00322}Prefabs/Props/Military/Sandbags/Sandbag_01_short_burlap.et" Offset 3 0 -4 } + } + Guarded 1 + GuardOffset 30 0 0 + } + ERR_SiteDefinition { + Id "everon-extraction-1" + Label "Initial helicopter pickup site" + Kind 2 + Prefab "{033503110AC34EAC}Prefabs/ERR/Extraction.et" + Position 7000 0 7500 + Guarded 1 + GuardOffset 30 0 0 + } + ERR_SiteDefinition { + Id "everon-supply-2" + AllowFactionPool 1 + Label "Western supply cache (vehicle maintenance)" + Kind 4 + Prefab "{7C3D16C17C24D351}Prefabs/ERR/SupplySite.et" + Position 5960 0 7220 + Guarded 1 + GuardOffset 16 0 0 + Patrol { + ERR_SitePatrolPoint { Offset 18 0 0 } + ERR_SitePatrolPoint { Offset 18 0 24 } + ERR_SitePatrolPoint { Offset -6 0 24 } + ERR_SitePatrolPoint { Offset -6 0 0 } + } + Container "{40986810D0B1F4AE}Prefabs/ERR/SupplyCache.et" + DiscoverRadius 25 + RevealOnEscape 1 + Transport ERR_SiteTransport { + Prefab "{FA65287C19DE304B}Prefabs/ERR/Vehicles/SupplyUAZ.et" + Offset 0 0 -8 + Heading 180 + ClearanceRadius 4 + MaximumTerrainRange 1 + } + Stock { + ERR_SiteStock { Prefab "{C09C7C97EFA34909}Prefabs/ERR/Items/EmergencyRepairKit.et" Count 1 } + ERR_SiteStock { Prefab "{12D5AD21E383B768}Prefabs/Items/Fuel/Jerrycan_01/Jerrycan_01_item.et" Count 2 } + ERR_SiteStock { Prefab "{FA5C25BF66A53DCF}Prefabs/Weapons/Rifles/AK74/Rifle_AK74.et" Count 4 } + ERR_SiteStock { Prefab "{BBB50A815A2F916B}Prefabs/Weapons/Magazines/Magazine_545x39_AK_30rnd_Ball.et" Count 8 } + ERR_SiteStock { Prefab "{C3F1FA1E2EC2B345}Prefabs/Items/Medicine/FieldDressing_01/FieldDressing_USSR_01.et" Count 6 } + ERR_SiteStock { Prefab "{13772C903CB5E4F7}Prefabs/Items/Equipment/Maps/Map_Paper_01/PaperMap_01_folded.et" Count 1 } + ERR_SiteStock { Prefab "{554159A6740C3F3E}Prefabs/ERR/ObjectiveDocument.et" Count 2 TargetId "everon-radio-1" } + } + } + ERR_SiteDefinition { + Id "everon-medical-1" + Label "Medical stash" + Kind 4 + Prefab "{7C3D16C17C24D351}Prefabs/ERR/SupplySite.et" + Position 6340 0 7400 + Guarded 0 + GuardOffset 16 0 0 + Container "{40986810D0B1F4AE}Prefabs/ERR/SupplyCache.et" + DiscoverRadius 25 + RevealOnEscape 0 + Stock { + ERR_SiteStock { Prefab "{C3F1FA1E2EC2B345}Prefabs/Items/Medicine/FieldDressing_01/FieldDressing_USSR_01.et" Count 12 } + ERR_SiteStock { Prefab "{527D7C5D2E476BDC}Prefabs/Items/Medicine/SalineBag_01/SalineBag_USSR_01.et" Count 6 } + ERR_SiteStock { Prefab "{0D9A5DCF89AE7AA9}Prefabs/Items/Medicine/MorphineInjection_01/MorphineInjection_01.et" Count 6 } + ERR_SiteStock { Prefab "{13772C903CB5E4F7}Prefabs/Items/Equipment/Maps/Map_Paper_01/PaperMap_01_folded.et" Count 3 } + } + } + ERR_SiteDefinition { + Id "everon-outpost-1" + Label "Eastern guard outpost" + Kind 3 + Prefab "{7C3D16C17C24D351}Prefabs/ERR/SupplySite.et" + Position 6740 0 7300 + Guarded 1 + GuardOffset 16 0 0 + Patrol { + ERR_SitePatrolPoint { Offset 18 0 0 } + ERR_SitePatrolPoint { Offset 18 0 24 } + ERR_SitePatrolPoint { Offset -6 0 24 } + ERR_SitePatrolPoint { Offset -6 0 0 } + } + Container "{40986810D0B1F4AE}Prefabs/ERR/SupplyCache.et" + DiscoverRadius 35 + RevealOnEscape 0 + Stock { + ERR_SiteStock { Prefab "{FA5C25BF66A53DCF}Prefabs/Weapons/Rifles/AK74/Rifle_AK74.et" Count 2 } + ERR_SiteStock { Prefab "{BBB50A815A2F916B}Prefabs/Weapons/Magazines/Magazine_545x39_AK_30rnd_Ball.et" Count 10 } + ERR_SiteStock { Prefab "{243948B23D90BECB}Prefabs/Items/Equipment/Binoculars/Binoculars_B8/Binoculars_B8.et" Count 2 } + ERR_SiteStock { Prefab "{C3F1FA1E2EC2B345}Prefabs/Items/Medicine/FieldDressing_01/FieldDressing_USSR_01.et" Count 4 } + } + Props { + ERR_SiteProp { Prefab "{4B6AC1D8701C35A1}Prefabs/ERR/FieldCover.et" Offset 0 0 -4 } + ERR_SiteProp { Prefab "{4B6AC1D8701C35A1}Prefabs/ERR/FieldCover.et" Offset -4 0 0 Angles 90 0 0 } + ERR_SiteProp { Prefab "{4B6AC1D8701C35A1}Prefabs/ERR/FieldCover.et" Offset 4 0 0 Angles 90 0 0 } + } + } + } +} diff --git a/addons/core/Configs/ERR/Tests/LegacySites.conf.meta b/addons/core/Configs/ERR/Tests/LegacySites.conf.meta new file mode 100644 index 0000000..f8fc44b --- /dev/null +++ b/addons/core/Configs/ERR/Tests/LegacySites.conf.meta @@ -0,0 +1,7 @@ +MetaFileClass { + Name "{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf" + Configurations { + ResourceClass PC {} + ResourceClass HEADLESS : PC {} + } +} diff --git a/addons/core/Prefabs/ERR/AssaultParachute.et b/addons/core/Prefabs/ERR/AssaultParachute.et new file mode 100644 index 0000000..6c87bbc --- /dev/null +++ b/addons/core/Prefabs/ERR/AssaultParachute.et @@ -0,0 +1,10 @@ +ERR_AssaultParachute : "{9ADD554959C09197}Prefabs/DeployedParachutes/Core/DeployedParachuteMK4.et" { + ID "C31F210ABE0781A1" + components { + SCR_AIVehicleUsageComponent "{C31F210ABE0781A2}" { + m_eVehicleType AIRCRAFT_HELICOPTER + m_bCanBePiloted 0 + } + } + m_DebugMode 0 +} diff --git a/addons/core/Prefabs/ERR/AssaultParachute.et.meta b/addons/core/Prefabs/ERR/AssaultParachute.et.meta new file mode 100644 index 0000000..cfdaeb2 --- /dev/null +++ b/addons/core/Prefabs/ERR/AssaultParachute.et.meta @@ -0,0 +1,7 @@ +MetaFileClass { + Name "{C31F210ABE0781A0}Prefabs/ERR/AssaultParachute.et" + Configurations { + EntityTemplateResourceClass PC {} + EntityTemplateResourceClass HEADLESS {} + } +} diff --git a/addons/core/Prefabs/ERR/FactionManager.et b/addons/core/Prefabs/ERR/FactionManager.et index d944b02..c55544a 100644 --- a/addons/core/Prefabs/ERR/FactionManager.et +++ b/addons/core/Prefabs/ERR/FactionManager.et @@ -1,11 +1,27 @@ SCR_FactionManager : "{A242612865F2A48E}Prefabs/MP/Managers/Factions/FactionManager_Base.et" { ID "9D8B772E349A5ED5" + m_bCanChangeFactionsPlayable 1 Factions { + SCR_Faction : "{8053DB656DD18B14}Configs/Factions/FIA.conf" { + m_aFriendlyFactionsIds { } + } SCR_Faction : "{5C67A1DDE0044806}Configs/ERR/Factions/US.conf" { m_sFactionBackground "{F8AC305446D5D480}UI/Textures/DeployMenu/Loadout-BG/Loadout_US_BG.imageset" } SCR_Faction : "{D7A5CFA62A36480D}Configs/ERR/Factions/USSR.conf" { m_sFactionBackground "{21533F2089005056}UI/Textures/DeployMenu/Loadout-BG/Loadout_BG_USSR.imageset" } + SCR_Faction : "{F33C7BF22CBF3A8A}Configs/Factions/RHS_US_USMC.conf" { + m_aFriendlyFactionsIds { } + m_bIsPlayable 0 + } + SCR_Faction : "{627A8CDE681D223D}Configs/Factions/RHS_RF_MSV.conf" { + m_aFriendlyFactionsIds { } + m_bIsPlayable 0 + } + SCR_Faction : "{CB76A36EACFBDF35}Configs/Factions/RHS_ION.conf" { + m_aFriendlyFactionsIds { } + m_bIsPlayable 0 + } } } diff --git a/addons/core/Prefabs/ERR/PrisonBackpack.et b/addons/core/Prefabs/ERR/PrisonBackpack.et new file mode 100644 index 0000000..309e68c --- /dev/null +++ b/addons/core/Prefabs/ERR/PrisonBackpack.et @@ -0,0 +1,7 @@ +GenericEntity : "{41A9C55B61F375F0}Prefabs/Items/Equipment/Backpacks/Backpack_Kolobok.et" { + ID "ED14790A26F68453" + components { + InventoryStorageManagerComponent "{BF182CDF2D660373}" { + } + } +} diff --git a/addons/core/Prefabs/ERR/PrisonBackpack.et.meta b/addons/core/Prefabs/ERR/PrisonBackpack.et.meta new file mode 100644 index 0000000..89dd379 --- /dev/null +++ b/addons/core/Prefabs/ERR/PrisonBackpack.et.meta @@ -0,0 +1,9 @@ +MetaFileClass { + Name "{D93480F29E73C1AB}Prefabs/ERR/PrisonBackpack.et" + Configurations { + ResourceClass PC { + } + ResourceClass HEADLESS : PC { + } + } +} diff --git a/addons/core/Prefabs/ERR/PrisonPatrol.et b/addons/core/Prefabs/ERR/PrisonPatrol.et new file mode 100644 index 0000000..efde958 --- /dev/null +++ b/addons/core/Prefabs/ERR/PrisonPatrol.et @@ -0,0 +1,7 @@ +SCR_AIGroup : "{000CD338713F2B5A}Prefabs/AI/Groups/Group_Base.et" { + ID "B3AC840A219FD076" + m_faction "USSR" + m_bSpawnImmediately 0 + m_aUnitPrefabSlots { + } +} diff --git a/addons/core/Prefabs/ERR/PrisonPatrol.et.meta b/addons/core/Prefabs/ERR/PrisonPatrol.et.meta new file mode 100644 index 0000000..5e91185 --- /dev/null +++ b/addons/core/Prefabs/ERR/PrisonPatrol.et.meta @@ -0,0 +1,7 @@ +MetaFileClass { + Name "{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et" + Configurations { + EntityTemplateResourceClass PC {} + EntityTemplateResourceClass HEADLESS {} + } +} diff --git a/addons/core/Prefabs/ERR/RadioCamp.et b/addons/core/Prefabs/ERR/RadioCamp.et new file mode 100644 index 0000000..8d8b3b6 --- /dev/null +++ b/addons/core/Prefabs/ERR/RadioCamp.et @@ -0,0 +1,3 @@ +GenericEntity : "{F7E7E3D6ACF0C860}Prefabs/ERR/Radio.et" { + ID "B7AD3B63842692C1" +} diff --git a/addons/core/Prefabs/ERR/RadioCamp.et.meta b/addons/core/Prefabs/ERR/RadioCamp.et.meta new file mode 100644 index 0000000..af5c238 --- /dev/null +++ b/addons/core/Prefabs/ERR/RadioCamp.et.meta @@ -0,0 +1,7 @@ +MetaFileClass { + Name "{47D725F56A904ACE}Prefabs/ERR/RadioCamp.et" + Configurations { + EntityTemplateResourceClass PC {} + EntityTemplateResourceClass HEADLESS : PC {} + } +} diff --git a/addons/core/Prefabs/ERR/ReconCrew.et b/addons/core/Prefabs/ERR/ReconCrew.et new file mode 100644 index 0000000..8aa349b --- /dev/null +++ b/addons/core/Prefabs/ERR/ReconCrew.et @@ -0,0 +1,7 @@ +SCR_AIGroup : "{000CD338713F2B5A}Prefabs/AI/Groups/Group_Base.et" { + ID "C31F210ABE078196" + m_faction "USSR" + m_bSpawnImmediately 0 + m_aUnitPrefabSlots { + } +} diff --git a/addons/core/Prefabs/ERR/ReconCrew.et.meta b/addons/core/Prefabs/ERR/ReconCrew.et.meta new file mode 100644 index 0000000..12dde5b --- /dev/null +++ b/addons/core/Prefabs/ERR/ReconCrew.et.meta @@ -0,0 +1,7 @@ +MetaFileClass { + Name "{C31F210ABE078195}Prefabs/ERR/ReconCrew.et" + Configurations { + EntityTemplateResourceClass PC {} + EntityTemplateResourceClass HEADLESS {} + } +} diff --git a/addons/core/Prefabs/ERR/ReconHelicopter.et b/addons/core/Prefabs/ERR/ReconHelicopter.et new file mode 100644 index 0000000..b9d6c49 --- /dev/null +++ b/addons/core/Prefabs/ERR/ReconHelicopter.et @@ -0,0 +1,14 @@ +Vehicle : "{7BD282AF716ED639}Prefabs/Vehicles/Helicopters/Mi8MT/Mi8MT_armed.et" { + ID "C31F210ABE078193" + components { + ERR_ReconFlightComponent "{C31F210ABE078194}" { + } + VehicleHelicopterSimulation "{51FAEE57F7DA270E}" { + Simulation Helicopter "{51FAED9EF37A2D29}" { + Engine HelicopterEngine "{51FAED9EF37A2D2B}" { + StartUpTime 0 + } + } + } + } +} diff --git a/addons/core/Prefabs/ERR/ReconHelicopter.et.meta b/addons/core/Prefabs/ERR/ReconHelicopter.et.meta new file mode 100644 index 0000000..cd7a19e --- /dev/null +++ b/addons/core/Prefabs/ERR/ReconHelicopter.et.meta @@ -0,0 +1,7 @@ +MetaFileClass { + Name "{C31F210ABE078192}Prefabs/ERR/ReconHelicopter.et" + Configurations { + EntityTemplateResourceClass PC {} + EntityTemplateResourceClass HEADLESS {} + } +} diff --git a/addons/core/Prefabs/ERR/ReconPilotEast.et b/addons/core/Prefabs/ERR/ReconPilotEast.et new file mode 100644 index 0000000..1e7e5bf --- /dev/null +++ b/addons/core/Prefabs/ERR/ReconPilotEast.et @@ -0,0 +1,12 @@ +SCR_ChimeraCharacter : "{A62FA97C4EC64F14}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_HeliPilot.et" { + ID "C31F210ABE078198" + components { + BaseLoadoutManagerComponent "{520EA1D2DB11821E}" { + Slots { + LoadoutSlotInfo Back { + Prefab "{54C68E438DD34265}Prefabs/Items/Equipment/Radios/Radio_R107M.et" + } + } + } + } +} diff --git a/addons/core/Prefabs/ERR/ReconPilotEast.et.meta b/addons/core/Prefabs/ERR/ReconPilotEast.et.meta new file mode 100644 index 0000000..c1365a3 --- /dev/null +++ b/addons/core/Prefabs/ERR/ReconPilotEast.et.meta @@ -0,0 +1,7 @@ +MetaFileClass { + Name "{C31F210ABE078197}Prefabs/ERR/ReconPilotEast.et" + Configurations { + EntityTemplateResourceClass PC {} + EntityTemplateResourceClass HEADLESS {} + } +} diff --git a/addons/core/Prefabs/ERR/ReconPilotWest.et b/addons/core/Prefabs/ERR/ReconPilotWest.et new file mode 100644 index 0000000..c8d6d8b --- /dev/null +++ b/addons/core/Prefabs/ERR/ReconPilotWest.et @@ -0,0 +1,12 @@ +SCR_ChimeraCharacter : "{42A502E3BB727CEB}Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_HeliPilot.et" { + ID "C31F210ABE07819A" + components { + BaseLoadoutManagerComponent "{520EA1D2DB11821E}" { + Slots { + LoadoutSlotInfo Back { + Prefab "{54C68E438DD34265}Prefabs/Items/Equipment/Radios/Radio_R107M.et" + } + } + } + } +} diff --git a/addons/core/Prefabs/ERR/ReconPilotWest.et.meta b/addons/core/Prefabs/ERR/ReconPilotWest.et.meta new file mode 100644 index 0000000..120fb96 --- /dev/null +++ b/addons/core/Prefabs/ERR/ReconPilotWest.et.meta @@ -0,0 +1,7 @@ +MetaFileClass { + Name "{C31F210ABE078199}Prefabs/ERR/ReconPilotWest.et" + Configurations { + EntityTemplateResourceClass PC {} + EntityTemplateResourceClass HEADLESS {} + } +} diff --git a/addons/core/Prefabs/ERR/ScoutEast.et b/addons/core/Prefabs/ERR/ScoutEast.et new file mode 100644 index 0000000..26e6cc2 --- /dev/null +++ b/addons/core/Prefabs/ERR/ScoutEast.et @@ -0,0 +1,23 @@ +SCR_ChimeraCharacter : "{976AC400219898FA}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_Sharpshooter.et" { + ID "EF871ED195094E29" + components { + SCR_CharacterInventoryStorageComponent "{520EA1D2DB118DAC}" { + components { + SCR_EquipmentStorageComponent "{56B49DA9722B635D}" { + InitialStorageSlots { + SCR_EquipmentStorageSlot BinocularSlot { + Prefab "{243948B23D90BECB}Prefabs/Items/Equipment/Binoculars/Binoculars_B8/Binoculars_B8.et" + } + } + } + } + } + BaseLoadoutManagerComponent "{520EA1D2DB11821E}" { + Slots { + LoadoutSlotInfo Back { + Prefab "{CF862ED195094E23}Prefabs/ERR/ScoutRadioPack.et" + } + } + } + } +} diff --git a/addons/core/Prefabs/ERR/ScoutEast.et.meta b/addons/core/Prefabs/ERR/ScoutEast.et.meta new file mode 100644 index 0000000..bcb0f02 --- /dev/null +++ b/addons/core/Prefabs/ERR/ScoutEast.et.meta @@ -0,0 +1,7 @@ +MetaFileClass { + Name "{CF862ED195094E28}Prefabs/ERR/ScoutEast.et" + Configurations { + EntityTemplateResourceClass PC {} + EntityTemplateResourceClass HEADLESS {} + } +} diff --git a/addons/core/Prefabs/ERR/ScoutGroup.et b/addons/core/Prefabs/ERR/ScoutGroup.et new file mode 100644 index 0000000..1fe336f --- /dev/null +++ b/addons/core/Prefabs/ERR/ScoutGroup.et @@ -0,0 +1,7 @@ +SCR_AIGroup : "{000CD338713F2B5A}Prefabs/AI/Groups/Group_Base.et" { + ID "EF871ED195094E22" + m_faction "USSR" + m_bSpawnImmediately 0 + m_aUnitPrefabSlots { + } +} diff --git a/addons/core/Prefabs/ERR/ScoutGroup.et.meta b/addons/core/Prefabs/ERR/ScoutGroup.et.meta new file mode 100644 index 0000000..5f7f6dc --- /dev/null +++ b/addons/core/Prefabs/ERR/ScoutGroup.et.meta @@ -0,0 +1,7 @@ +MetaFileClass { + Name "{DF862ED195094E21}Prefabs/ERR/ScoutGroup.et" + Configurations { + EntityTemplateResourceClass PC {} + EntityTemplateResourceClass HEADLESS {} + } +} diff --git a/addons/core/Prefabs/ERR/ScoutRadioPack.et b/addons/core/Prefabs/ERR/ScoutRadioPack.et new file mode 100644 index 0000000..525455f --- /dev/null +++ b/addons/core/Prefabs/ERR/ScoutRadioPack.et @@ -0,0 +1,34 @@ +GenericEntity : "{41A9C55B61F375F0}Prefabs/Items/Equipment/Backpacks/Backpack_Kolobok.et" { + ID "EF871ED195094E24" + components { + BaseRadioComponent "{EF871ED195094E25}" { + "Turned on" 1 + Transceivers { + RadioTransceiver "{EF871ED195094E26}" { + ChannelFrequency 48000 + "Transmitting Range" 2000 + "Min tunable frequency" 20000 + "Max tunable frequency" 76000 + "Frequency resolution" 200 + } + } + } + SCR_RadioComponent "{EF871ED195094E27}" { + m_bCanBeHeld 0 + m_iRadioCategory MANPACK + m_iRadioType R107M + } + SCR_UniversalInventoryStorageComponent "{5222CB03A40528FA}" { + Attributes SCR_ItemAttributeCollection "{5222CB039E9715D1}" { + ItemDisplayName UIInfo "{5222CB03904B414C}" { + Name "Scout radio pack" + Description "Long-range field radio with a compartment for spare rifle magazines." + } + ItemPhysAttributes ItemPhysicalAttributes PhysicalAttributes { + Weight 10 + } + } + MaxCumulativeVolume 6000 + } + } +} diff --git a/addons/core/Prefabs/ERR/ScoutRadioPack.et.meta b/addons/core/Prefabs/ERR/ScoutRadioPack.et.meta new file mode 100644 index 0000000..6521a5c --- /dev/null +++ b/addons/core/Prefabs/ERR/ScoutRadioPack.et.meta @@ -0,0 +1,7 @@ +MetaFileClass { + Name "{CF862ED195094E23}Prefabs/ERR/ScoutRadioPack.et" + Configurations { + EntityTemplateResourceClass PC {} + EntityTemplateResourceClass HEADLESS {} + } +} diff --git a/addons/core/Prefabs/ERR/ScoutWest.et b/addons/core/Prefabs/ERR/ScoutWest.et new file mode 100644 index 0000000..960ca7d --- /dev/null +++ b/addons/core/Prefabs/ERR/ScoutWest.et @@ -0,0 +1,23 @@ +SCR_ChimeraCharacter : "{0F6689B491641155}Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_Sniper.et" { + ID "EF871ED195094E2B" + components { + SCR_CharacterInventoryStorageComponent "{520EA1D2DB118DAC}" { + components { + SCR_EquipmentStorageComponent "{56B49DA9722B635D}" { + InitialStorageSlots { + SCR_EquipmentStorageSlot BinocularSlot { + Prefab "{243948B23D90BECB}Prefabs/Items/Equipment/Binoculars/Binoculars_B8/Binoculars_B8.et" + } + } + } + } + } + BaseLoadoutManagerComponent "{520EA1D2DB11821E}" { + Slots { + LoadoutSlotInfo Back { + Prefab "{CF862ED195094E23}Prefabs/ERR/ScoutRadioPack.et" + } + } + } + } +} diff --git a/addons/core/Prefabs/ERR/ScoutWest.et.meta b/addons/core/Prefabs/ERR/ScoutWest.et.meta new file mode 100644 index 0000000..a0b29b7 --- /dev/null +++ b/addons/core/Prefabs/ERR/ScoutWest.et.meta @@ -0,0 +1,7 @@ +MetaFileClass { + Name "{CF862ED195094E2A}Prefabs/ERR/ScoutWest.et" + Configurations { + EntityTemplateResourceClass PC {} + EntityTemplateResourceClass HEADLESS {} + } +} diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_Actors.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_Actors.c index cba3e85..251753c 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_Actors.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_Actors.c @@ -38,9 +38,27 @@ class ERR_Actors params.TransformMode = ETransformMode.WORLD; Math3D.AnglesToMatrix(angles, params.Transform); params.Transform[3] = position; - return GetGame().SpawnEntityPrefab(resource, GetGame().GetWorld(), params); + IEntity entity = GetGame().SpawnEntityPrefab(resource, GetGame().GetWorld(), params); + ERR_GameMode mode = ERR_GameMode.Get(); + if (mode && mode.FactionSelection() && mode.FactionSelection().Ready() && entity) + { + SCR_AIGroup group = SCR_AIGroup.Cast(entity); + if (group && (prefab.Contains("Prefabs/ERR/ConvoyCrew.et") || prefab.Contains("Prefabs/ERR/MortarCrew.et"))) group.SetFaction(mode.EnemyFaction()); + if (prefab == mode.Factions().PrisonerPrefab) SetFaction(entity, mode.PlayerFaction()); + } + return entity; } + static bool SetFaction(IEntity entity, Faction faction) + { + if (!Replication.IsServer() || !entity || !faction) return false; + // Characters derive directly from the native base, not SCR_FactionAffiliationComponent. + FactionAffiliationComponent affiliation = FactionAffiliationComponent.Cast(entity.FindComponent(FactionAffiliationComponent)); + if (!affiliation) return false; + affiliation.SetAffiliatedFaction(faction); + return affiliation.GetAffiliatedFaction() == faction; + } + static void DeleteSpawned(IEntity entity) { if (!Replication.IsServer() || !entity || Player(entity)) return; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c index 3168352..1fc8d70 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c @@ -8,12 +8,12 @@ class ERR_AdminMenuFields { static array Keys() { - return {"radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres"}; + return {"radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount"}; } static bool Available(string key, bool hq, bool intel) { if (key == "aiIntelChancePercent") return intel; - if (key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres") return hq; + if (key == "convoyCount" || key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres") return hq; return Keys().Contains(key); } // A short journal has no older off-screen lines, even if its slider is zero. @@ -30,6 +30,7 @@ class ERR_AdminMenuFields } static string Display(string key, string value) { + if (value == "-1" || value == "-2") return ERR_Settings.SelectionText(value.ToInt()); if (key == "startTimeMinutes") return ERR_TimeService.Clock(value.ToInt()); if (key == "startDateCode") return ERR_EnvironmentService.Date(value.ToInt()); if (key == "weatherProfile") return ERR_EnvironmentService.ProfileName(value.ToInt()); @@ -48,6 +49,7 @@ class ERR_AdminMenuFields else if (key == "reportIntervalSeconds") prefix = "hq interval"; else if (key == "supportSquadLimit") prefix = "hq squads"; else if (key == "responseRadiusMetres") prefix = "hq radius"; + else if (key == "convoyCount") prefix = "hq convoy count"; value = value.Trim(); if (prefix.IsEmpty() || value.IsEmpty() || value.Length() > 32) return ""; return prefix + " set " + value; @@ -96,8 +98,13 @@ class ERR_AdminMenu : ChimeraMenuBase m_Controller = SCR_PlayerController.Cast(GetGame().GetPlayerController()); m_Mode = ERR_GameMode.Cast(GetGame().GetGameMode()); if (!m_Controller) { Close(); return; } - array names = {"Close", "OpenHQ", "BackSettings", "OpenJournal", "BackHQ", "PauseJournal", "LatestJournal", "IntelSummary", "RerollIntel"}; - foreach (string key : ERR_AdminMenuFields.Keys()) names.Insert("Apply_" + key); + array names = {"Close", "OpenSettings", "OpenWorld", "OpenFactions", "OpenHQ", "OpenJournal", "PauseJournal", "LatestJournal", "IntelSummary", "RerollIntel", "OpenMods", "OpenIntel", "OpenStatus"}; + names.Insert("RandomDay_startTimeMinutes"); + foreach (string key : ERR_AdminMenuFields.Keys()) + { + names.Insert("Apply_" + key); + names.Insert("Random_" + key); + } foreach (string name : names) { Widget widget = GetRootWidget().FindAnyWidget(name); @@ -177,7 +184,7 @@ class ERR_AdminMenu : ChimeraMenuBase SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent(key, GetRootWidget()); if (edit && edit.IsInWriteMode()) return; } - if (m_Page > 0) ShowPage(m_Page - 1); + if (m_Page != 0) ShowPage(0); else Close(); } protected void SetText(string name, string value) @@ -185,28 +192,35 @@ class ERR_AdminMenu : ChimeraMenuBase TextWidget widget = TextWidget.Cast(GetRootWidget().FindAnyWidget(name)); if (widget) widget.SetText(value); } + // Stable page order is also used by the shared navigation bar and native probe. + static array Pages() { return {"SettingsPage", "HQPage", "JournalPage", "ModsPage", "FactionsPage", "WorldPage", "IntelPage", "StatusPage"}; } + static array Navigation() { return {"OpenSettings", "OpenHQ", "OpenJournal", "OpenMods", "OpenFactions", "OpenWorld", "OpenIntel", "OpenStatus"}; } protected void ShowPage(int page) { + array pages = Pages(); + array navigation = Navigation(); + if (page < 0 || page >= pages.Count()) return; m_Page = page; - GetRootWidget().FindAnyWidget("SettingsPage").SetVisible(page == 0); - GetRootWidget().FindAnyWidget("HQPage").SetVisible(page == 1); - GetRootWidget().FindAnyWidget("JournalPage").SetVisible(page == 2); - if (page == 0) SetText("Title", "Mission settings"); - if (page == 1) SetText("Title", "HQ and mission status"); - if (page == 2) { SetText("Title", "HQ activity journal"); m_LogPoll = 1; } + for (int i = 0; i < pages.Count(); i++) + { + GetRootWidget().FindAnyWidget(pages[i]).SetVisible(i == page); + GetRootWidget().FindAnyWidget("Selected_" + navigation[i]).SetVisible(i == page); + } + if (page == 2) m_LogPoll = 1; } protected void UpdateActions() { foreach (SCR_ButtonBaseComponent button : m_Buttons) { string name = button.GetRootWidget().GetName(); - if (name.IndexOf("Apply_") == 0) + if (name.IndexOf("Apply_") == 0 || name.IndexOf("Random_") == 0 || name.IndexOf("RandomDay_") == 0) { - string key = name.Substring(6, name.Length() - 6); + int prefix = name.IndexOf("_") + 1; + string key = name.Substring(prefix, name.Length() - prefix); button.SetEnabled(m_Ready && m_AvailableFields.Contains(key) && ERR_AdminMenuFields.Available(key, m_HQ, m_Intel)); } else if (name == "RerollIntel" || name == "IntelSummary") button.SetEnabled(m_Ready && m_Intel); - else if (name == "OpenJournal" || name == "LatestJournal") button.SetEnabled(m_Ready && m_HQ); + else if (name == "LatestJournal") button.SetEnabled(m_Ready && m_HQ); } } void ReceiveSnapshot(bool authorized, array values, array status) @@ -221,6 +235,12 @@ class ERR_AdminMenu : ChimeraMenuBase int divider = entry.IndexOf("="); if (divider <= 0) continue; string key = entry.Substring(0, divider); + if (key.IndexOf("resolved_") == 0) + { + string resolvedKey = key.Substring(9, key.Length() - 9); + if (ERR_AdminMenuFields.Keys().Contains(resolvedKey)) SetText("Saved_" + resolvedKey, "Saved: Random (using " + entry.Substring(divider + 1, entry.Length() - divider - 1) + ")"); + continue; + } if (!ERR_AdminMenuFields.Keys().Contains(key)) continue; string value = ERR_AdminMenuFields.Display(key, entry.Substring(divider + 1, entry.Length() - divider - 1)); m_AvailableFields.Insert(key); @@ -290,9 +310,8 @@ class ERR_AdminMenu : ChimeraMenuBase if (!button || !IsFocused()) return; string name = button.GetRootWidget().GetName(); if (name == "Close") { Close(); return; } - if (name == "OpenHQ" || name == "BackHQ") { ShowPage(1); return; } - if (name == "BackSettings") { ShowPage(0); return; } - if (name == "OpenJournal") { if (m_Ready && m_HQ) ShowPage(2); return; } + int page = Navigation().Find(name); + if (page >= 0) { ShowPage(page); return; } if (name == "PauseJournal") { m_LogPaused = !m_LogPaused; @@ -319,6 +338,21 @@ class ERR_AdminMenu : ChimeraMenuBase if (pause) pause.SetText("Pause live"); return; } + if (name.IndexOf("Random_") == 0 || name.IndexOf("RandomDay_") == 0) + { + int prefix = name.IndexOf("_") + 1; + string key = name.Substring(prefix, name.Length() - prefix); + if (!m_AvailableFields.Contains(key) || !ERR_AdminMenuFields.Available(key, m_HQ, m_Intel)) return; + SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent(key, GetRootWidget()); + if (edit) + { + string choice = "Random"; + if (name.IndexOf("RandomDay_") == 0) choice = "Random day"; + edit.SetValue(choice); + SetText("Feedback", choice + " selected. Press Apply to save."); + } + return; + } string command; if (name == "IntelSummary" && m_Intel) command = "intel show"; if (name == "RerollIntel" && m_Intel) command = "intel reroll"; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminPlayerTools.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminPlayerTools.c index 881eee3..ecb19a4 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminPlayerTools.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminPlayerTools.c @@ -78,8 +78,15 @@ class ERR_AdminPlayerTools args = args.Trim(); args.ToLower(); bool godCommand = args == "help god" || args == "god" || args == "ungod" || args.StartsWith("god "); bool itemCommand = args == "help cheat" || args == "cheat" || args.StartsWith("cheat "); - if (!godCommand && !itemCommand) return false; + bool killCommand = args == "killais" || args == "help killais"; + if (!godCommand && !itemCommand && !killCommand) return false; + if (args == "help killais") { replies.Insert("/esc killais: kill AI characters within 1000 m of your living character. Players and vehicles are preserved."); return true; } IEntity actor = CurrentBody(mode, controller); + if (killCommand && actor) + { + replies.Insert(string.Format("Killed %1 AI characters within 1000 m.", ERR_DebugAI.KillNearby(mode, actor))); + return true; + } if (!actor) { Stop(); @@ -127,8 +134,14 @@ class ERR_AdminPlayerTools } string item; if (args.Length() > 6) item = args.Substring(6, args.Length() - 6); + if (item == "intel") + { + if (ERR_DebugIntel.SpawnSix(mode, actor)) replies.Insert("Spawned 6 intel documents on the ground nearby. Pick them up to recover intel normally."); + else replies.Insert("Could not place 6 intel documents. Stand on clear ground outside a vehicle, with room nearby."); + return true; + } ResourceName prefab = ItemPrefab(item); - if (prefab.IsEmpty()) { replies.Insert("Use /esc cheat map | /esc cheat helmet | /esc cheat uniform (vanilla paper map, PASGT helmet or BDU jacket)."); return true; } + if (prefab.IsEmpty()) { replies.Insert("Use /esc cheat map | /esc cheat helmet | /esc cheat uniform | /esc cheat intel (6 documents on nearby ground; other items: vanilla paper map, PASGT helmet or BDU jacket)."); return true; } if (m_Grants.TimedOut) { replies.Insert("An inventory insertion is unresolved; further item grants are blocked for this character."); return true; } if (m_Grants.Operation) { replies.Insert("An item grant is already pending; wait for its result."); return true; } SCR_InventoryStorageManagerComponent inventory = SCR_InventoryStorageManagerComponent.Cast(actor.FindComponent(SCR_InventoryStorageManagerComponent)); diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c index ee75fc6..2fa9358 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c @@ -8,6 +8,15 @@ class ERR_CommandArguments if (section == "intel" && option == "reveal") return "intelRevealChancePercent"; return ""; } + static bool Selection(string key, string text, out int value) + { + if (text == "random" && ERR_Settings.Randomizable(key)) { value = -1; return true; } + if ((text == "random-day" || text == "random day") && key == "startTimeMinutes") { value = -2; return true; } + if (key == "startTimeMinutes") return ERR_TimeService.ParseClock(text, value); + if (key == "startDateCode") return ERR_EnvironmentService.ParseDate(text, value); + if (key == "weatherProfile") return ERR_EnvironmentService.ParseProfile(text, value); + return Integer(text, value); + } static bool Integer(string text, out int value) { if (text.IsEmpty() || text.Length() > 6) return false; @@ -79,35 +88,36 @@ class ERR_AdminService if (parts.Count() == 3 && parts[2] == "show") { if (mode.Time()) replies.Insert(mode.Time().Status()); - replies.Insert("Next round: " + ERR_TimeService.Clock(mode.Settings().Get("startTimeMinutes")) + ", " + mode.Settings().Get("timeMultiplier").ToString() + "x progression."); + replies.Insert("Next round: " + ERR_AdminMenuFields.Display("startTimeMinutes", mode.Settings().Get("startTimeMinutes").ToString()) + ", " + ERR_Settings.SelectionText(mode.Settings().Get("timeMultiplier")) + " progression."); return true; } int value; bool valid; if (parts.Count() == 4 && parts[2] == "set") { - if (key == "startTimeMinutes") valid = ERR_TimeService.ParseClock(parts[3], value); - else valid = ERR_CommandArguments.Integer(parts[3], value); + valid = ERR_CommandArguments.Selection(key, parts[3], value); } + if (parts.Count() == 5 && parts[2] == "set" && parts[3] == "random" && parts[4] == "day") + valid = ERR_CommandArguments.Selection(key, "random day", value); if (valid && mode.Settings().Set(key, value)) - replies.Insert("Saved for next round: " + ERR_TimeService.Clock(mode.Settings().Get("startTimeMinutes")) + ", " + mode.Settings().Get("timeMultiplier").ToString() + "x progression. Current round clock unchanged."); - else replies.Insert("Invalid time or settings could not be saved. Use mission time set HH:MM (00:00-23:59) or mission speed set N (1-24). Previous setting retained."); + replies.Insert("Saved for next round: " + ERR_AdminMenuFields.Display("startTimeMinutes", mode.Settings().Get("startTimeMinutes").ToString()) + ", " + ERR_Settings.SelectionText(mode.Settings().Get("timeMultiplier")) + " progression. Current round clock unchanged."); + else replies.Insert("Invalid time or settings could not be saved. Use mission time set HH:MM (00:00-23:59)|random|random-day or mission speed set N (1-24)|random. Previous setting retained."); return true; } protected string SettingHelp(string key) { if (key == "intelRevealChancePercent") - return "Use /esc intel reveal set N (0-100). This is the chance that a first document identifies its location exactly; otherwise it gives a rough location. A second document for that location always identifies it. Changes affect newly recovered documents immediately; known locations and spent documents remain unchanged."; + return "Use /esc intel reveal set N (0-100) or random. This is the chance that a first document identifies its location exactly; otherwise it gives a rough location. A second document for that location always identifies it. Changes affect newly recovered documents immediately; known locations and spent documents remain unchanged."; return ""; } protected void SettingCommand(ERR_Settings settings, string key, array parts, array replies) { - if (parts.Count() == 3 && parts[2] == "show") { replies.Insert(key + "=" + settings.Get(key).ToString()); return; } + if (parts.Count() == 3 && parts[2] == "show") { replies.Insert(key + "=" + ERR_Settings.SelectionText(settings.Get(key))); return; } string help = SettingHelp(key); if (parts.Count() == 3 && parts[2] == "help" && !help.IsEmpty()) { replies.Insert(help); return; } int value; - if (parts.Count() == 4 && parts[2] == "set" && ERR_CommandArguments.Integer(parts[3], value) && settings.Set(key, value)) - replies.Insert(key + "=" + value.ToString() + " saved"); + if (parts.Count() == 4 && parts[2] == "set" && ERR_CommandArguments.Selection(key, parts[3], value) && settings.Set(key, value)) + replies.Insert(key + "=" + ERR_Settings.SelectionText(value) + " saved"); else { string failure = "Invalid value or settings could not be saved; previous setting retained."; @@ -121,9 +131,9 @@ class ERR_AdminService if (provider.Help(topic, replies)) return; if (topic == "time" || topic == "mission") { - replies.Insert("/esc mission radio show|set N | mission abandon"); - replies.Insert("/esc time show|set HH:MM | mission speed show|set N; saved changes apply next round."); - replies.Insert("/esc mission date show|set YYYY-MM-DD|authored | mission weather show|set authored|clear|overcast|rain"); + replies.Insert("/esc mission radio show|set N|random | mission abandon"); + replies.Insert("/esc time show|set HH:MM|random|random-day | mission speed show|set N|random; saved changes apply next round."); + replies.Insert("/esc mission date show|set YYYY-MM-DD|authored|random | mission weather show|set authored|clear|overcast|rain|random"); return; } if (topic == "intel") @@ -137,8 +147,8 @@ class ERR_AdminService replies.Insert("This command topic is unavailable in the loaded mission: " + topic + "."); return; } - replies.Insert("/esc menu | status | settings | help mission|time|intel|hq|cheat|god"); - replies.Insert("/esc mission abandon | mission radio show|set N | time show|set HH:MM"); + replies.Insert("/esc menu | status | settings | help mission|time|intel|hq|cheat|god|killais"); + replies.Insert("/esc mission abandon | mission radio show|set N|random | time show|set HH:MM|random|random-day"); replies.Insert("/esc mission date|weather|speed show|set VALUE | intel reveal show|set N|help"); replies.Insert("/esc spectate next|previous; optional systems add their own command topics."); } diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminSession.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminSession.c index 836e0f3..a4e2925 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminSession.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminSession.c @@ -93,6 +93,11 @@ class ERR_AdminSession array status = {}; if (!m_Mode.Settings()) { status.Insert("Mission settings are not ready."); m_Controller.ERR_AdminDeliverSnapshot(m_Token, true, values, status); return; } foreach (string key : ERR_Settings.Keys()) values.Insert(key + "=" + m_Mode.Settings().Get(key).ToString()); + foreach (string key : ERR_AdminMenuFields.Keys()) + { + if (key == "convoyCount" || key == "startTimeMinutes" || key == "timeMultiplier" || key == "startDateCode" || key == "weatherProfile") continue; + if (m_Mode.Settings().Get(key) == -1) values.Insert("resolved_" + key + "=" + m_Mode.Settings().Runtime(key).ToString()); + } m_Mode.Admin().Capabilities(values); m_Mode.Admin().Execute(m_Mode, true, "status", status); m_Controller.ERR_AdminDeliverSnapshot(m_Token, true, values, status); diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_AirfieldPlacement.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_AirfieldPlacement.c new file mode 100644 index 0000000..c070580 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_AirfieldPlacement.c @@ -0,0 +1,32 @@ +// Scenario authors choose a pad on their island's airfield. Never relocate an +// obstructed aircraft into nearby woods: refuse preparation before spawning it. +class ERR_AirfieldPlacement +{ + protected bool m_NearAirport; + protected bool Airport(IEntity entity) + { + if (!entity || !entity.GetPrefabData()) return true; + string path = entity.GetPrefabData().GetPrefabName(); path.ToLower(); + if (path.Contains("/runway") || path.Contains("/lightrunway_") || path.Contains("/aircontroltower") || path.Contains("/airportcontroltower") || path.Contains("/locations/eden/airport.et")) m_NearAirport = true; + return !m_NearAirport; + } + static bool Clear(vector position) + { + if (!Replication.IsServer() || position[1] < 2 || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), position)) return false; + ERR_AirfieldPlacement survey = new ERR_AirfieldPlacement(); + GetGame().GetWorld().QueryEntitiesBySphere(position, 600, survey.Airport, null, EQueryEntitiesFlags.ALL); + if (!survey.m_NearAirport) return false; + for (int edge = 0; edge < 4; edge++) + { + float side = edge * Math.PI / 2; + vector sample = position + Vector(Math.Cos(side) * 14, 0, Math.Sin(side) * 14); + if (Math.AbsFloat(SCR_TerrainHelper.GetTerrainY(sample) - position[1]) > 0.8) return false; + } + if (!SCR_WorldTools.TraceCilinderUtil(position + "0 2.5 0", 40, 4, TraceFlags.ENTS)) return false; + TraceBox clearance = new TraceBox(); + clearance.Start = position + "0 3 0"; clearance.End = position + "0 60 0"; + clearance.Mins = "-40 -1 -40"; clearance.Maxs = "40 1 40"; + clearance.Flags = TraceFlags.ENTS | TraceFlags.WORLD; + return GetGame().GetWorld().TraceMove(clearance, null) >= 1; + } +} diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_ContentDefinition.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_ContentDefinition.c new file mode 100644 index 0000000..8393b67 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_ContentDefinition.c @@ -0,0 +1,103 @@ +// Modder-facing registry. New packs/factions and their menu rows need data only. +[BaseContainerProps()] +class ERR_ContentOption +{ + [Attribute()] string Id; + [Attribute()] string Label; + [Attribute("", UIWidgets.EditBox, "Parent pack ID; empty for a pack")] + string Parent; + [Attribute("1", UIWidgets.CheckBox)] bool DefaultEnabled; + [Attribute("", UIWidgets.EditBox, "Native faction key; empty for a pack")] + string NativeFactionKey; + [Attribute("", UIWidgets.ResourceNamePicker, "Physical guard group belonging to this faction", "et")] + ResourceName GuardGroup; + [Attribute("", UIWidgets.EditBox, "Exact resource GUIDs for optional equipment owned by this pack")] + ref array EquipmentGuids; +} + +[BaseContainerProps()] +class ERR_ContentDefinition +{ + [Attribute()] ref array Options; + ERR_ContentOption Find(string id) + { + if (!Options) return null; + foreach (ERR_ContentOption option : Options) + if (option && option.Id == id) return option; + return null; + } + bool Valid() + { + if (!Options || Options.IsEmpty() || Options.Count() > 64) return false; + set ids = new set(); + set factions = new set(); + set groups = new set(); + set resources = new set(); + foreach (ERR_ContentOption option : Options) + { + if (!option || option.Id.IsEmpty() || option.Id.Length() > 48 || option.Label.IsEmpty() || ids.Contains(option.Id)) return false; + for (int i = 0; i < option.Id.Length(); i++) + if ("abcdefghijklmnopqrstuvwxyz0123456789_".IndexOf(option.Id.Substring(i, 1)) < 0) return false; + ids.Insert(option.Id); + if (!option.Parent.IsEmpty()) + { + ERR_ContentOption parent = Find(option.Parent); + if (!parent || !parent.Parent.IsEmpty() || !parent.NativeFactionKey.IsEmpty()) return false; + } + if (!option.NativeFactionKey.IsEmpty()) + { + if (option.Parent.IsEmpty() || option.GuardGroup.IsEmpty() || factions.Contains(option.NativeFactionKey) || groups.Contains(option.GuardGroup)) return false; + factions.Insert(option.NativeFactionKey); + groups.Insert(option.GuardGroup); + } + else if (!option.GuardGroup.IsEmpty()) return false; + if (option.EquipmentGuids) + foreach (string guid : option.EquipmentGuids) + { + if (guid.Length() != 16 || resources.Contains(guid)) return false; + for (int j = 0; j < guid.Length(); j++) + if ("0123456789ABCDEF".IndexOf(guid.Substring(j, 1)) < 0) return false; + resources.Insert(guid); + } + } + return true; + } +} + +class ERR_ContentRegistry +{ + protected static ref ERR_ContentDefinition s_Definition; + static ERR_ContentDefinition Definition() + { + if (!s_Definition) s_Definition = ERR_ContentDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{64BF38F9F92CE016}Configs/ERR/Content/Registry.conf")); + return s_Definition; + } + static array Ids() + { + array ids = {}; + ERR_ContentDefinition definition = Definition(); + if (definition && definition.Valid()) + foreach (ERR_ContentOption option : definition.Options) ids.Insert(option.Id); + return ids; + } + static string Key(string id) { return "content_" + id; } + static string Label(string id) + { + ERR_ContentOption option = Definition().Find(id); + if (option) return option.Label; + return id; + } + static string State(bool enabled) + { + if (enabled) return "Enabled"; + return "Disabled"; + } + static string ResourcePack(ResourceName prefab) + { + foreach (ERR_ContentOption option : Definition().Options) + if (option.EquipmentGuids) + foreach (string guid : option.EquipmentGuids) + if (prefab.Contains("{" + guid + "}")) return option.Id; + return ""; + } +} diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_ContentMenu.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_ContentMenu.c new file mode 100644 index 0000000..9967edb --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_ContentMenu.c @@ -0,0 +1,118 @@ +modded class ERR_AdminMenu +{ + protected ref map m_ModRows = new map(); + protected ref map m_ModSaved = new map(); + protected ref map m_ModActive = new map(); + static void OpenMods() + { + Open(); + ERR_AdminMenu menu = GetOpenMenu(); + if (menu) menu.ShowPage(3); + } + override void OnMenuOpen() + { + super.OnMenuOpen(); + Widget list = GetRootWidget().FindAnyWidget("ModList"); + foreach (string pack : ERR_ContentRegistry.Ids()) + { + Widget row = GetGame().GetWorkspace().CreateWidgets("{F092D45656A8E02B}UI/layouts/ERR/ContentRow.layout", list); + if (!row) { Close(); return; } + m_ModRows.Set(pack, row); + TextWidget label = TextWidget.Cast(row.FindAnyWidget("PackLabel")); + if (label) label.SetText(ERR_ContentRegistry.Label(pack)); + SCR_ButtonTextComponent enable = SCR_ButtonTextComponent.GetButtonText("Enable", row); + SCR_ButtonTextComponent disable = SCR_ButtonTextComponent.GetButtonText("Disable", row); + if (!enable || !disable) { Close(); return; } + enable.m_OnClicked.Insert(OnModButton); disable.m_OnClicked.Insert(OnModButton); + } + UpdateActions(); + } + override void OnMenuClose() + { + foreach (string pack, Widget row : m_ModRows) + { + SCR_ButtonTextComponent enable = SCR_ButtonTextComponent.GetButtonText("Enable", row); + SCR_ButtonTextComponent disable = SCR_ButtonTextComponent.GetButtonText("Disable", row); + if (enable) enable.m_OnClicked.Remove(OnModButton); + if (disable) disable.m_OnClicked.Remove(OnModButton); + } + m_ModRows.Clear(); m_ModSaved.Clear(); m_ModActive.Clear(); + super.OnMenuClose(); + } + protected override void UpdateActions() + { + super.UpdateActions(); + foreach (string pack, Widget row : m_ModRows) + { + bool ready = m_Ready && m_ModSaved.Contains(pack) && m_ModActive.Contains(pack); + SCR_ButtonTextComponent enable = SCR_ButtonTextComponent.GetButtonText("Enable", row); + SCR_ButtonTextComponent disable = SCR_ButtonTextComponent.GetButtonText("Disable", row); + if (enable) enable.SetEnabled(ready && m_ModSaved.Get(pack) == 0); + if (disable) disable.SetEnabled(ready && m_ModSaved.Get(pack) == 1); + TextWidget state = TextWidget.Cast(row.FindAnyWidget("PackState")); + if (!state) continue; + if (!ready) state.SetText("Waiting for server settings..."); + else state.SetText("Current round: " + ERR_ContentRegistry.State(m_ModActive.Get(pack) == 1) + " | Saved preference: " + ERR_ContentRegistry.State(m_ModSaved.Get(pack) == 1)); + } + } + override void ReceiveSnapshot(bool authorized, array values, array status) + { + m_ModSaved.Clear(); m_ModActive.Clear(); + if (authorized && values) + foreach (string pack : ERR_ContentRegistry.Ids()) + foreach (string entry : values) + { + if (entry == ERR_ContentRegistry.Key(pack) + "=0") m_ModSaved.Set(pack, 0); + if (entry == ERR_ContentRegistry.Key(pack) + "=1") m_ModSaved.Set(pack, 1); + if (entry == "activeMod_" + pack + "=0") m_ModActive.Set(pack, 0); + if (entry == "activeMod_" + pack + "=1") m_ModActive.Set(pack, 1); + } + super.ReceiveSnapshot(authorized, values, status); + } + protected void OnModButton(SCR_ButtonBaseComponent button) + { + if (!button || !IsFocused() || !m_Controller || !m_Ready || m_Age > 6 || m_ClickDelay > 0) return; + foreach (string pack, Widget row : m_ModRows) + { + string action; + if (button.GetRootWidget() == row.FindAnyWidget("Enable")) action = "enable"; + if (button.GetRootWidget() == row.FindAnyWidget("Disable")) action = "disable"; + if (action.IsEmpty() || !m_ModSaved.Contains(pack) || !m_ModActive.Contains(pack)) continue; + m_ClickDelay = 0.3; + SetText("Feedback", "Saving selection on the server..."); + m_Controller.ERR_AdminSubmit("mods " + pack + " " + action, true); + return; + } + } +} + +// Add one mission-specific item to the existing Esc menu. The authenticated +// snapshot and command handlers still enforce access on the server. +modded class PauseMenuUI +{ + protected SCR_ButtonTextComponent m_ERRModsButton; + override void OnMenuOpen() + { + super.OnMenuOpen(); + PlayerController controller = GetGame().GetPlayerController(); + if (!ERR_GameMode.Get() || !controller || !SCR_Global.IsAdmin(controller.GetPlayerId())) return; + Widget settings = GetRootWidget().FindAnyWidget("Settings"); + if (!settings) return; + Widget widget = GetGame().GetWorkspace().CreateWidgets("{F0351A624C87DE90}UI/layouts/ERR/PauseModsButton.layout", settings.GetParent()); + if (!widget) return; + m_ERRModsButton = SCR_ButtonTextComponent.Cast(widget.FindHandler(SCR_ButtonTextComponent)); + if (m_ERRModsButton) m_ERRModsButton.m_OnClicked.Insert(ERR_OpenMods); + } + protected void ERR_OpenMods() + { + // Unpause local simulation before requesting an authenticated server snapshot. + Close(); + ERR_AdminMenu.Open(); + } + override void OnMenuClose() + { + if (m_ERRModsButton) m_ERRModsButton.m_OnClicked.Remove(ERR_OpenMods); + m_ERRModsButton = null; + super.OnMenuClose(); + } +} diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_ContentPolicy.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_ContentPolicy.c new file mode 100644 index 0000000..24982cb --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_ContentPolicy.c @@ -0,0 +1,136 @@ +class ERR_ContentPolicy +{ + protected bool m_FactionsPrepared; + protected int m_GuardOffset; + protected ref map m_Active = new map(); + void ERR_ContentPolicy(ERR_ContentSettings settings) + { + foreach (string pack : ERR_ContentRegistry.Ids()) + m_Active.Set(pack, settings && settings.Enabled(pack)); + int count; + ERR_ContentDefinition definition = ERR_ContentRegistry.Definition(); + if (definition && definition.Valid()) + foreach (ERR_ContentOption option : definition.Options) + if (!option.NativeFactionKey.IsEmpty() && Enabled(option.Id)) count++; + if (count > 0) m_GuardOffset = Math.RandomInt(0, count); + } + bool Enabled(string pack) + { + if (pack.IsEmpty()) return true; // Base game / owned mission content. + if (!m_Active.Contains(pack) || !m_Active.Get(pack)) return false; + ERR_ContentOption option = ERR_ContentRegistry.Definition().Find(pack); + return option && (option.Parent.IsEmpty() || (m_Active.Contains(option.Parent) && m_Active.Get(option.Parent))); + } + bool Allows(ResourceName prefab) { return Enabled(ERR_ContentRegistry.ResourcePack(prefab)); } + bool Enemy(Faction faction) + { + if (!faction) return false; + foreach (ERR_ContentOption option : ERR_ContentRegistry.Definition().Options) + if (!option.NativeFactionKey.IsEmpty() && Enabled(option.Id) && option.NativeFactionKey == faction.GetFactionKey()) return true; + return false; + } + ResourceName GuardGroup(int index) + { + array groups = {}; + foreach (ERR_ContentOption option : ERR_ContentRegistry.Definition().Options) + if (!option.NativeFactionKey.IsEmpty() && Enabled(option.Id)) groups.Insert(option.GuardGroup); + if (groups.IsEmpty()) return ""; + return groups[(index + m_GuardOffset) % groups.Count()]; + } + string GuardFaction(ResourceName prefab) + { + foreach (ERR_ContentOption option : ERR_ContentRegistry.Definition().Options) + if (option.GuardGroup == prefab && Enabled(option.Id)) return option.NativeFactionKey; + return ""; + } + bool PrepareFactions(ERR_GameMode mode) + { + if (m_FactionsPrepared) return true; + ERR_ContentDefinition definition = ERR_ContentRegistry.Definition(); + if (!definition || !definition.Valid()) return false; + SCR_FactionManager manager = SCR_FactionManager.Cast(GetGame().GetFactionManager()); + if (!manager) return false; + array factions = {}; + SCR_Faction player = SCR_Faction.Cast(mode.PlayerFaction()); + SCR_Faction primary = SCR_Faction.Cast(mode.EnemyFaction()); + if (!player || !primary) return false; + factions.Insert(player); factions.Insert(primary); + foreach (ERR_ContentOption option : definition.Options) + { + if (option.NativeFactionKey.IsEmpty() || !Enabled(option.Id)) continue; + SCR_Faction faction = SCR_Faction.Cast(manager.GetFactionByKey(option.NativeFactionKey)); + Resource group = Resource.Load(option.GuardGroup); + if (!faction || !group || !group.IsValid()) return false; + if (factions.Contains(faction)) continue; + factions.Insert(faction); + } + // The manager mirrors both directions and persists relationships for JIP. + // Call both orders as an inherited one-way friendship may exist. + foreach (SCR_Faction a : factions) + { + manager.SetFactionsFriendly(a, a, -1, false); + foreach (SCR_Faction b : factions) + { + if (a == b) continue; + manager.SetFactionsHostile(a, b, -1, false); + manager.SetFactionsHostile(b, a, -1, false); + if (!a.IsFactionEnemy(b) || !b.IsFactionEnemy(a)) return false; + } + } + SCR_FactionManager.RequestUpdateAllTargetsFactions(); + m_FactionsPrepared = true; + return true; + } + void Snapshot(array values) + { + foreach (string pack : ERR_ContentRegistry.Ids()) + { + int enabled = 0; + if (Enabled(pack)) enabled = 1; + values.Insert("activeMod_" + pack + "=" + enabled.ToString()); + } + } +} + +// Commands inherit the existing authenticated controller/session boundary. +class ERR_ContentAdminProvider : ERR_AdminProvider +{ + protected ERR_ContentPolicy m_Policy; + void ERR_ContentAdminProvider(ERR_ContentPolicy policy) { m_Policy = policy; } + override string Id() { return "mods"; } + override void Capabilities(array values) + { + m_Policy.Snapshot(values); + ERR_GameMode mode = ERR_GameMode.Get(); + if (mode && mode.ContentSettings()) mode.ContentSettings().Snapshot(values); + } + override bool Help(string topic, array replies) + { + if (topic != "mods") return false; + replies.Insert("Use /esc mods show, or /esc mods enable|disable. Saved selections apply after mission restart. A disabled pack also disables its factions. Workshop dependencies remain installed."); + return true; + } + override bool Execute(ERR_GameMode mode, string arguments, array replies) + { + array parts = {}; + arguments.Split(" ", parts, true); + if (parts.IsEmpty() || parts[0] != "mods") return false; + if (arguments == "mods show") + { + foreach (string pack : ERR_ContentRegistry.Ids()) + replies.Insert(ERR_ContentRegistry.Label(pack) + ": current round " + ERR_ContentRegistry.State(m_Policy.Enabled(pack)) + "; saved preference " + ERR_ContentRegistry.State(mode.ContentSettings().Enabled(pack)) + "."); + return true; + } + if (parts.Count() == 3 && ERR_ContentRegistry.Ids().Contains(parts[1]) && (parts[2] == "enable" || parts[2] == "disable")) + { + int value = 0; + if (parts[2] == "enable") value = 1; + if (mode.ContentSettings().Set(parts[1], value)) + replies.Insert(ERR_ContentRegistry.Label(parts[1]) + ": " + ERR_ContentRegistry.State(value == 1) + " after mission restart. Current round unchanged."); + else replies.Insert("Could not save mod selection. Previous setting retained."); + return true; + } + Help("mods", replies); + return true; + } +} diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_ContentSettings.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_ContentSettings.c new file mode 100644 index 0000000..645caec --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_ContentSettings.c @@ -0,0 +1,86 @@ +// Separate, config-driven preferences. Missing newly installed options use their +// authored defaults. Failed writes preserve both previous state and backup. +class ERR_ContentSettings +{ + static const string PATH = "$profile:EscapistsContent.json"; + protected string m_Path; + protected bool m_ReadOnly; + protected ref map m_Values = new map(); + void ERR_ContentSettings(string path = PATH) + { + m_Path = path; + ERR_ContentDefinition definition = ERR_ContentRegistry.Definition(); + if (!definition || !definition.Valid()) { m_ReadOnly = true; return; } + foreach (ERR_ContentOption option : definition.Options) + { + int value = 0; + if (option.DefaultEnabled) value = 1; + m_Values.Set(option.Id, value); + } + if (!Replication.IsServer()) { m_ReadOnly = true; return; } + map loaded; + if (Read(m_Path, loaded) || Read(m_Path + ".bak", loaded)) m_Values = loaded; + else if (FileIO.FileExists(m_Path) || FileIO.FileExists(m_Path + ".bak")) + { + m_ReadOnly = true; + Print("Escapists: invalid content preferences; authored defaults active, existing files preserved", LogLevel.ERROR); + } + } + bool Enabled(string id) { return m_Values.Contains(id) && m_Values.Get(id) == 1; } + protected bool Read(string path, out map values) + { + JsonLoadContext json = new JsonLoadContext(); + if (!json.LoadFromFile(path)) return false; + values = new map(); + foreach (ERR_ContentOption option : ERR_ContentRegistry.Definition().Options) + { + int fallback = 0; + if (option.DefaultEnabled) fallback = 1; + if (!json.DoesKeyExist(option.Id)) { values.Set(option.Id, fallback); continue; } + float number; + if (!json.ReadValue(option.Id, number) || (number != 0 && number != 1)) return false; + values.Set(option.Id, Math.Round(number)); + } + return true; + } + protected bool Matches(map values, string changed, int value) + { + if (!values) return false; + foreach (string id, int original : m_Values) + { + int expected = original; + if (id == changed) expected = value; + if (!values.Contains(id) || values.Get(id) != expected) return false; + } + return true; + } + bool Set(string id, int value) + { + if (!Replication.IsServer() || m_ReadOnly || !m_Values.Contains(id) || (value != 0 && value != 1)) return false; + JsonSaveContext json = new JsonSaveContext(); + json.EnableTypeDiscriminator(false); + foreach (string option, int original : m_Values) + { + int saved = original; + if (option == id) saved = value; + json.WriteValue(option, saved); + } + map checked; + if (!json.SaveToFile(m_Path + ".tmp") || !Read(m_Path + ".tmp", checked) || !Matches(checked, id, value)) return false; + map previous; + bool hadPrevious = Read(m_Path, previous); + if (hadPrevious && !FileIO.CopyFile(m_Path, m_Path + ".bak")) return false; + if (!FileIO.CopyFile(m_Path + ".tmp", m_Path) || !Read(m_Path, checked) || !Matches(checked, id, value)) + { + if (FileIO.FileExists(m_Path + ".bak")) FileIO.CopyFile(m_Path + ".bak", m_Path); + else if (!hadPrevious) FileIO.DeleteFile(m_Path); + return false; + } + m_Values = checked; + return true; + } + void Snapshot(array values) + { + foreach (string id, int saved : m_Values) values.Insert(ERR_ContentRegistry.Key(id) + "=" + saved.ToString()); + } +} diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_DebugAI.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_DebugAI.c new file mode 100644 index 0000000..5f301aa --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_DebugAI.c @@ -0,0 +1,43 @@ +// Invoked only through the authenticated, current-body admin tool boundary. +class ERR_DebugAI +{ + static int KillNearby(ERR_GameMode mode, IEntity center) + { + if (!Replication.IsServer() || !mode || mode != ERR_GameMode.Get() || !mode.Active() || !center || !GetGame().GetAIWorld()) return 0; + array lives = {}; mode.Players().Participants(lives); + array protectedBodies = {}; + foreach (ERR_PlayerLife life : lives) if (life.Actor) protectedBodies.Insert(life.Actor); + array agents = {}; GetGame().GetAIWorld().GetAIAgents(agents); + array targets = {}; + foreach (AIAgent agent : agents) + { + if (!agent) continue; + SCR_AIGroup group = SCR_AIGroup.Cast(agent); + if (group) + { + array members = {}; group.GetAgents(members); + foreach (AIAgent member : members) if (member) Add(member.GetControlledEntity(), center, protectedBodies, targets); + } + Add(agent.GetControlledEntity(), center, protectedBodies, targets); + } + int killed; + foreach (IEntity target : targets) + { + // Recheck ownership immediately before damage; possession can change. + if (!Eligible(target, center, protectedBodies)) continue; + SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(target.FindComponent(SCR_CharacterDamageManagerComponent)); + if (!damage) continue; + damage.Kill(Instigator.CreateInstigator(center)); + if (damage.IsDestroyed()) killed++; + } + return killed; + } + static bool Eligible(IEntity target, IEntity center, array protectedBodies) + { + return target && ChimeraCharacter.Cast(target) && target != center && ERR_Actors.Alive(target) && !ERR_Actors.Player(target) && !protectedBodies.Contains(target) && vector.Distance(target.GetOrigin(), center.GetOrigin()) <= 1000; + } + protected static void Add(IEntity target, IEntity center, array protectedBodies, array targets) + { + if (Eligible(target, center, protectedBodies) && !targets.Contains(target)) targets.Insert(target); + } +} diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_DebugIntel.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_DebugIntel.c new file mode 100644 index 0000000..11e2900 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_DebugIntel.c @@ -0,0 +1,30 @@ +class ERR_DebugIntel +{ + static bool SpawnSix(ERR_GameMode mode, IEntity actor) + { + if (!Replication.IsServer() || !mode || mode != ERR_GameMode.Get() || !mode.Active() || !ERR_Actors.CanFight(actor) || !mode.Players().CurrentActor(actor)) return false; + ERR_SpawnPlacement placement = new ERR_SpawnPlacement(); + map positions = new map(); + // Survey all six spots first, including solid obstructions and the path from + // the player. This also refuses airborne/seated players and deep water. + for (int i = 0; i < 6; i++) + { + vector position; + if (!placement.Near(actor, positions, position)) return false; + positions.Set(i, position); + } + array spawned = {}; + for (int j = 0; j < 6; j++) + { + IEntity document = ERR_Actors.Spawn(ERR_Resources.IntelFolder(), positions.Get(j)); + if (!document) + { + foreach (IEntity partial : spawned) ERR_Actors.DeleteSpawned(partial); + return false; + } + spawned.Insert(document); + } + foreach (IEntity intel : spawned) mode.Own(intel); + return true; + } +} diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_DiscoveryMap.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_DiscoveryMap.c index 18344bd..f693603 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_DiscoveryMap.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_DiscoveryMap.c @@ -14,6 +14,38 @@ class ERR_DiscoveryMapRules // that boundary so native edit/drag cannot move a discovered objective. class ERR_DiscoveryMarker : SCR_MapMarkerBase { + protected string m_Icon = "supplies"; + static string Icon(ERR_LocationKind kind, string id) + { + if (kind == ERR_LocationKind.EXTRACTION) return "helicopters"; + if (kind == ERR_LocationKind.RADIO) return "radio"; + if (kind == ERR_LocationKind.MORTAR) return "artilleryFireMission"; + if (kind == ERR_LocationKind.OUTPOST) return "building"; + if (id.Contains("medical")) return "medical"; + return "supplies"; + } + void SetLocationIcon(ERR_LocationKind kind, string id, int knowledge = 2) + { + m_Icon = "mark-question"; + if (knowledge >= 2) m_Icon = Icon(kind, id); + ApplyIcon(); + } + protected void ApplyIcon() + { + SCR_MapMarkerWidgetComponent widget = GetMarkerComponent(); + if (!GetRootWidget() || !widget) return; + if (m_Icon == "mark-question") + { + widget.SetImage("{E23427CAC80DA8B7}UI/Textures/Icons/icons_mapMarkersUI.imageset", m_Icon); + widget.SetGlowImage("{67B3A6DC2D712B52}UI/Textures/Icons/icons_mapMarkersUI-glow.imageset", m_Icon); + } + else + { + widget.SetImage("{3262679C50EF4F01}UI/Textures/Icons/icons_wrapperUI.imageset", m_Icon); + widget.SetGlowImage("{00FE3DBDFD15227B}UI/Textures/Icons/icons_wrapperUI-glow.imageset", m_Icon); + } + widget.SetColor(Color.FromSRGBA(35, 45, 50, 255)); + } void ERR_DiscoveryMarker() { SetCanBeRemovedByOwner(false); } void SetDiscoveryPosition(vector position, int knowledge) { @@ -25,6 +57,7 @@ class ERR_DiscoveryMarker : SCR_MapMarkerBase // Text is scenario-authored, never user input. Avoid delayed UGC callbacks // overwriting a newer discovery label after the location becomes identified. super.OnCreateMarker(true); + ApplyIcon(); } override void OnDelete() { @@ -73,17 +106,26 @@ modded class ERR_LocationComponent } protected void ERR_UpdateMarker() { - if (!GetGame().GetPlayerController() || Knowledge() <= 0 || (Knowledge() == m_ERRShown && Label() == m_ERRShownLabel)) return; + if (!GetGame().GetPlayerController()) return; + if (Kind() == ERR_LocationKind.PRISON || Knowledge() < 1) + { + SCR_MapMarkerManagerComponent oldManager = SCR_MapMarkerManagerComponent.GetInstance(); + if (m_ERRMarker && oldManager) oldManager.ERR_RemoveDiscoveryMarker(m_ERRMarker); + else if (m_ERRMarker) m_ERRMarker.OnDelete(); + m_ERRMarker = null; m_ERRShown = 0; m_ERRShownLabel = ""; + return; + } + if (Knowledge() == m_ERRShown && Label() == m_ERRShownLabel) return; SCR_MapMarkerManagerComponent manager = SCR_MapMarkerManagerComponent.GetInstance(); if (!manager || !manager.GetMarkerConfig()) return; - string label = "Search area (500 m square)"; + string label = "Unknown location (500 m area)"; if (Knowledge() == 2) { if (!Label().IsEmpty()) label = Label(); else if (Kind() == ERR_LocationKind.RADIO) label = "Radio station"; else if (Kind() == ERR_LocationKind.EXTRACTION) label = "Initial helicopter pickup site"; else if (Kind() == ERR_LocationKind.DEPOT) label = "Supply cache"; - else if (Kind() == ERR_LocationKind.PRISON) label = "Prison"; + else if (Kind() == ERR_LocationKind.MORTAR) label = "Mortar site"; else label = Id(); } if (!m_ERRMarker) @@ -92,10 +134,12 @@ modded class ERR_LocationComponent m_ERRMarker.SetType(SCR_EMapMarkerType.PLACED_CUSTOM); m_ERRMarker.SetDiscoveryPosition(Position(), Knowledge()); m_ERRMarker.SetMarkerConfigID(0); + m_ERRMarker.SetLocationIcon(Kind(), Id(), Knowledge()); m_ERRMarker.SetCustomText(label); manager.InsertStaticMarker(m_ERRMarker, true); } m_ERRMarker.SetDiscoveryPosition(Position(), Knowledge()); + m_ERRMarker.SetLocationIcon(Kind(), Id(), Knowledge()); m_ERRMarker.SetCustomText(label); // Reactivate a previously culled rough marker if the exact point moves // into the open map's view. Native update performs normal visibility checks. diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_DiscoveryService.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_DiscoveryService.c index 5d26f66..c64c9db 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_DiscoveryService.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_DiscoveryService.c @@ -57,18 +57,24 @@ class ERR_DiscoveryService m_Mode.Locations(locations); array eligible = {}; ERR_DocumentComponent document = ERR_DocumentComponent.Cast(folder.FindComponent(ERR_DocumentComponent)); + string sealedTarget = m_Mode.Evidence().Target(folder); foreach (ERR_LocationComponent location : locations) { if (!location || location.Knowledge() >= 2) continue; + if (!sealedTarget.IsEmpty() && location.Id() != sealedTarget) continue; if (document) { if (location.Id() == document.TargetId()) eligible.Insert(location); } else if (location.Kind() != ERR_LocationKind.PRISON && location.Kind() != ERR_LocationKind.EXTRACTION) eligible.Insert(location); } - if (eligible.IsEmpty()) continue; + if (eligible.IsEmpty()) + { + if (!document && MarkSpent(folder)) m_Mode.ERR_IntelExhausted(!sealedTarget.IsEmpty()); + continue; + } ERR_LocationComponent chosen = eligible.GetRandomElement(); - if (!chosen.Discover(Math.RandomInt(0, 100), m_Mode.Settings().Get("intelRevealChancePercent"))) continue; + if (!chosen.Discover(Math.RandomInt(0, 100), m_Mode.Settings().Runtime("intelRevealChancePercent"))) continue; m_Spent.Insert(folder); PrintFormat("Reintroduced [intel]: player %1 recovered folder; location=%2 knowledge=%3", GetGame().GetPlayerManager().GetPlayerIdFromControlledEntity(player), chosen.Id(), chosen.Knowledge()); } diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_EnvironmentService.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_EnvironmentService.c index 36339d4..a290d37 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_EnvironmentService.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_EnvironmentService.c @@ -66,12 +66,40 @@ class ERR_EnvironmentService if (state && state.GetStateName() == name) return true; return false; } + static int DateFromOrdinal(int year, int ordinal) + { + if (year < 1900 || year > 2050 || ordinal < 0) return -1; + for (int month = 1; month <= 12; month++) + { + int days = 31; + if (month == 4 || month == 6 || month == 9 || month == 11) days = 30; + if (month == 2) + { + days = 28; + if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) days = 29; + } + if (ordinal < days) return (year - 1900) * 10000 + month * 100 + ordinal + 1; + ordinal -= days; + } + return -1; + } + static int RandomDate(int year) + { + int days = 365; + if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) days = 366; + return DateFromOrdinal(year, Math.RandomInt(0, days)); + } bool Start(ERR_GameMode mode) { if (!Replication.IsServer() || !mode || !mode.Settings()) return false; ChimeraWorld world = ChimeraWorld.CastFrom(mode.GetWorld()); if (!world) return false; - return StartOn(world.GetTimeAndWeatherManager(), mode.Settings().Get("startDateCode"), mode.Settings().Get("weatherProfile")); + if (m_Started) return !m_Stopped; + TimeAndWeatherManagerEntity manager = world.GetTimeAndWeatherManager(); + if (!manager) return false; + int dateCode = mode.Settings().Get("startDateCode"); + if (dateCode == -1) dateCode = RandomDate(manager.GetYear()); + return StartOn(manager, dateCode, mode.Settings().Runtime("weatherProfile")); } protected bool StartOn(TimeAndWeatherManagerEntity manager, int dateCode, int profile) { @@ -144,7 +172,7 @@ class ERR_EnvironmentCommands if (parts.Count() == 3 && parts[2] == "show") { if (environment) replies.Insert(environment.Status()); - replies.Insert("Next round: date " + ERR_EnvironmentService.Date(settings.Get("startDateCode")) + ", weather " + ERR_EnvironmentService.ProfileName(settings.Get("weatherProfile")) + "."); + replies.Insert("Next round: date " + ERR_AdminMenuFields.Display("startDateCode", settings.Get("startDateCode").ToString()) + ", weather " + ERR_AdminMenuFields.Display("weatherProfile", settings.Get("weatherProfile").ToString()) + "."); return true; } int value; @@ -153,12 +181,11 @@ class ERR_EnvironmentCommands if (parts[1] == "weather") key = "weatherProfile"; if (parts.Count() == 4 && parts[2] == "set") { - if (key == "startDateCode") valid = ERR_EnvironmentService.ParseDate(parts[3], value); - else valid = ERR_EnvironmentService.ParseProfile(parts[3], value); + valid = ERR_CommandArguments.Selection(key, parts[3], value); } if (valid && settings.Set(key, value)) - replies.Insert("Saved for next round: date " + ERR_EnvironmentService.Date(settings.Get("startDateCode")) + ", weather " + ERR_EnvironmentService.ProfileName(settings.Get("weatherProfile")) + ". Current round unchanged."); - else replies.Insert("Invalid environment or settings could not be saved. Use mission date set YYYY-MM-DD (1900-2050) or authored; mission weather set authored|clear|overcast|rain. Previous setting retained."); + replies.Insert("Saved for next round: date " + ERR_AdminMenuFields.Display("startDateCode", settings.Get("startDateCode").ToString()) + ", weather " + ERR_AdminMenuFields.Display("weatherProfile", settings.Get("weatherProfile").ToString()) + ". Current round unchanged."); + else replies.Insert("Invalid environment or settings could not be saved. Use mission date set YYYY-MM-DD (1900-2050) or authored|random; mission weather set authored|clear|overcast|rain|random. Previous setting retained."); return true; } } diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_FactionMenu.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_FactionMenu.c new file mode 100644 index 0000000..c9b3082 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_FactionMenu.c @@ -0,0 +1,96 @@ +// Uses only authenticated server snapshots for eligibility and saved role choices. +modded class ERR_AdminMenu +{ + protected ref array m_FactionIds = {}; + protected ref array m_FactionButtons = {}; + protected ref map m_FactionSaved = new map(); + protected ref map m_FactionActive = new map(); + protected bool m_FactionFieldsInitialized; + override void OnMenuOpen() + { + super.OnMenuOpen(); + array names = {"FactionsApply", "FactionsRandomAll"}; + foreach (string name : names) + { + SCR_ButtonTextComponent button = SCR_ButtonTextComponent.GetButtonText(name, GetRootWidget()); + if (!button) { Close(); return; } + button.m_OnClicked.Insert(OnFactionButton); m_FactionButtons.Insert(button); + } + UpdateActions(); + } + override void OnMenuClose() + { + foreach (SCR_ButtonTextComponent button : m_FactionButtons) if (button) button.m_OnClicked.Remove(OnFactionButton); + m_FactionButtons.Clear(); m_FactionIds.Clear(); m_FactionSaved.Clear(); m_FactionActive.Clear(); m_FactionFieldsInitialized = false; + super.OnMenuClose(); + } + protected override void UpdateActions() + { + super.UpdateActions(); + bool ready = m_Ready && m_FactionSaved.Count() == 3 && m_FactionActive.Count() == 3; + foreach (SCR_ButtonTextComponent button : m_FactionButtons) + if (button) button.SetEnabled(ready); + foreach (string role : ERR_FactionChoices.Roles()) + { + SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("FactionSelect_" + role, GetRootWidget()); + if (combo) combo.SetEnabled(ready); + if (ready) SetText("FactionState_" + role, "Current: " + ERR_FactionChoices.Label(m_FactionActive.Get(role)) + " | Saved: " + ERR_FactionChoices.Label(m_FactionSaved.Get(role))); + } + } + override void ReceiveSnapshot(bool authorized, array values, array status) + { + m_FactionSaved.Clear(); m_FactionActive.Clear(); + array available = {"random"}; + if (authorized && values) + foreach (string value : values) + { + array pair = {}; value.Split("=", pair, true); + if (pair.Count() != 2) continue; + if (pair[0] == "factionAvailable" && ERR_FactionChoices.Find(pair[1]) && !available.Contains(pair[1])) available.Insert(pair[1]); + foreach (string role : ERR_FactionChoices.Roles()) + { + if (pair[0] == "factionSaved_" + role) m_FactionSaved.Set(role, pair[1]); + if (pair[0] == "factionActive_" + role) m_FactionActive.Set(role, pair[1]); + } + } + bool changed = available.Count() != m_FactionIds.Count(); + if (!changed) for (int i = 0; i < available.Count(); i++) if (available[i] != m_FactionIds[i]) changed = true; + if (authorized && m_FactionSaved.Count() == 3 && (changed || !m_FactionFieldsInitialized)) + { + foreach (string role : ERR_FactionChoices.Roles()) + { + SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("FactionSelect_" + role, GetRootWidget()); + if (!combo) continue; + string draft = m_FactionSaved.Get(role); + int previous = combo.GetCurrentIndex(); + if (m_FactionFieldsInitialized && previous >= 0 && previous < m_FactionIds.Count()) draft = m_FactionIds[previous]; + combo.ClearAll(); + foreach (string id : available) combo.AddItem(ERR_FactionChoices.Label(id)); + combo.SetCurrentItem(Math.Max(0, available.Find(draft)), false, false, false); + } + m_FactionIds.Copy(available); m_FactionFieldsInitialized = true; + } + super.ReceiveSnapshot(authorized, values, status); + } + protected void OnFactionButton(SCR_ButtonBaseComponent button) + { + if (!button) return; + string name = button.GetRootWidget().GetName(); + if (!m_Ready || !IsFocused() || !m_Controller || m_Age > 6 || m_ClickDelay > 0 || !m_FactionFieldsInitialized) return; + if (name == "FactionsRandomAll") + { + foreach (string role : ERR_FactionChoices.Roles()) SCR_ComboBoxComponent.GetComboBoxComponent("FactionSelect_" + role, GetRootWidget()).SetCurrentItem(0); + return; + } + if (name != "FactionsApply") return; + string command = "factions set"; + foreach (string role : ERR_FactionChoices.Roles()) + { + int index = SCR_ComboBoxComponent.GetComboBoxComponent("FactionSelect_" + role, GetRootWidget()).GetCurrentIndex(); + if (index < 0 || index >= m_FactionIds.Count()) return; + command += " " + m_FactionIds[index]; + } + m_ClickDelay = 0.3; SetText("Feedback", "Saving faction selections..."); + m_Controller.ERR_AdminSubmit(command, true); + } +} diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_FactionSelection.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_FactionSelection.c new file mode 100644 index 0000000..b7d8b26 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_FactionSelection.c @@ -0,0 +1,268 @@ +// Saved role choices are stable registry IDs, never menu indexes. Random resolves +// once per round on the server, without replacement across the three roles. +class ERR_FactionChoice +{ + string Id; + string Label; + string Key; + ResourceName Group; + void ERR_FactionChoice(string id, string label, string key, ResourceName group) + { Id = id; Label = label; Key = key; Group = group; } +} +class ERR_FactionChoices +{ + static array Roles() { return {"escapists", "enemy", "independent"}; } + static array All() + { + array choices = {}; + choices.Insert(new ERR_FactionChoice("us", "US Army", "US", "{84E5BBAB25EA23E5}Prefabs/Groups/BLUFOR/Group_US_FireTeam.et")); + choices.Insert(new ERR_FactionChoice("ussr", "Soviet Army", "USSR", "{D5A4D0D3AE1A3E26}Prefabs/ERR/Patrol.et")); + choices.Insert(new ERR_FactionChoice("fia", "FIA", "FIA", "{5BEA04939D148B1D}Prefabs/Groups/INDFOR/Group_FIA_FireTeam.et")); + ERR_ContentDefinition definition = ERR_ContentRegistry.Definition(); + if (definition && definition.Valid()) + foreach (ERR_ContentOption option : definition.Options) + { + if (option.NativeFactionKey.IsEmpty()) continue; + bool duplicate; + foreach (ERR_FactionChoice existing : choices) if (existing.Id == option.Id || existing.Key == option.NativeFactionKey) duplicate = true; + if (!duplicate) choices.Insert(new ERR_FactionChoice(option.Id, option.Label, option.NativeFactionKey, option.GuardGroup)); + } + return choices; + } + static ERR_FactionChoice Find(string id) + { + foreach (ERR_FactionChoice choice : All()) if (choice.Id == id) return choice; + return null; + } + static string Label(string id) + { + if (id == "random") return "Random"; + ERR_FactionChoice choice = Find(id); + if (choice) return choice.Label; + return "Unavailable (" + id + ")"; + } + static bool Enabled(string id, ERR_ContentSettings settings) + { + if (id == "us" || id == "ussr" || id == "fia") return true; + ERR_ContentOption option = ERR_ContentRegistry.Definition().Find(id); + return option && settings && settings.Enabled(id) && (option.Parent.IsEmpty() || settings.Enabled(option.Parent)); + } + static bool Resolve(array available, map saved, map resolved) + { + resolved.Clear(); + if (available.Count() < 3 || !Distinct(saved)) return false; + // Reserve every explicit choice first, so Random cannot steal a later role. + foreach (string fixedRole : Roles()) + { + string fixedId = saved.Get(fixedRole); + if (fixedId == "random" || !available.Contains(fixedId)) continue; + resolved.Set(fixedRole, fixedId); available.RemoveItem(fixedId); + } + foreach (string randomRole : Roles()) + { + if (resolved.Contains(randomRole)) continue; + if (available.IsEmpty()) return false; + int index = Math.RandomInt(0, available.Count()); + resolved.Set(randomRole, available[index]); available.Remove(index); + } + return true; + } + static bool Distinct(map selections) + { + array fixed = {}; + foreach (string role : Roles()) + { + if (!selections.Contains(role)) return false; + string id = selections.Get(role); + if (id == "random") continue; + if (!Find(id) || fixed.Contains(id)) return false; + fixed.Insert(id); + } + return true; + } +} +class ERR_FactionSelection +{ + static const string PATH = "$profile:EscapistsFactions.json"; + protected string m_Path; + protected bool m_ReadOnly; + protected bool m_Ready; + protected ref map m_Saved = new map(); + protected ref map m_Resolved = new map(); + void ERR_FactionSelection(string path = PATH) + { + m_Path = path; + m_Saved.Set("escapists", "us"); m_Saved.Set("enemy", "ussr"); m_Saved.Set("independent", "fia"); + if (!Replication.IsServer()) { m_ReadOnly = true; return; } + map loaded; + if (Read(m_Path, loaded) || Read(m_Path + ".bak", loaded)) m_Saved = loaded; + else if (FileIO.FileExists(m_Path) || FileIO.FileExists(m_Path + ".bak")) + { m_ReadOnly = true; Print("Escapists: invalid faction selections; defaults active and files preserved", LogLevel.ERROR); } + } + protected bool Read(string path, out map values) + { + JsonLoadContext json = new JsonLoadContext(); + if (!json.LoadFromFile(path)) return false; + values = new map(); + foreach (string role : ERR_FactionChoices.Roles()) + { + string id; + if (!json.ReadValue(role, id) || (id != "random" && !ERR_FactionChoices.Find(id))) return false; + values.Set(role, id); + } + return ERR_FactionChoices.Distinct(values); + } + protected bool Same(map a, map b) + { + if (!a || !b) return false; + foreach (string role : ERR_FactionChoices.Roles()) if (!a.Contains(role) || !b.Contains(role) || a.Get(role) != b.Get(role)) return false; + return true; + } + string Saved(string role) { return m_Saved.Get(role); } + string Active(string role) { return m_Resolved.Get(role); } + bool Ready() { return m_Ready; } + ERR_FactionChoice Choice(string role) { return ERR_FactionChoices.Find(Active(role)); } + ResourceName Soldier() + { + ERR_FactionChoice enemy = Choice("enemy"); + if (!enemy) return ""; + Resource resource = Resource.Load(enemy.Group); + if (!resource || !resource.IsValid()) return ""; + IEntitySource source = resource.GetResource().ToEntitySource(); + array members = {}; + if (!source || !source.Get("m_aUnitPrefabSlots", members) || members.IsEmpty()) return ""; + return members[0]; + } + bool Save(array ids, ERR_ContentSettings content) + { + if (!Replication.IsServer() || m_ReadOnly || !ids || ids.Count() != 3) return false; + map values = new map(); + array roles = ERR_FactionChoices.Roles(); + for (int i = 0; i < roles.Count(); i++) + { + if (ids[i] != "random" && !ERR_FactionChoices.Enabled(ids[i], content)) return false; + values.Set(roles[i], ids[i]); + } + if (!ERR_FactionChoices.Distinct(values)) return false; + JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false); + foreach (string role : roles) json.WriteValue(role, values.Get(role)); + map checked; + if (!json.SaveToFile(m_Path + ".tmp") || !Read(m_Path + ".tmp", checked) || !Same(values, checked)) return false; + map previous; + bool hadPrevious = Read(m_Path, previous); + if (hadPrevious && !FileIO.CopyFile(m_Path, m_Path + ".bak")) return false; + if (!FileIO.CopyFile(m_Path + ".tmp", m_Path) || !Read(m_Path, checked) || !Same(values, checked)) + { + if (hadPrevious) FileIO.CopyFile(m_Path + ".bak", m_Path); + else FileIO.DeleteFile(m_Path); + return false; + } + m_Saved = checked; return true; + } + bool Prepare(ERR_GameMode mode) + { + if (m_Ready) return true; + if (!Replication.IsServer() || !mode || !mode.Factions()) return false; + m_Resolved.Clear(); + array available = {}; + foreach (ERR_FactionChoice option : ERR_FactionChoices.All()) + { + if (!ERR_FactionChoices.Enabled(option.Id, mode.ContentSettings())) continue; + if (!GetGame().GetFactionManager().GetFactionByKey(option.Key)) continue; + Resource resource = Resource.Load(option.Group); + if (resource && resource.IsValid()) available.Insert(option.Id); + } + if (available.Count() < 3) return false; + if (!ERR_FactionChoices.Resolve(available, m_Saved, m_Resolved)) return false; + mode.Factions().PlayerFactionKey = Choice("escapists").Key; + mode.Factions().EnemyFactionKey = Choice("enemy").Key; + mode.Factions().GuardGroupPrefab = Choice("enemy").Group; + SCR_FactionManager manager = SCR_FactionManager.Cast(GetGame().GetFactionManager()); + if (!manager) return false; + foreach (string a : ERR_FactionChoices.Roles()) + foreach (string b : ERR_FactionChoices.Roles()) + { + SCR_Faction first = SCR_Faction.Cast(manager.GetFactionByKey(Choice(a).Key)); + SCR_Faction second = SCR_Faction.Cast(manager.GetFactionByKey(Choice(b).Key)); + if (a == b) manager.SetFactionsFriendly(first, second, -1, false); + else manager.SetFactionsHostile(first, second, -1, false); + } + SCR_Faction player = SCR_Faction.Cast(manager.GetFactionByKey(Choice("escapists").Key)); + player.SetIsPlayable(true); + if (!player.IsPlayable()) return false; + m_Ready = true; + mode.PublishFactionRoles(Choice("escapists").Key, Choice("enemy").Key); + PrintFormat("Escapists factions: players=%1 enemy=%2 independent=%3", Choice("escapists").Key, Choice("enemy").Key, Choice("independent").Key); + return true; + } + void Snapshot(ERR_ContentSettings content, array values) + { + foreach (string role : ERR_FactionChoices.Roles()) + { + values.Insert("factionSaved_" + role + "=" + Saved(role)); + values.Insert("factionActive_" + role + "=" + Active(role)); + } + foreach (ERR_FactionChoice option : ERR_FactionChoices.All()) + if (ERR_FactionChoices.Enabled(option.Id, content)) values.Insert("factionAvailable=" + option.Id); + } +} +class ERR_FactionAdminProvider : ERR_AdminProvider +{ + override string Id() { return "factions"; } + override void Capabilities(array values) + { + ERR_GameMode mode = ERR_GameMode.Get(); + if (mode && mode.FactionSelection()) mode.FactionSelection().Snapshot(mode.ContentSettings(), values); + } + override bool Execute(ERR_GameMode mode, string arguments, array replies) + { + array parts = {}; arguments.Split(" ", parts, true); + if (parts.IsEmpty() || parts[0] != "factions") return false; + if (parts.Count() == 5 && parts[1] == "set") + { + array ids = {parts[2], parts[3], parts[4]}; + if (mode.FactionSelection().Save(ids, mode.ContentSettings())) replies.Insert("Faction selections saved for next round. Current factions unchanged."); + else replies.Insert("Could not save factions. Choose three distinct enabled factions, or Random. Previous selections retained."); + return true; + } + foreach (string role : ERR_FactionChoices.Roles()) replies.Insert(role + ": " + ERR_FactionChoices.Label(mode.FactionSelection().Active(role)) + "; next round: " + ERR_FactionChoices.Label(mode.FactionSelection().Saved(role))); + replies.Insert("Use the Factions menu, or /esc factions set ; each accepts a faction ID or random."); + return true; + } +} + +// Only the live mission policy follows role selections. Isolated policy probes +// and registry tooling keep the generic content eligibility behavior. +modded class ERR_ContentPolicy +{ + protected ERR_FactionSelection Roles() + { + ERR_GameMode mode = ERR_GameMode.Get(); + if (mode && mode.Content() == this && mode.FactionSelection() && mode.FactionSelection().Ready()) return mode.FactionSelection(); + return null; + } + override bool Enemy(Faction faction) + { + ERR_FactionSelection roles = Roles(); + if (roles) return faction && faction.GetFactionKey() == roles.Choice("independent").Key; + return super.Enemy(faction); + } + override ResourceName GuardGroup(int index) + { + ERR_FactionSelection roles = Roles(); + if (!roles) return super.GuardGroup(index); + if (index % 2 == 0) return roles.Choice("independent").Group; + return roles.Choice("enemy").Group; + } + override string GuardFaction(ResourceName prefab) + { + ERR_FactionSelection roles = Roles(); + if (roles) + { + if (prefab == roles.Choice("independent").Group) return roles.Choice("independent").Key; + if (prefab == roles.Choice("enemy").Group) return roles.Choice("enemy").Key; + return ""; + } + return super.GuardFaction(prefab); + } +} diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_ForceLedger.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_ForceLedger.c index 617bc72..d2a2023 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_ForceLedger.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_ForceLedger.c @@ -104,9 +104,9 @@ class ERR_ForceLedger if (group && FindGroup(group)) return null; // Retired source IDs also occupy the finite pool: re-registering a removed // formation cannot replace its casualties with a fresh full-strength unit. - if (m_Records.Count() >= 128 || m_Sources.Count() >= 128) + if (m_Records.Count() >= 256 || m_Sources.Count() >= 256) { - if (!m_CapLogged) Print("Reintroduced [ledger]: 128-formation registration cap reached."); + if (!m_CapLogged) Print("Reintroduced [ledger]: 256-formation registration cap reached."); m_CapLogged = true; return null; } diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_GameMode.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_GameMode.c index da97302..b1379bf 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_GameMode.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_GameMode.c @@ -3,6 +3,8 @@ class ERR_GameMode : SCR_BaseGameMode { [RplProp(), Attribute("6090 0 7160", UIWidgets.Coords, "Prison position; configured from authored scenario")] protected vector m_PrisonPosition; + [RplProp()] protected float m_PrisonHeading; + float PrisonHeading() { return m_PrisonHeading; } [Attribute("6090 0 7160", UIWidgets.Coords, "Centre of extraction boundary")] protected vector m_EscapeCenter; [RplProp(), Attribute("9000", UIWidgets.EditBox, "Helicopter must cross this radius to escape")] @@ -14,7 +16,18 @@ class ERR_GameMode : SCR_BaseGameMode [RplProp()] protected string m_PreparationError; protected ref ERR_FactionCatalog m_Factions; + protected ref ERR_FactionSelection m_FactionSelection; + [RplProp()] protected string m_SelectedPlayerFaction; + [RplProp()] protected string m_SelectedEnemyFaction; + ERR_FactionSelection FactionSelection() { return m_FactionSelection; } + void PublishFactionRoles(string player, string enemy) + { + if (!Replication.IsServer()) return; + m_SelectedPlayerFaction = player; m_SelectedEnemyFaction = enemy; Replication.BumpMe(); + } protected ref ERR_Settings m_Settings; + protected ref ERR_ContentPolicy m_Content; + protected ref ERR_ContentSettings m_ContentSettings; protected ref ERR_TimeService m_Time = new ERR_TimeService(); protected ref ERR_EnvironmentService m_Environment = new ERR_EnvironmentService(); protected ref ERR_DiscoveryService m_Discovery; @@ -60,6 +73,12 @@ class ERR_GameMode : SCR_BaseGameMode ERR_PlayerRoster Players() { return m_Roster; } ERR_AdminService Admin() { return m_Admin; } ERR_Settings Settings() { return m_Settings; } + ERR_ContentPolicy Content() { return m_Content; } + ERR_ContentSettings ContentSettings() { return m_ContentSettings; } + bool IsMissionEnemy(Faction faction) + { + return faction && (faction == EnemyFaction() || (m_Content && m_Content.Enemy(faction))); + } ERR_DiscoveryService Discovery() { return m_Discovery; } ERR_TimeService Time() { return m_Time; } ERR_EnvironmentService Environment() { return m_Environment; } @@ -112,11 +131,13 @@ class ERR_GameMode : SCR_BaseGameMode Faction PlayerFaction() { if (!m_Factions) return null; + if (!m_SelectedPlayerFaction.IsEmpty()) return GetGame().GetFactionManager().GetFactionByKey(m_SelectedPlayerFaction); return m_Factions.PlayerFaction(); } Faction EnemyFaction() { if (!m_Factions) return null; + if (!m_SelectedEnemyFaction.IsEmpty()) return GetGame().GetFactionManager().GetFactionByKey(m_SelectedEnemyFaction); return m_Factions.EnemyFaction(); } string PreparationError() { return m_PreparationError; } @@ -164,10 +185,11 @@ class ERR_GameMode : SCR_BaseGameMode bool Active() { return !m_Stopped && !m_NativeEndHandled && m_Phase >= ERR_Phase.PRISON && !ERR_MissionRules.Terminal(m_Phase); } protected ERR_CommandService CreateCommandService() { return null; } - void ConfigureStart(vector position) + void ConfigureStart(vector position, float heading = 0) { if (!Replication.IsServer() || m_Phase != ERR_Phase.PREPARING) return; m_PrisonPosition = position; + m_PrisonHeading = heading; m_EscapeCenter = position; Replication.BumpMe(); } @@ -226,6 +248,13 @@ class ERR_GameMode : SCR_BaseGameMode } } if (m_Command) m_Command.RegisterGroup(group); + ERR_ForceRecord auditRecord = m_Forces.FindGroup(group); + string auditSource = "mission-owned hostile faction"; + if (auditRecord) auditSource = auditRecord.SourceId; + else if (Sites() && Sites().FindGroup(group)) auditSource = Sites().FindGroup(group).Definition.Id; + string auditFaction; + if (group.GetFaction()) auditFaction = group.GetFaction().GetFactionKey(); + PrintFormat("Reintroduced [SPAWN GROUP]: source=%1 faction=%2 group=%3 position=%4 living=%5", auditSource, auditFaction, group, group.GetOrigin(), ForceLiving(group)); m_OnGroup.Invoke(group); } @@ -358,6 +387,11 @@ class ERR_GameMode : SCR_BaseGameMode if (!System.IsConsoleApp()) GetGame().GetCallqueue().CallLater(BindLocalObserver, 250, true); if (!Replication.IsServer()) return; m_Settings = new ERR_Settings(SettingsPath()); + m_ContentSettings = new ERR_ContentSettings(); + m_Content = new ERR_ContentPolicy(m_ContentSettings); + m_FactionSelection = new ERR_FactionSelection(); + m_Admin.Register(new ERR_ContentAdminProvider(m_Content)); + m_Admin.Register(new ERR_FactionAdminProvider()); OnStop().Insert(m_Environment.Stop); m_Recovery = new ERR_RecoveryService(this); m_Recovery.OnChanged().Insert(RecoveryChanged); @@ -405,7 +439,9 @@ class ERR_GameMode : SCR_BaseGameMode protected void StartScenario() { if (m_Stopped || m_NativeEndHandled || m_Phase != ERR_Phase.PREPARING || !m_PreparationError.IsEmpty()) return; + if (!m_FactionSelection || !m_FactionSelection.Prepare(this)) { FailPreparation("Three enabled faction profiles are required for the mission roles."); return; } if (!m_Factions || !m_Factions.WorldReady()) { FailPreparation("The configured player and enemy factions or required assets are missing, invalid or not hostile."); return; } + if (!m_Content || !m_Content.PrepareFactions(this)) { FailPreparation("The content registry or enabled faction profiles are invalid. Check faction keys, guard groups and the mission faction manager."); return; } // Scenario content is created through a separate authoring component. ERR_ScenarioComponent scenario = ERR_ScenarioComponent.Cast(FindComponent(ERR_ScenarioComponent)); ERR_PrepareResult prepared = ERR_PrepareResult.FAILED; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_GeneratedSites.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_GeneratedSites.c new file mode 100644 index 0000000..8f55425 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_GeneratedSites.c @@ -0,0 +1,148 @@ +// Clear-ground sampling adapted from the owner's improvised-prison extension (see migration provenance). +// Searches create no entities and finish before any site starts spawning. +class ERR_CampTerrain +{ + protected ref array m_Airfields = {}; + protected bool Airport(IEntity entity) + { + if (!entity || !entity.GetPrefabData()) return false; + string path = entity.GetPrefabData().GetPrefabName(); path.ToLower(); + return path.Contains("prefabs/structures/") && (path.Contains("/runway") || path.Contains("/lightrunway_") || path.Contains("/aircontroltower") || path.Contains("/airportcontroltower") || path.Contains("/controltower")); + } + protected bool AddAirport(IEntity entity) { m_Airfields.Insert(entity.GetOrigin()); return true; } + void Collect(vector low, vector high) + { + GetGame().GetWorld().QueryEntitiesByAABB(Vector(low[0], -1000, low[2]), Vector(high[0], 5000, high[2]), AddAirport, Airport, EQueryEntitiesFlags.ALL); + } + static vector Rotate(vector offset, float heading) + { + vector rotation[3]; Math3D.AnglesToMatrix(Vector(heading, 0, 0), rotation); + return rotation[0] * offset[0] + rotation[1] * offset[1] + rotation[2] * offset[2]; + } + static vector Local(vector offset, float heading) + { + vector rotation[3]; Math3D.AnglesToMatrix(Vector(heading, 0, 0), rotation); + return Vector(vector.Dot(offset, rotation[0]), offset[1], vector.Dot(offset, rotation[2])); + } + bool Clear(vector center, float heading, float halfSize, float maximumRange, out float lowest) + { + lowest = float.MAX; float highest = -float.MAX; + for (int x = -1; x <= 1; x++) + for (int z = -1; z <= 1; z++) + { + vector corner = center + Rotate(Vector(x * halfSize, 0, z * halfSize), heading); + if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), corner)) return false; + } + // A 2m grid covers the walls, door approach, furniture and all interior slots. + for (float dx = -halfSize; dx <= halfSize; dx += 2) + for (float dz = -halfSize; dz <= halfSize; dz += 2) + { + vector sample = center + Rotate(Vector(dx, 0, dz), heading); + float height = GetGame().GetWorld().GetSurfaceY(sample[0], sample[2]); + lowest = Math.Min(lowest, height); highest = Math.Max(highest, height); + if (lowest < 1 || highest - lowest > maximumRange) return false; + } + for (float cx = -halfSize + 1; cx < halfSize; cx += 2) + for (float cz = -halfSize + 1; cz < halfSize; cz += 2) + { + vector cell = center + Rotate(Vector(cx, 0, cz), heading); + float ground = GetGame().GetWorld().GetSurfaceY(cell[0], cell[2]); + TraceBox trace = new TraceBox(); + trace.Start = Vector(cell[0], ground + 0.12, cell[2]); trace.End = trace.Start + "0 4 0"; + // Bounding the rotated cell conservatively also protects the exit corridor. + trace.Mins = "-1.42 0 -1.42"; trace.Maxs = "1.42 0.1 1.42"; + trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD; + if (GetGame().GetWorld().TraceMove(trace, null) < 1) return false; + } + return true; + } + bool Site(ERR_SiteDefinition site, vector position, float heading, out float baseHeight) + { + bool prison = site.Kind == ERR_LocationKind.PRISON; + if (prison) + { + foreach (vector airport : m_Airfields) if (vector.DistanceXZ(position, airport) < 1500) return false; + } + SCR_AIWorld aiWorld = SCR_AIWorld.Cast(GetGame().GetAIWorld()); + if (!aiWorld || !aiWorld.GetRoadNetworkManager()) return false; + BaseRoad road; float distance; + aiWorld.GetRoadNetworkManager().GetClosestRoad(position, road, distance, skipNavlinks: true); + if (road && distance < 12) return false; + if (!Clear(position, heading, 10, 0.6, baseHeight)) return false; + float guardHeight; + if (site.Guarded && !Clear(position + Rotate(site.GuardOffset, heading), heading, 6, 0.6, guardHeight)) return false; + return true; + } +} +class ERR_GeneratedSites +{ + protected ERR_ScenarioDefinition m_Definition; + protected ref ERR_CampTerrain m_Terrain = new ERR_CampTerrain(); + protected ref array m_Pending = {}; + protected vector m_Low, m_High; + protected int m_Index, m_Attempts, m_Began; + protected bool m_Started; + protected ERR_PrepareResult m_Result = ERR_PrepareResult.PENDING; + protected string m_Error; + string Error() { return m_Error; } + bool Begin(ERR_ScenarioDefinition definition) + { + if (m_Started || !Replication.IsServer() || !definition || !definition.Valid() || !GetGame().GetWorldEntity()) return false; + m_Started = true; m_Definition = definition; + // Prison resolves first, irrespective of authored array order. + foreach (ERR_SiteDefinition prison : definition.Sites) if (prison.RandomClear && prison.Kind == ERR_LocationKind.PRISON) m_Pending.Insert(prison); + foreach (ERR_SiteDefinition radio : definition.Sites) if (radio.RandomClear && radio.Kind == ERR_LocationKind.RADIO) m_Pending.Insert(radio); + foreach (ERR_SiteDefinition mortar : definition.Sites) if (mortar.RandomClear && mortar.Kind == ERR_LocationKind.MORTAR) m_Pending.Insert(mortar); + GetGame().GetWorldEntity().GetWorldBounds(m_Low, m_High); + if (!m_Pending.IsEmpty()) m_Terrain.Collect(m_Low, m_High); + m_Began = System.GetTickCount(); + return true; + } + protected bool Separated(ERR_SiteDefinition candidate, vector position, bool fallback) + { + foreach (ERR_SiteDefinition other : m_Definition.Sites) + { + if (other == candidate) continue; + if (candidate.Kind == ERR_LocationKind.PRISON && other.Kind == ERR_LocationKind.RADIO && other.RandomClear) continue; + float minimum = 300; + if (candidate.Kind == ERR_LocationKind.MORTAR && other.Kind == ERR_LocationKind.MORTAR) minimum = 1000; + if (candidate.Kind == ERR_LocationKind.MORTAR && other.Kind == ERR_LocationKind.PRISON) minimum = 500; + if (candidate.Kind == ERR_LocationKind.RADIO && other.Kind == ERR_LocationKind.PRISON) minimum = 1000; + if (vector.DistanceXZ(position, other.Position) < minimum) return false; + if (candidate.Kind == ERR_LocationKind.PRISON && other.Kind == ERR_LocationKind.EXTRACTION && vector.DistanceXZ(position, other.Position) + 100 >= ERR_GameMode.Get().EscapeRadius()) return false; + } + return true; + } + ERR_PrepareResult Tick() + { + if (m_Result != ERR_PrepareResult.PENDING) return m_Result; + if (!m_Started || !Replication.IsServer()) return ERR_PrepareResult.FAILED; + int tick = System.GetTickCount(); + while (m_Index < m_Pending.Count()) + { + ERR_SiteDefinition site = m_Pending[m_Index]; + bool fallback = m_Attempts >= 12000 || System.GetTickCount() - m_Began >= 30000; + vector position = site.Position; float heading = site.Heading; + if (!fallback) + { + position = Vector(Math.RandomFloat(m_Low[0] + 50, m_High[0] - 50), 0, Math.RandomFloat(m_Low[2] + 50, m_High[2] - 50)); + heading = Math.RandomFloat(0, 360); m_Attempts++; + } + float floor; + if (Separated(site, position, fallback) && m_Terrain.Site(site, position, heading, floor)) + { + position[1] = floor + 0.02; + site.Position = position; site.Heading = heading; site.PlacementResolved = true; + PrintFormat("Escapists [generated site]: id=%1 position=%2 heading=%3 attempts=%4 fallback=%5", site.Id, position, heading, m_Attempts, fallback); + m_Index++; m_Attempts = 0; + } + else if (fallback) + { + m_Error = "No safe terrain or valid authored fallback for " + site.Id; + m_Result = ERR_PrepareResult.FAILED; return m_Result; + } + if (System.GetTickCount() - tick >= 8) return ERR_PrepareResult.PENDING; + } + m_Result = ERR_PrepareResult.READY; return m_Result; + } +} diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_Location.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_Location.c index b26d4f2..ff205a8 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_Location.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_Location.c @@ -1,4 +1,4 @@ -enum ERR_LocationKind { PRISON, RADIO, EXTRACTION, OUTPOST, DEPOT } +enum ERR_LocationKind { PRISON, RADIO, EXTRACTION, OUTPOST, DEPOT, MORTAR } [ComponentEditorProps(category: "Reintroduced", description: "Server-owned location with replicated discovery state")] class ERR_LocationComponentClass : ScriptComponentClass {} diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAdminMenuProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAdminMenuProbe.c index 2eec258..e2545f4 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAdminMenuProbe.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAdminMenuProbe.c @@ -1,9 +1,47 @@ #ifdef WORKBENCH +modded class PauseMenuUI +{ + static bool ERR_ProbeModsEntry() + { + return s_Instance && s_Instance.m_ERRModsButton; + } + static void ERR_ProbeOpenMods() { if (s_Instance) s_Instance.ERR_OpenMods(); } +} // Explicit local fixture: real dialog, widgets and owner RPCs; no mouse/remote claim. modded class ERR_AdminMenu { - bool ERR_ProbeReady() { return m_Ready && m_AvailableFields.Count() == 10; } - void ERR_ProbePage(int page) { ShowPage(page); } + bool ERR_ProbeReady() { return m_Ready && m_AvailableFields.Count() == ERR_AdminMenuFields.Keys().Count(); } + void ERR_ProbePage(int page) + { + OnButton(SCR_ButtonTextComponent.GetButtonText(Navigation()[page], GetRootWidget())); + } + bool ERR_ProbeNavigation(int expected) + { + GetRootWidget().Update(); + if (m_Page != expected) return false; + for (int i = 0; i < Pages().Count(); i++) + { + Widget panel = GetRootWidget().FindAnyWidget(Pages()[i]); + Widget tab = GetRootWidget().FindAnyWidget(Navigation()[i]); + Widget selected = GetRootWidget().FindAnyWidget("Selected_" + Navigation()[i]); + if (!panel || !tab || !selected || panel.IsVisible() != (i == expected) || !tab.IsVisible() || selected.IsVisible() != (i == expected)) return false; + float x, y, width, height, px, py; + tab.GetScreenPos(x, y); tab.GetScreenSize(width, height); panel.GetScreenPos(px, py); + if (width <= 1 || height <= 1 || (i == expected && y + height > py)) + { PrintFormat("ERR ADMIN NAV BOUNDS: page=%1 tab=%2 bounds=%3,%4,%5,%6 panelY=%7", expected, i, x, y, width, height, py); return false; } + } + return true; + } + void ERR_ProbeFieldPage(string key) + { + Widget widget = GetRootWidget().FindAnyWidget(key); + while (widget) + { + int page = Pages().Find(widget.GetName()); + if (page >= 0) { ERR_ProbePage(page); return; } + widget = widget.GetParent(); + } + } string ERR_ProbeDraft() { return SCR_EditBoxComponent.GetEditBoxComponent("radioDefenceSeconds", GetRootWidget()).GetValue(); } void ERR_ProbeEdit(string text) { SCR_EditBoxComponent.GetEditBoxComponent("radioDefenceSeconds", GetRootWidget()).SetValue(text); } void ERR_ProbeApply() @@ -11,8 +49,71 @@ modded class ERR_AdminMenu Widget widget = GetRootWidget().FindAnyWidget("Apply_radioDefenceSeconds"); OnButton(SCR_ButtonBaseComponent.Cast(widget.FindHandler(SCR_ButtonBaseComponent))); } + bool ERR_ProbeRandomChoices() + { + foreach (string key : ERR_AdminMenuFields.Keys()) + { + ERR_ProbeFieldPage(key); + SCR_ButtonTextComponent button = SCR_ButtonTextComponent.GetButtonText("Random_" + key, GetRootWidget()); + SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent(key, GetRootWidget()); + bool available = ERR_AdminMenuFields.Available(key, m_HQ, m_Intel); + if (!button || !edit || button.IsEnabled() != available) return false; + if (!available) continue; + string previous = edit.GetValue(); + OnButton(button); + if (edit.GetValue() != "Random") return false; + edit.SetValue(previous); + } + ERR_ProbeFieldPage("startTimeMinutes"); + SCR_EditBoxComponent time = SCR_EditBoxComponent.GetEditBoxComponent("startTimeMinutes", GetRootWidget()); + string previousTime = time.GetValue(); + SCR_ButtonTextComponent day = SCR_ButtonTextComponent.GetButtonText("RandomDay_startTimeMinutes", GetRootWidget()); + if (!day || !day.IsEnabled()) return false; + OnButton(day); + bool selected = time.GetValue() == "Random day"; + time.SetValue(previousTime); + ERR_ProbePage(0); + return selected; + } + bool ERR_ProbeFactionRandomAll() + { + if (!m_FactionFieldsInitialized || m_FactionIds.Count() < 4) return false; + ERR_ProbePage(4); + if (m_Page != 4) return false; + OnFactionButton(SCR_ButtonTextComponent.GetButtonText("FactionsRandomAll", GetRootWidget())); + foreach (string role : ERR_FactionChoices.Roles()) + { + SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("FactionSelect_" + role, GetRootWidget()); + if (!combo || !combo.IsEnabled() || combo.GetCurrentIndex() != 0) return false; + } + return true; + } + void ERR_ProbeFactionApply() { OnFactionButton(SCR_ButtonTextComponent.GetButtonText("FactionsApply", GetRootWidget())); } bool ERR_ProbeHQ() { return m_HQ; } bool ERR_ProbeIntel() { return m_Intel; } + bool ERR_ProbeMods() + { + GetRootWidget().Update(); + PrintFormat("ERR MOD MENU: page=%1 rows=%2 saved=%3 active=%4", m_Page, m_ModRows.Count(), m_ModSaved.Count(), m_ModActive.Count()); + if (m_Page != 3 || m_ModRows.Count() != ERR_ContentRegistry.Ids().Count()) return false; + foreach (string pack, Widget row : m_ModRows) + { + TextWidget text = TextWidget.Cast(row.FindAnyWidget("PackState")); + if (text) PrintFormat("ERR MOD ROW: id=%1 state=%2", pack, text.GetText()); + if (!text || !text.GetText().Contains("Saved preference:") || !m_ModSaved.Contains(pack) || !m_ModActive.Contains(pack)) return false; + float width, height; row.GetScreenSize(width, height); + PrintFormat("ERR MOD BOUNDS: id=%1 width=%2 height=%3", pack, width, height); + if (width < 100 || height < 2) return false; + } + return true; + } + void ERR_ProbeMod(bool enabled) + { + Widget row = m_ModRows.Get("rhs"); + string name = "Disable"; + if (enabled) name = "Enable"; + OnModButton(SCR_ButtonTextComponent.GetButtonText(name, row)); + } int ERR_ProbeLogCursor() { return m_LogCursor; } bool ERR_ProbeJournalVisible() { @@ -39,10 +140,14 @@ class ERR_NativeAdminMenuProbe protected DamageManagerComponent m_Damage; protected bool m_PreviousDamage; protected int m_Stage; + protected int m_CapturePage; protected float m_Deadline; protected float m_Wait; protected int m_RadioSeconds; protected bool m_Failed; + protected bool m_RhsSaved; + protected ref array m_FactionSaved = {}; + protected ref array m_FactionActive = {}; void ERR_NativeAdminMenuProbe(ERR_GameMode mode) { m_Mode = mode; } protected int Fail(string reason) { @@ -67,8 +172,12 @@ class ERR_NativeAdminMenuProbe m_Damage = DamageManagerComponent.Cast(m_Controller.GetControlledEntity().FindComponent(DamageManagerComponent)); m_PreviousDamage = m_Damage.IsDamageHandlingEnabled(); m_RadioSeconds = m_Mode.Settings().Get("radioDefenceSeconds"); - m_Deadline = now + 45000; - m_Controller.ERR_AdminSubmit("menu"); + m_Deadline = now + 90000; + m_RhsSaved = m_Mode.ContentSettings().Enabled("rhs"); + GetGame().OpenPauseMenu(false, true); + if (!PauseMenuUI.ERR_ProbeModsEntry()) return Fail("Esc menu missing Escapists Reintroduced entry"); + PauseMenuUI.ERR_ProbeOpenMods(); + Pass("native Esc menu entry opens mission administration"); m_Stage = 1; return 0; } if (m_Stage < 6 && !menu) return Fail("dialog unexpectedly closed"); @@ -78,6 +187,32 @@ class ERR_NativeAdminMenuProbe array capabilities = {}; m_Mode.Admin().Capabilities(capabilities); if (menu.ERR_ProbeHQ() != capabilities.Contains("supports=hq") || menu.ERR_ProbeIntel() != capabilities.Contains("supports=intel")) return Fail("capability snapshot mismatch"); Pass("real owner RPC opens menu and returns current settings/capabilities"); + System.MakeScreenshot("$logs:err-admin-open"); + if (!menu.ERR_ProbeNavigation(0)) return Fail("Esc entry did not open Mission page with shared navigation"); + menu.ERR_ProbePage(3); + if (!menu.ERR_ProbeMods()) return Fail("mod rows missing state or collapsed"); + Print("ERR MOD CAPTURE READY"); + menu.ERR_ProbePage(0); + Print("ERR ADMIN CAPTURE PAGE: 0"); + m_Wait = now + 3000; m_Stage = 30; return 0; + } + if (m_Stage == 30) + { + if (!menu || !menu.ERR_ProbeNavigation(m_CapturePage)) return Fail("page or top navigation bounds invalid"); + System.MakeScreenshot("$logs:err-admin-page-" + m_CapturePage.ToString()); + m_CapturePage++; + if (m_CapturePage < ERR_AdminMenu.Pages().Count()) + { menu.ERR_ProbePage(m_CapturePage); Print("ERR ADMIN CAPTURE PAGE: " + m_CapturePage.ToString()); } + else { Pass("all eight pages reached through shared top navigation with one visible page"); m_Stage = 13; } + m_Wait = now + 3000; + return 0; + } + if (m_Stage == 13) + { + if (!menu) return Fail("mods dialog closed during visibility check"); + menu.ERR_ProbePage(0); + if (!menu.ERR_ProbeRandomChoices()) return Fail("random choice controls missing, incorrectly gated or did not select draft"); + Pass("all Random buttons and Random day select editable drafts"); menu.ERR_ProbeEdit("47"); m_Controller.ERR_AdminRequestSnapshot(); m_Wait = now + 3000; m_Stage = 2; return 0; @@ -103,6 +238,51 @@ class ERR_NativeAdminMenuProbe if (m_Mode.Settings().Get("radioDefenceSeconds") != m_RadioSeconds) return Fail("test setting restoration failed"); Pass("test setting restored without resetting menu drafts"); System.MakeScreenshot("$logs:err-admin-hq"); + menu.ERR_ProbePage(3); + menu.ERR_ProbeMod(!m_RhsSaved); + m_Wait = now + 2000; m_Stage = 11; return 0; + } + if (m_Stage == 11) + { + if (!menu || m_Mode.ContentSettings().Enabled("rhs") == m_RhsSaved || m_Mode.Content().Enabled("rhs") != m_RhsSaved || !menu.ERR_ProbeMods()) return Fail("mod button failed to save independently of active round"); + Pass("native mod toggle persists through authenticated owner RPC without changing active content"); + menu.ERR_ProbeMod(m_RhsSaved); + m_Wait = now + 2000; m_Stage = 12; return 0; + } + if (m_Stage == 12) + { + if (!menu || m_Mode.ContentSettings().Enabled("rhs") != m_RhsSaved) return Fail("mod preference restoration failed"); + Pass("native mod toggle restores saved preference"); + foreach (string role : ERR_FactionChoices.Roles()) + { + m_FactionSaved.Insert(m_Mode.FactionSelection().Saved(role)); + m_FactionActive.Insert(m_Mode.FactionSelection().Active(role)); + } + if (!menu.ERR_ProbeFactionRandomAll()) return Fail("faction selectors or Random all failed"); + Pass("three faction selectors and Random all create distinct-role drafts"); + m_Wait = now + 3000; m_Stage = 20; return 0; + } + if (m_Stage == 20) + { + if (!menu || !menu.ERR_ProbeFactionRandomAll()) return Fail("snapshot lost faction drafts"); + menu.ERR_ProbeFactionApply(); + m_Wait = now + 1500; m_Stage = 21; return 0; + } + if (m_Stage == 21) + { + array roles = ERR_FactionChoices.Roles(); + for (int i = 0; i < roles.Count(); i++) + if (m_Mode.FactionSelection().Saved(roles[i]) != "random" || m_Mode.FactionSelection().Active(roles[i]) != m_FactionActive[i]) return Fail("faction Apply failed or changed current round"); + Pass("faction Apply persists all three through authenticated owner RPC without changing active round"); + m_Controller.ERR_AdminSubmit("factions set " + m_FactionSaved[0] + " " + m_FactionSaved[1] + " " + m_FactionSaved[2], true); + m_Wait = now + 1500; m_Stage = 22; return 0; + } + if (m_Stage == 22) + { + array restoredRoles = ERR_FactionChoices.Roles(); + for (int j = 0; j < restoredRoles.Count(); j++) + if (m_Mode.FactionSelection().Saved(restoredRoles[j]) != m_FactionSaved[j]) return Fail("faction restoration failed"); + Pass("saved faction choices restored"); if (menu.ERR_ProbeHQ()) menu.ERR_ProbePage(2); m_Wait = now + 3000; m_Stage = 5; return 0; } diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAdminSettingsProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAdminSettingsProbe.c index c17a68d..965cbe1 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAdminSettingsProbe.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAdminSettingsProbe.c @@ -52,6 +52,57 @@ class ERR_NativeAdminSettingsProbe adapter.Probe(settings, "mission radio show", replies); return Check(replies.Count() == 1 && replies[0] == "radioDefenceSeconds=" + initial.Get("radioDefenceSeconds").ToString(), "existing radio alias and response remain intact"); } + protected static bool RandomChoices(string path, ERR_GameMode mode) + { + ERR_Settings settings = new ERR_Settings(path); + foreach (string key : ERR_AdminMenuFields.Keys()) + { + int parsed; + if (!Check(ERR_CommandArguments.Selection(key, "random", parsed) && parsed == -1 && settings.Set(key, parsed), "random selection saved: " + key)) return false; + ERR_Settings reload = new ERR_Settings(path); + if (!Check(reload.Get(key) == -1 && ERR_AdminMenuFields.Display(key, "-1") == "Random", "random mode survives reload and displays by name: " + key)) return false; + if (key == "startDateCode") continue; + int rolled = settings.Runtime(key); + if (!Check(ERR_Settings.Valid(key, rolled), "random resolves within concrete bounds: " + key)) return false; + for (int i = 0; i < 100; i++) + if (settings.Runtime(key) != rolled) return Check(false, "repeated reads rerolled " + key); + if (!Check(settings.Set(key, -1) && settings.Runtime(key) == rolled, "saving same mode preserves roll: " + key)) return false; + } + int radio = settings.Runtime("radioDefenceSeconds"); + if (!Check(settings.Set("intelRevealChancePercent", 15) && settings.Runtime("radioDefenceSeconds") == radio, "unrelated save does not reroll another option")) return false; + if (!Check(settings.Set("radioDefenceSeconds", 45) && settings.Runtime("radioDefenceSeconds") == 45 && settings.Set("radioDefenceSeconds", -1) && ERR_Settings.Valid("radioDefenceSeconds", settings.Runtime("radioDefenceSeconds")), "fixed value replaces random and random can be selected again")) return false; + if (!Check(!settings.Set("unknown", -1) && !settings.Set("weatherProfile", -2) && !settings.Set("startTimeMinutes", -3), "unknown keys and invalid modes rejected")) return false; + int day; + if (!Check(ERR_CommandArguments.Selection("startTimeMinutes", "random day", day) && day == -2 && settings.Set("startTimeMinutes", day), "Random day parses and saves")) return false; + ERR_Settings reloadedDay = new ERR_Settings(path); + if (!Check(reloadedDay.Get("startTimeMinutes") == -2 && ERR_AdminMenuFields.Display("startTimeMinutes", "-2") == "Random day" && !ERR_CommandArguments.Selection("weatherProfile", "random day", day), "Random day reloads and is restricted to time")) return false; + ChimeraWorld world = ChimeraWorld.CastFrom(mode.GetWorld()); + TimeAndWeatherManagerEntity manager = world.GetTimeAndWeatherManager(); + float sunrise, sunset; + int first, end; + if (!Check(manager.GetSunriseHour(sunrise) && manager.GetSunsetHour(sunset) && ERR_TimeService.DaylightBounds(sunrise, sunset, first, end), "native island sunrise and sunset define Random day")) return false; + if (mode.Settings().Get("startTimeMinutes") == -2) + { + int started = mode.Time().StartMinutes(); + if (!Check(started / 60.0 > sunrise && started / 60.0 < sunset, "actual round starts within native daylight interval")) return false; + } + if (!Check(ERR_Settings.Valid("timeMultiplier", mode.Time().Multiplier()) && ERR_Settings.Valid("startDateCode", mode.Environment().CurrentDateCode()), "actual startup uses concrete speed and valid date")) return false; + for (int sample = 0; sample < 100; sample++) + { + int minute = Math.RandomInt(first, end); + int dateCode = ERR_EnvironmentService.RandomDate(manager.GetYear()); + if (minute / 60.0 <= sunrise || minute / 60.0 >= sunset || !ERR_Settings.Valid("startTimeMinutes", minute)) return Check(false, "daylight roll outside sun bounds"); + if (!ERR_Settings.Valid("startDateCode", dateCode) || 1900 + dateCode / 10000 != manager.GetYear()) return Check(false, "random date outside authored year"); + } + array replies = {}; + ERR_NativeAdminSettingsAdapter adapter = new ERR_NativeAdminSettingsAdapter(); + adapter.Probe(settings, "intel reveal set random", replies); + if (!Check(settings.Get("intelRevealChancePercent") == -1 && replies[0].Contains("Random"), "numeric random passes real command handler")) return false; + replies.Clear(); + ERR_EnvironmentCommands.Execute(settings, mode.Environment(), {"mission", "weather", "set", "random"}, replies); + if (!Check(settings.Get("weatherProfile") == -1 && replies[0].Contains("Random"), "environment random passes real command handler")) return false; + return Check(true, "100 valid daylight and calendar samples; all random modes retained"); + } static bool Run(ERR_GameMode mode) { if (!Check(Replication.IsServer() && mode && mode.Admin() && mode.Settings(), "native Core command service and settings ready")) return false; @@ -65,9 +116,20 @@ class ERR_NativeAdminSettingsProbe if (!Check(replies.Count() == 1 && replies[0].Contains("0-100") && mode.Settings().Get("intelRevealChancePercent") == original, "actual dispatch rejects out-of-range value with guidance")) return false; mode.Admin().Execute(mode, true, "intel reveal help", replies); if (!Check(replies.Count() == 1 && replies[0].Contains("second document"), "actual Core help describes physical document progression")) return false; + int savedTime = mode.Settings().Get("startTimeMinutes"); + int activeTime = mode.Time().StartMinutes(); + mode.Admin().Execute(mode, true, "mission time set random day", replies); + bool dayAccepted = mode.Settings().Get("startTimeMinutes") == -2 && mode.Time().StartMinutes() == activeTime && replies.Count() == 1 && replies[0].Contains("Random day"); + bool timeRestored = mode.Settings().Set("startTimeMinutes", savedTime); + if (!Check(dayAccepted && timeRestored, "Random day command saves next-round mode without jumping active clock; restored")) return false; + mode.Admin().Execute(mode, true, "mission time set random", replies); + bool randomAccepted = mode.Settings().Get("startTimeMinutes") == -1 && mode.Time().StartMinutes() == activeTime && replies.Count() == 1 && replies[0].Contains("Random"); + timeRestored = mode.Settings().Set("startTimeMinutes", savedTime); + if (!Check(randomAccepted && timeRestored, "Random time command preserves active clock; restored")) return false; string path = "$profile:erri-native-admin-settings-probe.json"; if (!Check(!FileIO.FileExists(path) && !FileIO.FileExists(path + ".bak") && !FileIO.FileExists(path + ".tmp"), "disposable fixture filenames are unused")) return false; bool result = Storage(path); + if (result) result = RandomChoices(path, mode); FileIO.DeleteFile(path); FileIO.DeleteFile(path + ".bak"); FileIO.DeleteFile(path + ".tmp"); if (!result) return false; return Check(mode.Settings().Get("intelRevealChancePercent") == original && !FileIO.FileExists(path) && !FileIO.FileExists(path + ".bak") && !FileIO.FileExists(path + ".tmp"), "fixture removed its files and never changed actual mission settings"); diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAggressionProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAggressionProbe.c new file mode 100644 index 0000000..4d3eaa0 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAggressionProbe.c @@ -0,0 +1,121 @@ +#ifdef WORKBENCH +modded class ERR_ObjectiveDefence +{ + bool ERR_ProbeMedical() + { + foreach (ERR_ObjectiveDefenceEntry entry : m_Entries) + { + if (entry.Site.Definition.Id != "everon-medical-1") continue; + if (!entry.Spawned || !entry.Admitted || !entry.Group || !entry.Hold) return false; + return entry.Group.IsExpandComplete() && m_Mode.ForceLiving(entry.Group) == entry.Group.m_aUnitPrefabSlots.Count() + && vector.Distance(entry.Hold.GetOrigin(), entry.Site.Location.Position()) < 0.1 + && m_HQ.ERR_ProbeDedicated(entry.Group); + } + return false; + } +} +modded class ERR_HunterService +{ + ERR_HunterVehicle ERR_ProbeFirst() + { + if (m_Hunters.IsEmpty()) return null; + ERR_HunterVehicle first = m_Hunters[0]; + return first; + } +} +modded class ERR_HQ +{ + void ERR_ProbeBody(int slot, float now) + { + ERR_HQReport report = new ERR_HQReport(); + report.TravelMode = "body"; report.MessageId = 100000 + slot; + report.CasualtySourceId = "fixture-identified"; report.CasualtyStrength = 4; report.CasualtySlot = slot; + report.ObservedAt = now; report.Position = "5000 50 5000"; + // Delivered intake boundary; policy tests independently cover route/replay rules. + Accept(report, now); + } + bool ERR_ProbeDedicated(SCR_AIGroup group) + { + foreach (ERR_HQUnit unit : m_Units) if (unit.Group == group) return unit.ReservedForSupport && !unit.Eligible(m_Mode); + return false; + } + bool ERR_ProbeMedicalDefence() { return m_ObjectiveDefence.ERR_ProbeMedical(); } +} +modded class ERR_GameMode +{ + protected int m_ERRAggressionStage; + protected int m_ERRAggressionDeadline; + protected vector m_ERRHunterStart; + protected bool m_ERRHunterOrder; + protected override void EOnInit(IEntity owner) + { + super.EOnInit(owner); + string outcome; + if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "aggression") return; + m_ERRAggressionDeadline = System.GetTickCount() + 270000; + GetGame().GetCallqueue().CallLater(ERR_AggressionTick, 1000, true); + } + protected bool ERR_AggressionCheck(bool ok, string text) + { + if (ok) { Print("ERR SMOKE PASS: aggression " + text); return true; } + Print("ERR SMOKE FAIL: aggression " + text, LogLevel.ERROR); + GetGame().GetCallqueue().Remove(ERR_AggressionTick); return false; + } + protected void ERR_AggressionTick() + { + if (System.GetTickCount() >= m_ERRAggressionDeadline || Phase() == ERR_Phase.DEFEAT) { ERR_AggressionCheck(false, "native startup/movement deadline"); return; } + if (Phase() < ERR_Phase.PRISON || !GetGame().GetPlayerController()) return; + IEntity player = GetGame().GetPlayerController().GetControlledEntity(); if (!player) return; + DamageManagerComponent damage = DamageManagerComponent.Cast(player.FindComponent(DamageManagerComponent)); + if (damage) damage.EnableDamageHandling(false); + ERR_HQ hq = ERR_HQ.Get(); if (!hq) return; + float now = GetWorld().GetWorldTime(); + if (m_ERRAggressionStage == 0) + { + if (!ERR_AggressionCheck(UnlockPrison(player), "native prison release")) return; + player.SetOrigin(ERR_Actors.Ground(Sites().Find("everon-medical-1").Location.Position() + "300 0 0")); + for (int i = 0; i < 3; i++) hq.ERR_ProbeBody(i, now); + hq.ERR_ProbeBody(2, now); + if (!ERR_AggressionCheck(hq.Watch().IdentifiedBodies() == 3 && hq.Watch().Aggression() == 1 && hq.Watch().HunterTarget() == 1, "three distinct delivered bodies unlock one UAZ; duplicate ignored")) return; + if (!ERR_AggressionCheck(ERR_HQWatchRules.Aggression(90) == 30 && ERR_HQWatchRules.Hunters(30) == 15 && ERR_HQWatchRules.Hunters(1000) == 15, "maximum 30 aggression / 15 UAZ allowance")) return; + for (int j = 0; j < Sites().Count(); j++) + { + ERR_SiteRuntime site = Sites().At(j); + if (site.Group && site.Group.GetFaction() == EnemyFaction() && ERR_ObjectiveDefence.Objective(site.Definition.Kind)) + if (!ERR_AggressionCheck(hq.ERR_ProbeDedicated(site.Group), "objective guard permanently excluded from HQ reassignment: " + site.Definition.Id)) return; + } + m_ERRAggressionStage = 1; + } + ERR_HunterVehicle hunter = hq.Hunters().ERR_ProbeFirst(); + if (!hunter || !hunter.Registered) return; + if (m_ERRAggressionStage == 1) + { + if (!ERR_AggressionCheck(hunter.CrewValid(this) && hunter.Members.Count() == 2, "physical UAZ with native seated driver and gunner")) return; + m_ERRHunterStart = hunter.Entity.GetOrigin(); m_ERRAggressionStage = 2; + } + if (m_ERRAggressionStage == 2) + { + foreach (IEntity soldier : hunter.Members) + { + SCR_AICombatComponent combat = SCR_AICombatComponent.Cast(soldier.FindComponent(SCR_AICombatComponent)); + if (!combat || combat.GetAISkill() != EAISkill.CYLON || !combat.ERR_EnhancedEnemy()) return; + } + if (hunter.Waypoint) m_ERRHunterOrder = true; + if (!m_ERRHunterOrder || vector.DistanceXZ(m_ERRHunterStart, hunter.Entity.GetOrigin()) < 30) return; + if (!hq.ERR_ProbeMedicalDefence()) return; + if (!ERR_AggressionCheck(true, "nearby medical stash gained complete dedicated squad and defend waypoint at terrain height")) return; + if (!ERR_AggressionCheck(true, "HQ radio delivered road order; UAZ drove over 30m; maximum scoped crew skill active")) return; + if (!ERR_AggressionCheck(hq.Hunters().Deployed() == 1, "bounded allowance does not spawn duplicate UAZs")) return; + hq.Hunters().Stop(); + if (!ERR_AggressionCheck(hunter.Released && !hunter.Waypoint && hunter.Entity && hunter.Members.Count() == 2, "stop removes orders and preserves physical vehicle/crew")) return; + foreach (IEntity survivor : hunter.Members) + { + SCR_AICombatComponent survivingCombat = SCR_AICombatComponent.Cast(survivor.FindComponent(SCR_AICombatComponent)); + if (!ERR_AggressionCheck(survivingCombat && survivingCombat.ERR_EnhancedEnemy(), "released driving task preserves surviving crew AI profile")) return; + } + GetGame().GetCallqueue().Remove(ERR_AggressionTick); + Print("ERR SMOKE COMPLETE: aggression, physical UAZ, native movement, dedicated objectives and scoped skill"); + } + } +} +#endif diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAirfieldMarkersProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAirfieldMarkersProbe.c new file mode 100644 index 0000000..c419500 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAirfieldMarkersProbe.c @@ -0,0 +1,205 @@ +#ifdef WORKBENCH +modded class ERR_DiscoveryMarker +{ + string ERR_ProbeIcon() { return m_Icon; } +} +modded class ERR_LocationComponent +{ + bool ERR_ProbeLocationIcon() + { + ERR_UpdateMarker(); + if (Kind() == ERR_LocationKind.PRISON) return !m_ERRMarker; + if (!m_ERRMarker || m_ERRMarker.GetMarkerOwnerID() != -1) return false; + string expected = ERR_DiscoveryMarker.Icon(Kind(), Id()); + if (Knowledge() == 1) expected = "mark-question"; + int point[2]; m_ERRMarker.GetWorldPos(point); + return m_ERRMarker.ERR_ProbeIcon() == expected && point[0] == ERR_DiscoveryMapRules.Coordinate(Position()[0], Knowledge()) && point[1] == ERR_DiscoveryMapRules.Coordinate(Position()[2], Knowledge()); + } +} +modded class ERR_GameMode +{ + protected bool ERR_RadioRoof(IEntity tent, vector position, IEntity exclude = null) + { + TraceParam roof = new TraceParam(); + roof.Start = position; roof.End = position + "0 5 0"; + roof.Flags = TraceFlags.ENTS; roof.Exclude = exclude; + return GetWorld().TraceMove(roof, null) < 1 && roof.TraceEnt == tent; + } + protected bool ERR_TestRadioCamp() + { + ERR_SiteRuntime radio = Sites().Find("everon-radio-1"); + IEntity tent; + foreach (IEntity prop : radio.Props) + if (prop && prop.GetPrefabData().GetPrefabName().Contains("/TentUSSR_01_base.et")) tent = prop; + if (!ERR_AirfieldCheck(tent && ERR_RadioRoof(tent, radio.Location.Position() + "0 0.2 0", radio.Location.GetOwner()), "radio physically underneath tent roof")) return false; + ERR_SpawnPlacement placement = new ERR_SpawnPlacement(); + bool accessible; + for (int step = 0; step < 32; step++) + { + float angle = step * Math.PI / 16; + vector offset = Vector(Math.Cos(angle) * 1.4, 0, Math.Sin(angle) * 1.4); + vector position = ERR_Actors.Ground(radio.Location.Position() + ERR_CampTerrain.Rotate(offset, radio.Definition.Heading)); + if (!placement.Clear(position, null) || !ERR_RadioRoof(tent, position + "0 1.7 0")) continue; + TraceParam sight = new TraceParam(); + sight.Start = position + "0 1.3 0"; sight.End = radio.Location.Position() + "0 0.3 0"; + sight.Flags = TraceFlags.ENTS | TraceFlags.WORLD; + float fraction = GetWorld().TraceMove(sight, null); + if (fraction < 1 && sight.TraceEnt != radio.Location.GetOwner()) continue; + accessible = true; break; + } + if (!ERR_AirfieldCheck(accessible, "standing room inside tent within radio interaction distance and line of sight")) return false; + array guards = {}; radio.Group.GetAgents(guards); + if (!ERR_AirfieldCheck(guards.Count() == radio.InitialGuardCount && guards.Count() > 0, "radio always has its complete physical guard squad")) return false; + foreach (AIAgent guard : guards) + if (!ERR_AirfieldCheck(guard && ERR_Actors.CanFight(guard.GetControlledEntity()), "radio guard alive and conscious")) return false; + if (!ERR_AirfieldCheck(ERR_HQ.Get().ERR_ProbeRadioReservation(radio.Group), "HQ permanently refuses radio squad reassignment and courier reservation")) return false; + array waypoints = {}; radio.Group.GetWaypoints(waypoints); + bool defending; + foreach (AIWaypoint waypoint : waypoints) + if (SCR_DefendWaypoint.Cast(waypoint) && vector.DistanceXZ(waypoint.GetOrigin(), radio.Location.Position()) < 0.1 && waypoint.GetCompletionRadius() == 20) defending = true; + return ERR_AirfieldCheck(defending, "permanent native defend order centred on radio with 20m radius"); + } + + protected int m_ERRAirfieldStage; + protected bool m_ERRAirfieldOpeningChecked; + protected int m_ERRMortarFolders; + protected IEntity m_ERRMortarFolder; + protected ref ERR_AdminItemCallback m_ERRMortarPickup; + protected bool ERR_MortarIntelReady() + { + array batteries = {}; ERR_MortarSystem.Get().Batteries(batteries); + if (batteries.Count() != 4) { ERR_AirfieldCheck(false, "exactly four authored mortar batteries"); return false; } + foreach (ERR_MortarBattery battery : batteries) + { + if (!battery.SpawnFailure.IsEmpty()) { ERR_AirfieldCheck(false, "mortar preparation: " + battery.SpawnFailure); return false; } + if (!battery.StartupActivated) return false; + } + if (m_ERRMortarFolder) + { + if (!m_ERRMortarPickup.Finished) return false; + if (!m_ERRMortarPickup.Succeeded) { ERR_AirfieldCheck(false, "native intel inventory insertion"); return false; } + if (!Discovery().IsSpent(m_ERRMortarFolder)) return false; + m_ERRMortarFolder = null; + } + int known; + for (int i = 1; i <= 4; i++) + if (Sites().Find("everon-mortar-" + i).Location.Knowledge() == 2) known++; + if (known == 4) return true; + if (m_ERRMortarFolders >= 8) { ERR_AirfieldCheck(false, "all four mortar locations discoverable within eight folders"); return false; } + // Exclude already-known non-mortar destinations, then exercise actual normal + // untargeted folder recovery. No direct mortar Discover call is made here. + array locations = {}; Locations(locations); + foreach (ERR_LocationComponent location : locations) + if (location.Kind() != ERR_LocationKind.MORTAR) location.Discover(0, 100); + IEntity player = GetGame().GetPlayerController().GetControlledEntity(); + m_ERRMortarFolder = ERR_Actors.Spawn(ERR_Resources.IntelFolder(), player.GetOrigin()); Own(m_ERRMortarFolder); + m_ERRMortarPickup = new ERR_AdminItemCallback(); + SCR_InventoryStorageManagerComponent inventory = SCR_InventoryStorageManagerComponent.Cast(player.FindComponent(SCR_InventoryStorageManagerComponent)); + if (!m_ERRMortarFolder || !inventory.TryInsertItem(m_ERRMortarFolder, EStoragePurpose.PURPOSE_DEPOSIT, m_ERRMortarPickup)) + ERR_AirfieldCheck(false, "physical intel pickup accepted"); + m_ERRMortarFolders++; + return false; + } + + protected int m_ERRAirfieldDeadline; + protected float m_ERRAirfieldWait; + protected override void EOnInit(IEntity owner) + { + super.EOnInit(owner); + string outcome; + if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "airfield-markers") return; + m_ERRAirfieldDeadline = System.GetTickCount() + 200000; + GetGame().GetCallqueue().CallLater(ERR_AirfieldTick, 500, true); + } + protected bool ERR_AirfieldCheck(bool value, string text) + { + if (value) { Print("ERR SMOKE PASS: airfield " + text); return true; } + Print("ERR SMOKE FAIL: airfield " + text, LogLevel.ERROR); + GetGame().GetCallqueue().Remove(ERR_AirfieldTick); return false; + } + protected void ERR_AirfieldTick() + { + if (System.GetTickCount() >= m_ERRAirfieldDeadline || Phase() == ERR_Phase.DEFEAT) { ERR_AirfieldCheck(false, "preparation or deadline failure"); return; } + if ((Phase() != ERR_Phase.PRISON && Phase() != ERR_Phase.RADIO_SEARCH) || !GetGame().GetPlayerController() || !GetGame().GetPlayerController().GetControlledEntity()) return; + if (GetWorld().GetWorldTime() < m_ERRAirfieldWait) return; + if (m_ERRAirfieldStage == 0) + { + if (!m_ERRAirfieldOpeningChecked) + { + if (!ERR_TestRadioCamp()) return; + if (!ERR_AirfieldCheck(ERR_NativeScoutProbe.Run(), "ten hill scouts: complete finite loadouts, full ammo packs, physical radios and reserved observation posts")) return; + ERR_SiteRuntime prison = Sites().Find("everon-prison-1"); + array guards = {}; prison.Group.GetAgents(guards); + int radios; array guardPositions = {}; + foreach (AIAgent guard : guards) + { + IEntity actor = guard.GetControlledEntity(); + float distance = vector.DistanceXZ(actor.GetOrigin(), prison.Location.Position()); + if (!ERR_AirfieldCheck(distance >= 11.5 && distance <= 24.5 && !guard.IsAIActivated() && ERR_Actors.CanFight(actor), "guard alive, frozen and outside prison at random perimeter position")) return; + foreach (vector previous : guardPositions) + if (!ERR_AirfieldCheck(vector.DistanceXZ(previous, actor.GetOrigin()) > 3, "guards occupy separate perimeter positions")) return; + guardPositions.Insert(actor.GetOrigin()); + if (ERR_HQRadio.Capability(actor) == 2) radios++; + } + if (!ERR_AirfieldCheck(radios > 0, "perimeter patrol retains physical large radio")) return; + for (int mortarId = 1; mortarId <= 4; mortarId++) + { + ERR_LocationComponent hidden = Sites().Find("everon-mortar-" + mortarId).Location; + if (!ERR_AirfieldCheck(hidden.Knowledge() == 0, "mortar initially undiscovered")) return; + } + ERR_LocationComponent partial = Sites().Find("everon-mortar-1").Location; + partial.Discover(99, 0); + if (!ERR_AirfieldCheck(partial.Knowledge() == 1 && partial.ERR_ProbeLocationIcon(), "partial mortar intel displays question mark at coarse location")) return; + IEntity player = GetGame().GetPlayerController().GetControlledEntity(); + if (!ERR_AirfieldCheck(UnlockPrison(player), "normal prison unlock starts HQ and mortar activity")) return; + // Park only the fixture actor beside the unguarded medical stash so nearby + // prison combat does not interrupt the inventory/UI observation sequence. + ERR_SpawnPlacement placement = new ERR_SpawnPlacement(); bool parked; + for (int spot = 0; spot < 8; spot++) + { + vector position = ERR_Actors.Ground(Sites().Find("everon-medical-1").Location.Position() + Vector(5 + spot * 2, 0, 5)); + if (!placement.Clear(position, null, -1, player)) continue; + vector transform[4]; player.GetTransform(transform); transform[3] = position; + ChimeraCharacter.Cast(player).Teleport(transform); parked = true; break; + } + if (!ERR_AirfieldCheck(parked, "fixture actor parked on checked terrain for mortar intel observation")) return; + m_ERRAirfieldOpeningChecked = true; + } + if (!ERR_MortarIntelReady()) return; + if (!ERR_AirfieldCheck(!Sites().Find("everon-supply-2"), "guarded car-and-crate site absent")) return; + ERR_SiteRuntime extraction = Sites().Find("everon-extraction-1"); + if (!ERR_AirfieldCheck(extraction.Definition.RequireAirfield && vector.DistanceXZ(ExtractionVehicle().GetOrigin(), "4800.953 0 11487.412") < 3 && ERR_CasualtyLoadingRules.AircraftReady(ExtractionVehicle()), "production aircraft settled intact on validated Everon airfield pad")) return; + if (!ERR_AirfieldCheck(!ERR_AirfieldPlacement.Clear(ERR_Actors.Ground("7012 0 7500")), "old off-airfield helicopter position rejected")) return; + if (!ERR_AirfieldCheck(!ERR_AirfieldPlacement.Clear(ExtractionVehicle().GetOrigin()), "occupied airfield pad rejected")) return; + if (!ERR_AirfieldCheck(Sites().Find("everon-medical-1").Definition.RevealOnEscape && Sites().Find("everon-supply-1").Definition.RevealOnEscape, "two reachable intel sites retained")) return; + if (!ERR_AirfieldCheck(true, "four stocked operational mortar batteries discovered through physical intel")) return; + array locations = {}; Locations(locations); + foreach (ERR_LocationComponent location : locations) + { + location.Discover(0, 100); + if (!ERR_AirfieldCheck(location.Knowledge() == 2 && location.ERR_ProbeLocationIcon(), "identified " + location.Id() + " has correct map visibility and icon")) return; + } + GetGame().GetMenuManager().OpenMenu(ChimeraMenuPreset.MapMenu); + m_ERRAirfieldStage = 1; m_ERRAirfieldWait = GetWorld().GetWorldTime() + 3000; return; + } + SCR_MapEntity mapEntity = SCR_MapEntity.GetMapInstance(); + if (m_ERRAirfieldStage == 1) + { + if (!ERR_AirfieldCheck(mapEntity && mapEntity.IsOpen(), "native fullscreen map open")) return; + mapEntity.ZoomPanSmooth(0.08, 5800, 9000, 0.1); + m_ERRAirfieldStage = 2; m_ERRAirfieldWait = GetWorld().GetWorldTime() + 3000; return; + } + if (m_ERRAirfieldStage == 2) + { + System.MakeScreenshot("$logs:err-airfield-map-icons"); + Print("ERR AIRFIELD CAPTURE READY"); + m_ERRAirfieldStage = 3; m_ERRAirfieldWait = GetWorld().GetWorldTime() + 15000; return; + } + GetGame().GetMenuManager().CloseMenuByPreset(ChimeraMenuPreset.MapMenu); + GetGame().GetCallqueue().Remove(ERR_AirfieldTick); + // Runner tears down the test world. Avoid mixing native map close with a + // same-frame task-list teardown, which has a vanilla delayed-refresh race. + Print("ERR SMOKE COMPLETE: airfield, four mortar intel sites, hidden prison and random perimeter guards"); + } +} +#endif diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeBaseSmoke.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeBaseSmoke.c index ffb5beb..a242406 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeBaseSmoke.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeBaseSmoke.c @@ -54,7 +54,7 @@ modded class ERR_GameMode string enabled; if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errBaseSmoke", enabled) || enabled != "1") return; string nativeEndCase; - if (System.GetCLIParam("errSmokeOutcome", nativeEndCase) && (nativeEndCase == "native-end-active" || nativeEndCase == "native-end-preparing")) return; + if (System.GetCLIParam("errSmokeOutcome", nativeEndCase) && (nativeEndCase == "aggression" || nativeEndCase == "native-end-active" || nativeEndCase == "native-end-preparing" || nativeEndCase == "generated-sites" || nativeEndCase == "mortar-station" || nativeEndCase == "opening-tools" || nativeEndCase == "airfield-markers" || nativeEndCase == "air-logistics" || nativeEndCase == "air-assault" || nativeEndCase == "air-recon")) return; ERR_NativeObjectiveProbe.BeginRound(); ERR_NativeProgressionIdentityProbe.BeginRound(); string outcome; @@ -565,6 +565,11 @@ modded class ERR_GameMode if (m_ERRExtractionCase == "prisoner-kit") { + int contentMenu = ERR_ProbeAdminMenu(now); + if (contentMenu < 0) { ERR_BaseCheck(false, "content admin menu"); return; } + if (contentMenu == 0) return; + if (!ERR_NativeAdminSettingsProbe.Run(this)) { ERR_BaseCheck(false, "random settings storage and commands"); return; } + if (!ERR_NativeContentProbe.Run(this)) { ERR_BaseCheck(false, "content registry and backpack"); return; } DamageManagerComponent.Cast(m_ERRBasePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false); m_ERRPrisonerProbe = new ERR_NativePrisonerProbe(this, m_ERRBasePlayer); m_ERRBaseStep = 700; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeContentProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeContentProbe.c new file mode 100644 index 0000000..0e1d87b --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeContentProbe.c @@ -0,0 +1,71 @@ +#ifdef WORKBENCH +class ERR_NativeContentProbe +{ + protected static bool Check(bool condition, string detail) + { + if (condition) { Print("ERR SMOKE PASS: content " + detail); return true; } + Print("ERR SMOKE FAIL: content " + detail, LogLevel.ERROR); + return false; + } + static bool Run(ERR_GameMode mode) + { + if (!ERR_NativeFactionSelectionProbe.Run(mode)) return false; + ERR_ContentDefinition definition = ERR_ContentRegistry.Definition(); + if (!Check(definition && definition.Valid() && definition.Options.Count() == 4, "native pack and three faction definitions load")) return false; + ERR_SiteRuntime prison = mode.Sites().Find("everon-prison-1"); + if (!Check(prison && prison.Container && prison.Container.FindComponent(BaseLoadoutClothComponent), "prison supplies use a wearable ground backpack")) return false; + BaseInventoryStorageComponent storage = BaseInventoryStorageComponent.Cast(prison.Container.FindComponent(BaseInventoryStorageComponent)); + array items = {}; + if (storage) storage.GetAll(items); + array unique = {}; + int pistols, magazines, maps; + foreach (IEntity item : items) + { + if (!item || unique.Contains(item)) continue; + unique.Insert(item); + if (!Check(ERR_SpawnSceneInventory.InStorage(item), "bag item has native visible storage ownership")) return false; + ResourceName prefab = item.GetPrefabData().GetPrefabName(); + PrintFormat("ERR CONTENT BAG: item=%1 prefab=%2", item, prefab); + if (prefab.Contains("Handgun_PM.et")) pistols++; + else if (prefab.Contains("Magazine_9x18_PM_8rnd_Ball.et")) magazines++; + else maps++; + } + if (!Check(unique.Count() == 14 && pistols == 5 && magazines == 8 && maps == 1, "backpack physically contains five pistols, eight magazines and map before admission")) return false; + array siteIds = {"everon-supply-1", "everon-radio-1", "everon-supply-2"}; + array factions = {mode.PlayerFaction(), mode.EnemyFaction()}; + int poolIndex; + for (int i = 0; i < siteIds.Count(); i++) + { + ERR_SiteRuntime site = mode.Sites().Find(siteIds[i]); + ResourceName expected; + if (site && site.Definition.AllowFactionPool) expected = mode.Content().GuardGroup(poolIndex++); + if (expected.IsEmpty()) expected = mode.Factions().GuardGroupPrefab; + if (!Check(site && site.Group && site.Group.GetPrefabData().GetPrefabName() == expected && mode.IsMissionEnemy(site.Group.GetFaction()), "selected physical faction group at " + siteIds[i])) return false; + if (!factions.Contains(site.Group.GetFaction())) factions.Insert(site.Group.GetFaction()); + } + foreach (Faction a : factions) + foreach (Faction b : factions) + if (a != b && !Check(a.IsFactionEnemy(b) && b.IsFactionEnemy(a), "mutual hostility " + a.GetFactionKey() + " / " + b.GetFactionKey())) return false; + array replies = {}; + bool original = mode.ContentSettings().Enabled("rhs"); + mode.Admin().Execute(mode, false, "mods rhs disable", replies); + if (!Check(replies.Count() == 1 && replies[0] == "Administrator access required." && mode.ContentSettings().Enabled("rhs") == original, "unauthorized mod command rejected")) return false; + string path = "$profile:erri-content-probe.json"; + if (!Check(!FileIO.FileExists(path), "disposable preference path unused")) return false; + ERR_ContentSettings settings = new ERR_ContentSettings(path); + ERR_ContentPolicy active = new ERR_ContentPolicy(settings); + if (!Check(active.Enabled("rhs") && active.Enabled("rhs_usmc") && !active.Enabled("unknown"), "new options default on; unknown IDs denied")) return false; + if (!Check(settings.Set("rhs", 0) && active.Enabled("rhs_usmc"), "saving pack disable leaves active round unchanged")) return false; + ERR_ContentSettings reloaded = new ERR_ContentSettings(path); + ERR_ContentPolicy disabled = new ERR_ContentPolicy(reloaded); + if (!Check(!disabled.Enabled("rhs") && !disabled.Enabled("rhs_usmc") && !disabled.Enabled("rhs_afrf") && !disabled.Enabled("rhs_ion") && disabled.GuardGroup(0).IsEmpty(), "reload excludes disabled pack and every child faction")) return false; + if (!Check(!disabled.Allows("{A1D5833FF10C6503}Prefabs/Weapons/Attachments/Optics/Optic_PSO1/Optic_PSO1M2.et") && disabled.Allows("{C850A33226B8F9C1}Prefabs/Weapons/Attachments/Optics/Optic_PSO1/Optic_PSO1.et"), "disabled RHS optics keep vanilla alternatives")) return false; + if (!Check(reloaded.Set("rhs", 1) && reloaded.Set("rhs_usmc", 0), "pack and individual faction preferences save independently")) return false; + ERR_ContentPolicy selected = new ERR_ContentPolicy(reloaded); + if (!Check(selected.Enabled("rhs") && !selected.Enabled("rhs_usmc") && selected.Enabled("rhs_afrf") && selected.Enabled("rhs_ion"), "individual faction disable preserves sibling factions")) return false; + if (!Check(!reloaded.Set("unknown", 1) && !reloaded.Set("rhs", 2), "unknown IDs and malformed flags rejected")) return false; + FileIO.DeleteFile(path); FileIO.DeleteFile(path + ".bak"); FileIO.DeleteFile(path + ".tmp"); + return Check(!FileIO.FileExists(path) && mode.ContentSettings().Enabled("rhs") == original, "disposable preferences cleaned; live preferences unchanged"); + } +} +#endif diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeContractsProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeContractsProbe.c index 14e03c1..9ff4deb 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeContractsProbe.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeContractsProbe.c @@ -13,7 +13,9 @@ class ERR_NativeContractsProbe if (!Check(mode.Factions() && mode.Factions().WorldReady() && mode.PreparationError().IsEmpty(), "catalog resolves hostile vanilla factions and successful preparation")) return false; if (!Check(SCR_FactionManager.SGetFaction(player) == mode.PlayerFaction() && player.GetPrefabData().GetPrefabName() == mode.Factions().PrisonerPrefab, "physical prisoner matches injected faction and resource")) return false; ERR_SiteRuntime site = mode.Sites().Find("everon-supply-1"); - if (!Check(site && site.Group && site.Group.GetFaction() == mode.EnemyFaction() && site.Group.GetPrefabData().GetPrefabName() == mode.Factions().GuardGroupPrefab, "physical guard squad comes from injected catalog")) return false; + ResourceName expectedGroup = mode.Content().GuardGroup(0); + if (expectedGroup.IsEmpty()) expectedGroup = mode.Factions().GuardGroupPrefab; + if (!Check(site && site.Group && mode.IsMissionEnemy(site.Group.GetFaction()) && site.Group.GetPrefabData().GetPrefabName() == expectedGroup, "physical guard squad comes from enabled catalog")) return false; ERR_FactionCatalog isolated = ERR_FactionCatalog.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{BE50B97B8E2B36E6}Configs/ERR/VanillaFactions.conf")); if (!isolated) return false; string enemyKey = isolated.EnemyFactionKey; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeDefinitionProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeDefinitionProbe.c index d795dd9..daea402 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeDefinitionProbe.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeDefinitionProbe.c @@ -9,7 +9,7 @@ class ERR_NativeDefinitionProbe } static bool Run() { - ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{BC310DC0CD27F523}Configs/ERR/EveronSites.conf")); + ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf")); if (!Check(definition && definition.Valid(), "authored configuration resolves")) return false; ERR_SiteDefinition cache = definition.Find("everon-supply-1"); if (!cache) return false; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeFactionCatalogProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeFactionCatalogProbe.c index 0c9d96c..881e6f6 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeFactionCatalogProbe.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeFactionCatalogProbe.c @@ -53,7 +53,7 @@ class ERR_NativeFactionCatalogProbe if (!Check(manager != null, "native faction manager exists")) return false; SCR_Faction us = SCR_Faction.Cast(manager.GetFactionByKey("US")); SCR_Faction ussr = SCR_Faction.Cast(manager.GetFactionByKey("USSR")); - if (!Check(us && ussr && mode.PlayerFaction() == us && mode.EnemyFaction() == ussr, "mission retains US and USSR faction identities")) return false; + if (!Check(us && ussr && mode.PlayerFaction().GetFactionKey() == mode.FactionSelection().Choice("escapists").Key && mode.EnemyFaction().GetFactionKey() == mode.FactionSelection().Choice("enemy").Key, "mission uses selected faction identities")) return false; if (!Check(us.IsFactionEnemy(ussr) && ussr.IsFactionEnemy(us) && !us.IsFactionEnemy(us) && !ussr.IsFactionEnemy(ussr), "mutual enemy and same-faction relations preserved")) return false; if (!Check(us.GetFactionRadioEncryptionKey() == "chickenNuggets" && ussr.GetFactionRadioEncryptionKey() == "coldBorscht" && us.GetFactionRadioFrequency() == 48000 && ussr.GetFactionRadioFrequency() == 42000, "native radio encryption and frequencies preserved")) return false; if (!Check(us.GetCallsignInfo() && ussr.GetCallsignInfo(), "native faction callsign configurations resolve")) return false; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeFactionSelectionProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeFactionSelectionProbe.c new file mode 100644 index 0000000..03d6ce6 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeFactionSelectionProbe.c @@ -0,0 +1,53 @@ +#ifdef WORKBENCH +class ERR_NativeFactionSelectionProbe +{ + protected static bool Check(bool condition, string detail) + { + if (condition) { Print("ERR SMOKE PASS: factions " + detail); return true; } + Print("ERR SMOKE FAIL: factions " + detail, LogLevel.ERROR); return false; + } + static bool Run(ERR_GameMode mode) + { + ERR_FactionSelection live = mode.FactionSelection(); + if (!Check(live && live.Ready() && mode.PlayerFaction() != mode.EnemyFaction(), "server resolves distinct roles before preparation")) return false; + Faction independent = GetGame().GetFactionManager().GetFactionByKey(live.Choice("independent").Key); + if (!Check(independent && independent != mode.PlayerFaction() && independent != mode.EnemyFaction() && mode.IsMissionEnemy(independent) && !mode.IsMissionEnemy(mode.PlayerFaction()), "independent is hostile while player faction is excluded")) return false; + IEntity player = GetGame().GetPlayerController().GetControlledEntity(); + FactionAffiliationComponent affiliation = FactionAffiliationComponent.Cast(player.FindComponent(FactionAffiliationComponent)); + if (!Check(affiliation && affiliation.GetAffiliatedFaction() == mode.PlayerFaction(), "admitted prisoner uses selected player faction")) return false; + map saved = new map(); + map resolved = new map(); + saved.Set("escapists", "random"); saved.Set("enemy", "us"); saved.Set("independent", "fia"); + for (int i = 0; i < 30; i++) + { + array pool = {"us", "ussr", "fia"}; + if (!ERR_FactionChoices.Resolve(pool, saved, resolved) || resolved.Get("escapists") != "ussr" || resolved.Get("enemy") != "us" || resolved.Get("independent") != "fia") return Check(false, "Random stole a later explicit selection"); + } + if (!Check(true, "Random reserves later explicit roles first")) return false; + saved.Set("enemy", "random"); saved.Set("independent", "random"); + for (int j = 0; j < 60; j++) + { + array all = {"us", "ussr", "fia", "rhs_afrf", "rhs_usmc", "rhs_ion"}; + if (!ERR_FactionChoices.Resolve(all, saved, resolved) || !ERR_FactionChoices.Distinct(resolved)) return Check(false, "Random all duplicated a faction"); + } + if (!Check(true, "Random all chooses three different factions")) return false; + saved.Set("enemy", "rhs_afrf"); + array basePool = {"us", "ussr", "fia"}; + if (!Check(ERR_FactionChoices.Resolve(basePool, saved, resolved) && resolved.Get("enemy") != "rhs_afrf", "disabled choice falls back to eligible random faction")) return false; + array replies = {}; + string previous = live.Saved("escapists"); + mode.Admin().Execute(mode, false, "factions set random random random", replies); + if (!Check(replies.Count() == 1 && replies[0] == "Administrator access required." && live.Saved("escapists") == previous, "unauthorized role changes rejected")) return false; + string path = "$profile:erri-faction-probe.json"; + ERR_FactionSelection settings = new ERR_FactionSelection(path); + array valid = {"random", "ussr", "fia"}; + array duplicate = {"us", "us", "fia"}; + array unknown = {"unknown", "ussr", "fia"}; + if (!Check(settings.Save(valid, mode.ContentSettings()) && !settings.Save(duplicate, mode.ContentSettings()) && !settings.Save(unknown, mode.ContentSettings()), "atomic persistence rejects duplicate and unknown selections")) return false; + ERR_FactionSelection reloaded = new ERR_FactionSelection(path); + if (!Check(reloaded.Saved("escapists") == "random" && reloaded.Saved("enemy") == "ussr" && reloaded.Saved("independent") == "fia", "saved roles survive reload and rejected changes")) return false; + FileIO.DeleteFile(path); FileIO.DeleteFile(path + ".bak"); FileIO.DeleteFile(path + ".tmp"); + return true; + } +} +#endif diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeGeneratedSitesProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeGeneratedSitesProbe.c new file mode 100644 index 0000000..821380d --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeGeneratedSitesProbe.c @@ -0,0 +1,138 @@ +#ifdef WORKBENCH +modded class ERR_LocationComponent +{ + bool ERR_ProbeGeneratedMarker(bool expected) + { + ERR_UpdateMarker(); + if (!expected) return m_ERRMarker == null; + if (!m_ERRMarker) return false; + int point[2]; m_ERRMarker.GetWorldPos(point); + return point[0] == ERR_DiscoveryMapRules.Coordinate(Position()[0], Knowledge()) && point[1] == ERR_DiscoveryMapRules.Coordinate(Position()[2], Knowledge()); + } +} +modded class ERR_GameMode +{ + protected int m_ERRGeneratedStage; + protected float m_ERRGeneratedWait; + protected int m_ERRGeneratedDeadline; + protected IEntity m_ERRGeneratedPlayer, m_ERRGeneratedNear, m_ERRGeneratedFar, m_ERRGeneratedSeated; + protected Vehicle m_ERRGeneratedVehicle; + protected ERR_LocationComponent m_ERRGeneratedRadio; + protected ref array m_ERRGeneratedTeam = {}; + protected ref map m_ERRGeneratedReservations = new map(); + protected bool m_ERRGeneratedTeamChecked; + protected int ERR_GeneratedTeamSlots(IEntity player) + { + if (m_ERRGeneratedTeamChecked) return 1; + ERR_SpawnPlacement placement = new ERR_SpawnPlacement(); + if (m_ERRGeneratedTeam.IsEmpty() && !ERR_GeneratedCheck(!placement.Clear(player.GetOrigin(), null), "physical player blocks occupied slot without a reservation")) return -1; + if (m_ERRGeneratedTeam.IsEmpty() && !ERR_GeneratedCheck(!placement.Clear(ERR_Actors.Ground(PrisonPosition() + ERR_CampTerrain.Rotate("0 0 6", PrisonHeading())), null), "rotated physical wall blocks spawn volume")) return -1; + m_ERRGeneratedReservations.Set(0, player.GetOrigin()); + if (m_ERRGeneratedTeam.Count() < 11) + { + vector position; + if (!ERR_GeneratedCheck(placement.Prison(PrisonPosition(), m_ERRGeneratedReservations, position, PrisonHeading()), "rotated prison clear slot for member " + (m_ERRGeneratedTeam.Count() + 2))) return -1; + IEntity actor = ERR_Actors.Spawn(Factions().PrisonerPrefab, position); + if (!ERR_GeneratedCheck(actor != null, "additional physical prisoner created")) return -1; + Own(actor); m_ERRGeneratedTeam.Insert(actor); m_ERRGeneratedReservations.Set(m_ERRGeneratedTeam.Count(), position); + DamageManagerComponent.Cast(actor.FindComponent(DamageManagerComponent)).EnableDamageHandling(false); + return 0; + } + foreach (IEntity prisoner : m_ERRGeneratedTeam) ERR_Actors.DeleteSpawned(prisoner); + m_ERRGeneratedTeam.Clear(); m_ERRGeneratedReservations.Clear(); m_ERRGeneratedTeamChecked = true; + return 1; + } + protected override void EOnInit(IEntity owner) + { + super.EOnInit(owner); + string outcome; + if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "generated-sites") return; + m_ERRGeneratedDeadline = System.GetTickCount() + 120000; + GetGame().GetCallqueue().CallLater(ERR_GeneratedProbeTick, 500, true); + } + protected bool ERR_GeneratedCheck(bool value, string detail) + { + if (value) { Print("ERR SMOKE PASS: generated " + detail); return true; } + Print("ERR SMOKE FAIL: generated " + detail, LogLevel.ERROR); + GetGame().GetCallqueue().Remove(ERR_GeneratedProbeTick); return false; + } + protected void ERR_GeneratedProbeTick() + { + if (System.GetTickCount() > m_ERRGeneratedDeadline || !PreparationError().IsEmpty()) { ERR_GeneratedCheck(false, "deadline or preparation failure: " + PreparationError()); return; } + if (!Active() || GetGame().GetWorld().GetWorldTime() < m_ERRGeneratedWait) return; + SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController()); + if (!controller || !controller.GetControlledEntity()) return; + if (m_ERRGeneratedStage == 0) + { + m_ERRGeneratedPlayer = controller.GetControlledEntity(); + if (ERR_GeneratedTeamSlots(m_ERRGeneratedPlayer) != 1) return; + ERR_SiteRuntime prison = Sites().Find("everon-prison-1"); ERR_SiteRuntime radio = Sites().Find("everon-radio-1"); + if (!ERR_GeneratedCheck(prison && radio && prison.Definition.PlacementResolved && radio.Definition.PlacementResolved, "both generated locations committed before admission")) return; + if (!ERR_GeneratedCheck(vector.DistanceXZ(prison.Definition.Position, radio.Definition.Position) >= 1000 && vector.DistanceXZ(prison.Definition.Position, "6090 0 7160") > 1, "fresh island prison and separated radio")) return; + vector local = ERR_CampTerrain.Local(m_ERRGeneratedPlayer.GetOrigin() - PrisonPosition(), PrisonHeading()); + if (!ERR_GeneratedCheck(Math.AbsFloat(local[0]) <= 5.5 && Math.AbsFloat(local[2]) <= 5.5 && PrisonEscape().Registered(), "player inside rotated prison with intact registered gate")) return; + BaseInventoryStorageComponent bag = BaseInventoryStorageComponent.Cast(prison.Container.FindComponent(BaseInventoryStorageComponent)); + array contents = {}; if (bag) bag.GetAll(contents); + if (!ERR_GeneratedCheck(contents.Count() == 14 && radio.Group.GetFaction() == EnemyFaction(), "starter backpack ready and camp guards use enemy faction")) return; + m_ERRGeneratedRadio = radio.Location; + if (!ERR_GeneratedCheck(radio.Props.Count() == 5, "radio composition has five independently owned scenery objects")) return; + if (!ERR_GeneratedCheck(m_ERRGeneratedRadio.ERR_ProbeGeneratedMarker(false), "undiscovered radio location hidden")) return; + m_ERRGeneratedRadio.Discover(99, 0); + if (!ERR_GeneratedCheck(m_ERRGeneratedRadio.Knowledge() == 1 && m_ERRGeneratedRadio.ERR_ProbeGeneratedMarker(true), "partial intel creates approximate question marker")) return; + m_ERRGeneratedRadio.Discover(99, 0); + if (!ERR_GeneratedCheck(m_ERRGeneratedRadio.Knowledge() == 2 && m_ERRGeneratedRadio.ERR_ProbeGeneratedMarker(true), "further intel reveals actual generated coordinates")) return; + m_ERRGeneratedNear = ERR_Actors.Spawn(Factions().PrisonerPrefab, ERR_Actors.Ground(PrisonPosition() + "0 0 15")); Own(m_ERRGeneratedNear); + vector far; + for (int radius = 1200; radius <= 6000 && far == vector.Zero; radius += 300) + for (int i = 0; i < 36; i++) + { + vector candidate = ERR_Actors.Ground(PrisonPosition() + ERR_CampTerrain.Rotate(Vector(radius, 0, 0), i * 10)); + if (candidate[1] > 1 && !ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetWorld(), candidate)) { far = candidate; break; } + } + if (!ERR_GeneratedCheck(far != vector.Zero, "dry outside-radius test location available")) return; + m_ERRGeneratedFar = ERR_Actors.Spawn(Factions().PrisonerPrefab, far); Own(m_ERRGeneratedFar); + m_ERRGeneratedVehicle = Vehicle.Cast(ERR_Actors.Spawn("{259EE7B78C51B624}Prefabs/Vehicles/Wheeled/UAZ469/UAZ469.et", ERR_Actors.Ground(PrisonPosition() + "0 0 20"))); Own(m_ERRGeneratedVehicle); + BaseCompartmentManagerComponent seats = BaseCompartmentManagerComponent.Cast(m_ERRGeneratedVehicle.FindComponent(BaseCompartmentManagerComponent)); + array slots = {}; seats.GetCompartments(slots); + foreach (BaseCompartmentSlot slot : slots) + { + if (slot.GetType() != ECompartmentType.CARGO) continue; + AIGroup group; + m_ERRGeneratedSeated = slot.SpawnCharacterInCompartment(Factions().PrisonerPrefab, group, "{7A968AB297475FA7}Prefabs/ERR/ConvoyCrew.et"); + Own(group); Own(m_ERRGeneratedSeated); break; + } + if (!ERR_GeneratedCheck(m_ERRGeneratedNear && m_ERRGeneratedFar && m_ERRGeneratedVehicle && m_ERRGeneratedSeated, "friendly foot AI, distant AI and seated AI fixtures created")) return; + m_ERRGeneratedWait = GetWorld().GetWorldTime() + 2000; m_ERRGeneratedStage = 1; return; + } + if (m_ERRGeneratedStage == 1) + { + if (!ERR_GeneratedCheck(ERR_Actors.Alive(m_ERRGeneratedNear) && ERR_Actors.Alive(m_ERRGeneratedFar) && ERR_Actors.Alive(m_ERRGeneratedSeated) && CompartmentAccessComponent.GetVehicleIn(m_ERRGeneratedSeated) == m_ERRGeneratedVehicle, "targets alive and passenger physically seated")) return; + array denied = {}; Admin().Execute(this, false, "killais", denied); + if (!ERR_GeneratedCheck(denied.Count() == 1 && denied[0] == "Administrator access required." && ERR_Actors.Alive(m_ERRGeneratedNear), "unauthorized kill request refused")) return; + controller.ERR_AdminSubmit("killais"); + m_ERRGeneratedWait = GetWorld().GetWorldTime() + 2000; m_ERRGeneratedStage = 2; return; + } + if (m_ERRGeneratedStage == 2) + { + if (!ERR_GeneratedCheck(!ERR_Actors.Alive(m_ERRGeneratedNear) && !ERR_Actors.Alive(m_ERRGeneratedSeated) && ERR_Actors.Alive(m_ERRGeneratedFar) && ERR_Actors.Alive(m_ERRGeneratedVehicle) && ERR_Actors.Alive(m_ERRGeneratedPlayer), "authenticated killais kills nearby foot and seated AI, preserves player, vehicle and distant AI")) return; + if (!ERR_GeneratedCheck(UnlockPrison(m_ERRGeneratedPlayer), "generated prison unlock enters normal progression")) return; + vector approach = ERR_Actors.Ground(m_ERRGeneratedRadio.Position() + ERR_CampTerrain.Rotate("0 0 -1.5", Sites().Find("everon-radio-1").Definition.Heading)); + m_ERRGeneratedPlayer.SetOrigin(approach); + m_ERRGeneratedWait = GetWorld().GetWorldTime() + 1000; m_ERRGeneratedStage = 3; return; + } + if (m_ERRGeneratedStage == 3) + { + if (!ERR_GeneratedCheck(TuneRadio(m_ERRGeneratedPlayer, m_ERRGeneratedRadio) && Phase() == ERR_Phase.RADIO_DEFENCE, "generated radio starts existing transmission objective")) return; + CharacterControllerComponent.Cast(m_ERRGeneratedPlayer.FindComponent(CharacterControllerComponent)).SetHeadingAngle(Sites().Find("everon-radio-1").Definition.Heading * Math.DEG2RAD, true); + Print("ERR GENERATED CAPTURE READY"); + m_ERRGeneratedWait = GetWorld().GetWorldTime() + 3000; m_ERRGeneratedStage = 4; return; + } + if (m_ERRGeneratedStage == 4) + { + GetGame().GetCallqueue().Remove(ERR_GeneratedProbeTick); + Transition(ERR_Phase.DEFEAT); + Print("ERR SMOKE COMPLETE: generated sites, actual intel markers, admin killais and radio progression; scripted local fixture"); + } + } +} +#endif diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeOpeningToolsProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeOpeningToolsProbe.c new file mode 100644 index 0000000..162e03c --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeOpeningToolsProbe.c @@ -0,0 +1,109 @@ +#ifdef WORKBENCH +modded class ERR_GameMode +{ + protected int m_ERROpeningStage; + protected int m_ERROpeningDeadline; + protected ref array m_ERROpeningDocuments = {}; + protected SCR_AIGroup m_ERROpeningFallbackGroup; + protected ref ERR_PrisonPatrol m_ERROpeningFallback; + protected ref ERR_AdminItemCallback m_ERROpeningPickup; + protected IEntity m_ERROpeningPicked; + protected override void EOnInit(IEntity owner) + { + super.EOnInit(owner); + string outcome; + if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "opening-tools") return; + m_ERROpeningDeadline = System.GetTickCount() + 150000; + GetGame().GetCallqueue().CallLater(ERR_OpeningTick, 500, true); + } + protected bool ERR_OpeningCheck(bool value, string text) + { + if (value) { Print("ERR SMOKE PASS: opening " + text); return true; } + Print("ERR SMOKE FAIL: opening " + text, LogLevel.ERROR); + GetGame().GetCallqueue().Remove(ERR_OpeningTick); return false; + } + protected bool ERR_OpeningDocument(IEntity entity) + { + if (entity && entity.GetPrefabData() && entity.GetPrefabData().GetPrefabName() == ERR_Resources.IntelFolder()) m_ERROpeningDocuments.Insert(entity); + return true; + } + protected bool ERR_OpeningAircraftAudit(IEntity entity) + { + if (!entity || !entity.GetPrefabData()) return true; + string prefab = entity.GetPrefabData().GetPrefabName(); string name = prefab; name.ToLower(); + if (name.Contains("helicopter") || name.Contains("mi8") || name.Contains("uh1")) + PrintFormat("ERR OPENING AIRFRAME: prefab=%1 position=%2 extraction=%3", prefab, entity.GetOrigin(), entity == ExtractionVehicle()); + return true; + } + protected void ERR_OpeningTick() + { + if (System.GetTickCount() >= m_ERROpeningDeadline || Phase() == ERR_Phase.DEFEAT) { ERR_OpeningCheck(false, "fixture exceeded deadline or mission failed"); return; } + SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController()); + IEntity player; if (controller) player = controller.GetControlledEntity(); + if (Phase() != ERR_Phase.PRISON || !player || !Players().CurrentActor(player)) return; + if (m_ERROpeningStage == 0) + { + array roster = {"rifleman", "squadleader", "Character_RTO.et", "Character_RTO_second.et"}; + for (int size = 3; size <= 5; size++) + { + array selected = ERR_PrisonPatrol.Members(roster, size); + int operators; foreach (ResourceName prefab : selected) if (prefab.Contains("RTO")) operators++; + if (!ERR_OpeningCheck(selected.Count() == size && operators == 1, "roster selection retains exactly one RTO at size " + size)) return; + } + vector low, high; GetGame().GetWorldEntity().GetWorldBounds(low, high); + GetWorld().QueryEntitiesByAABB(low, high, ERR_OpeningAircraftAudit, null, EQueryEntitiesFlags.ALL); + ERR_SiteRuntime prison = Sites().Find("everon-prison-1"); + array guards = {}; prison.Group.GetAgents(guards); + if (!ERR_OpeningCheck(guards.Count() >= 3 && guards.Count() <= 5 && guards.Count() == prison.InitialGuardCount, "3-5 physical prison guards")) return; + int radios; + foreach (AIAgent guard : guards) + { + if (!ERR_OpeningCheck(!guard.IsAIActivated() && ERR_Actors.CanFight(guard.GetControlledEntity()), "guard held alive until lockpick")) return; + if (ERR_HQRadio.Capability(guard.GetControlledEntity()) == 2) radios++; + } + if (!ERR_OpeningCheck(radios >= 1, "large manpack radio physically equipped")) return; + if (!ERR_OpeningCheck(ERR_CasualtyLoadingRules.AircraftReady(ExtractionVehicle()), "initial extraction helicopter intact and grounded")) return; + // Test the fallback on a faction roster containing no RTO. + m_ERROpeningFallbackGroup = SCR_AIGroup.Cast(ERR_Actors.Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", ERR_Actors.Ground(prison.Group.GetOrigin() + "10 0 10"))); + Own(m_ERROpeningFallbackGroup); + m_ERROpeningFallbackGroup.SetFaction(EnemyFaction()); + m_ERROpeningFallbackGroup.m_aUnitPrefabSlots = {"{5436629450D8387A}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_SL.et"}; + m_ERROpeningFallbackGroup.RequestSpawn(1); + m_ERROpeningFallback = new ERR_PrisonPatrol(); + array denied = {}; Admin().Execute(this, false, "cheat intel", denied); + if (!ERR_OpeningCheck(denied.Count() == 1 && denied[0] == "Administrator access required.", "unauthorized intel command denied")) return; + controller.ERR_AdminSubmit("cheat intel"); + m_ERROpeningStage = 1; return; + } + if (m_ERROpeningStage == 1) + { + m_ERROpeningDocuments.Clear(); + GetWorld().QueryEntitiesBySphere(player.GetOrigin(), 6, ERR_OpeningDocument, null, EQueryEntitiesFlags.ALL); + if (m_ERROpeningDocuments.IsEmpty()) return; + if (!ERR_OpeningCheck(m_ERROpeningDocuments.Count() == 6, "authenticated command spawned exactly six physical intel documents")) return; + foreach (IEntity document : m_ERROpeningDocuments) + { + InventoryItemComponent item = InventoryItemComponent.Cast(document.FindComponent(InventoryItemComponent)); + if (!ERR_OpeningCheck(item && !item.GetParentSlot() && Math.AbsFloat(document.GetOrigin()[1] - ERR_Actors.Ground(document.GetOrigin())[1]) < 0.4 && !Discovery().IsSpent(document), "fresh document lies on nearby ground outside inventory")) return; + } + m_ERROpeningPicked = m_ERROpeningDocuments[0]; + SCR_InventoryStorageManagerComponent inventory = SCR_InventoryStorageManagerComponent.Cast(player.FindComponent(SCR_InventoryStorageManagerComponent)); + m_ERROpeningPickup = new ERR_AdminItemCallback(); + if (!ERR_OpeningCheck(inventory.TryInsertItem(m_ERROpeningPicked, EStoragePurpose.PURPOSE_DEPOSIT, m_ERROpeningPickup), "ground document pickup accepted")) return; + m_ERROpeningStage = 2; return; + } + if (m_ERROpeningStage == 2) + { + ERR_PrepareResult radio = m_ERROpeningFallback.Radio(this, m_ERROpeningFallbackGroup, GetWorld().GetWorldTime() + 10000); + if (radio == ERR_PrepareResult.FAILED) { ERR_OpeningCheck(false, "fallback radio insertion failed"); return; } + if (radio != ERR_PrepareResult.READY || !m_ERROpeningPickup.Finished || !Discovery().IsSpent(m_ERROpeningPicked)) return; + if (!ERR_OpeningCheck(m_ERROpeningPickup.Succeeded, "picked-up cheat document consumed by normal discovery")) return; + if (!ERR_OpeningCheck(ERR_HQRadio.Capability(m_ERROpeningFallbackGroup.GetLeaderEntity()) == 2, "non-RTO loadout receives actual large radio")) return; + if (!ERR_OpeningCheck(UnlockPrison(player), "normal lockpick progression remains available")) return; + GetGame().GetCallqueue().Remove(ERR_OpeningTick); + Transition(ERR_Phase.DEFEAT); + Print("ERR SMOKE COMPLETE: prison patrol, large radio, ground intel admin command and discovery"); + } + } +} +#endif diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativePatrolProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativePatrolProbe.c index ec795e5..d0b5029 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativePatrolProbe.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativePatrolProbe.c @@ -38,7 +38,7 @@ class ERR_NativePatrolProbe } protected bool DefinitionChecks() { - ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{BC310DC0CD27F523}Configs/ERR/EveronSites.conf")); + ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf")); if (!Check(definition && definition.Valid(), "authored definition resolves")) return false; ERR_SiteDefinition site = definition.Find("everon-outpost-1"); if (!Check(site && site.Patrol && site.Patrol.Count() == 4, "four ordered outpost points authored")) return false; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeRadioCampProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeRadioCampProbe.c new file mode 100644 index 0000000..95b497d --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeRadioCampProbe.c @@ -0,0 +1,17 @@ +#ifdef WORKBENCH +modded class ERR_HQ +{ + bool ERR_ProbeRadioReservation(SCR_AIGroup group) + { + foreach (ERR_HQUnit unit : m_Units) + { + if (unit.Group != group) continue; + if (!unit.ReservedForSupport || !unit.HomeGuard || unit.Eligible(m_Mode)) return false; + if (ReserveLogistics(group, 999)) return false; + ReleaseMortarCrew(group); // Only actual destroyed mortar crews may be released. + return unit.ReservedForSupport && !unit.Eligible(m_Mode) && !unit.Waypoint && !unit.PendingOrder && !unit.Assignment; + } + return false; + } +} +#endif diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeTransportProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeTransportProbe.c index 1c413f0..5974a38 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeTransportProbe.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeTransportProbe.c @@ -27,7 +27,7 @@ class ERR_NativeTransportProbe } protected bool DefinitionChecks() { - ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{BC310DC0CD27F523}Configs/ERR/EveronSites.conf")); + ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf")); ERR_SiteDefinition site; if (definition) site = definition.Find("everon-supply-2"); if (!Check(site && site.Transport && definition.Valid(), "authored transport definition resolves")) return false; @@ -189,7 +189,7 @@ class ERR_NativeTransportProbe if (m_Step == 5) { if (!Check(!m_Site.Transport && m_Mode.Sites().TransportCount() == 0 && m_Mode.Sites().Prepare() == ERR_PrepareResult.READY, "lost transport remains absent after service ticks and preparation queries")) return -1; - ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{BC310DC0CD27F523}Configs/ERR/EveronSites.conf")); + ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf")); if (!Check(!m_Mode.Sites().Begin(definition, ERR_Supplies.Map()) && m_Mode.Sites().TransportCount() == 0, "duplicate initialization cannot replace lost transport")) return -1; m_Mode.Sites().Stop(); if (!Check(m_Mode.Sites().Prepare() == ERR_PrepareResult.FAILED && !m_Mode.Sites().Begin(definition, ERR_Supplies.Map()), "stopped finite owner cannot reinitialize transport")) return -1; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeTransportSurvey.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeTransportSurvey.c index 475a2b1..844a95e 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeTransportSurvey.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeTransportSurvey.c @@ -15,7 +15,7 @@ class ERR_NativeTransportSurvey { static void Run(ERR_GameMode mode) { - ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{BC310DC0CD27F523}Configs/ERR/EveronSites.conf")); + ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf")); ERR_SiteDefinition site = definition.Find("everon-supply-2"); int clear; for (int x = -40; x <= 40; x += 8) diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_ObjectiveText.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_ObjectiveText.c index 8dad2bc..a02bbc8 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_ObjectiveText.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_ObjectiveText.c @@ -54,8 +54,8 @@ class ERR_ObjectiveText } // Scan all sites before acknowledging rough intel: an identified radio wins. if (roughRadio) - return "Intel recovered: a 500 m search square is now marked on the map. Use a carried field map to view it. Take a different intelligence folder into your inventory to pinpoint the radio; a folder already recovered cannot provide another clue. Try the other marked supply cache if needed. A close approach with a clear view can also identify the station."; - return "Choose either marked supply cache. Take its intelligence folders into your own inventory to reveal the station on your map. If only a search area appears, take another folder. Each cache has enough folders; use the other if one is lost. If every folder is lost, a close approach with a clear view can identify the station itself. Nearby medical and guard sites can also be discovered by approaching with a clear view."; + return "Intel recovered: a question mark shows an approximate location, but its type is still unknown. Take a different intelligence folder into your inventory to pinpoint the radio; a folder already recovered cannot provide another clue. Try the other marked supply cache if needed. A close approach with a clear view can also identify the station."; + return "Choose either marked supply cache. Take its intelligence folders into your own inventory to reveal the station on your map. If the location is not yet identified, take another folder. Each cache has enough folders; use the other if one is lost. If every folder is lost, a close approach with a clear view can identify the station itself. Nearby medical and guard sites can also be discovered by approaching with a clear view."; } if (phase == ERR_Phase.RADIO_DEFENCE) { diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_PrisonAI.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_PrisonAI.c index 05d0322..80d3309 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_PrisonAI.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_PrisonAI.c @@ -11,7 +11,7 @@ modded class SCR_ChimeraAIAgent ERR_GameMode mode = ERR_GameMode.Get(); IEntity actor = GetControlledEntity(); if (!mode || mode.Phase() > ERR_Phase.PRISON || !actor || ERR_Actors.Player(actor) || IsPlayerPending_S()) return; - if (!mode.EnemyFaction() || SCR_FactionManager.SGetFaction(actor) != mode.EnemyFaction()) return; + if (!mode.IsMissionEnemy(SCR_FactionManager.SGetFaction(actor))) return; DeactivateAI(); if (m_ERRPrisonMode) return; m_ERRPrisonMode = mode; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_PrisonEscapeService.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_PrisonEscapeService.c index d108e35..dcf65a6 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_PrisonEscapeService.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_PrisonEscapeService.c @@ -161,7 +161,7 @@ class ERR_PrisonEscapeService vector position = actor.GetOrigin(); float ground = GetGame().GetWorld().GetSurfaceY(position[0], position[2]); if (!ERR_PrisonEscapeRules.Grounded(position[1] - ground)) continue; - vector offset = position - m_Center; + vector offset = ERR_CampTerrain.Local(position - m_Center, m_Mode.PrisonHeading()); ERR_PrisonEscapeSample sample = m_Samples.Get(life.AccountId); bool armed; if (sample && sample.Actor == actor && sample.Controller == controller) @@ -169,7 +169,7 @@ class ERR_PrisonEscapeService float elapsed = now - sample.ObservedAt; float displacement = vector.Distance(position, sample.Position); bool continuous = ERR_PrisonEscapeRules.Continuous(elapsed, displacement); - vector previousOffset = sample.Position - m_Center; + vector previousOffset = ERR_CampTerrain.Local(sample.Position - m_Center, m_Mode.PrisonHeading()); if (ERR_PrisonEscapeRules.Crossing(sample.Armed, previousOffset[0], previousOffset[2], offset[0], offset[2], elapsed, displacement)) { // An actual qualifying exit is sufficient even if the gate remains intact. diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_PrisonPatrol.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_PrisonPatrol.c new file mode 100644 index 0000000..ddda0c2 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_PrisonPatrol.c @@ -0,0 +1,106 @@ +// One finite opening patrol. Build the selected faction's roster before asking +// the native spawn queue for any members; never spawn a full squad and trim it. +class ERR_PrisonPatrol +{ + protected IEntity m_Radio; + protected bool m_RequestedRadio; + + protected bool m_Placed; + bool Place(SCR_AIGroup group, vector center) + { + if (m_Placed) return true; + if (!Replication.IsServer() || !group) return false; + array agents = {}; group.GetAgents(agents); + if (agents.Count() < 3 || agents.Count() > 5) return false; + map positions = new map(); + ERR_SpawnPlacement placement = new ERR_SpawnPlacement(); + float start = Math.RandomFloat(0, 360); + float sector = 360.0 / agents.Count(); + for (int index = 0; index < agents.Count(); index++) + { + if (!agents[index] || !ChimeraCharacter.Cast(agents[index].GetControlledEntity())) return false; + bool found; + for (int attempt = 0; attempt < 80; attempt++) + { + float angle = (start + sector * index + Math.RandomFloat(-sector * 0.3, sector * 0.3)) * Math.DEG2RAD; + float radius = Math.RandomFloat(12, 24); + vector position = ERR_Actors.Ground(center + Vector(Math.Cos(angle) * radius, 0, Math.Sin(angle) * radius)); + if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), position)) continue; + if (!placement.Clear(position, positions, index, agents[index].GetControlledEntity())) continue; + positions.Insert(index, position); found = true; break; + } + if (!found) return false; + } + for (int member = 0; member < agents.Count(); member++) + { + ChimeraCharacter actor = ChimeraCharacter.Cast(agents[member].GetControlledEntity()); + vector transform[4]; + Math3D.AnglesToMatrix(Vector(Math.RandomFloat(0, 360), 0, 0), transform); + transform[3] = positions.Get(member); + actor.Teleport(transform); + } + m_Placed = true; + return true; + } + + static array Members(array source, int count) + { + array result = {}; + if (!source || source.IsEmpty() || count < 3 || count > 5) return result; + array soldiers = {}; + ResourceName radioman; + foreach (ResourceName prefab : source) + { + string name = prefab; name.ToLower(); + if (name.Contains("_rto") || name.Contains("radioman")) + { if (radioman.IsEmpty()) radioman = prefab; } + else if (!prefab.IsEmpty()) soldiers.Insert(prefab); + } + if (!radioman.IsEmpty()) result.Insert(radioman); + if (soldiers.IsEmpty()) soldiers.Insert(source[0]); + int index; + while (result.Count() < count) { result.Insert(soldiers[index % soldiers.Count()]); index++; } + return result; + } + + static bool Configure(SCR_AIGroup group, ResourceName profile, Faction faction) + { + if (!Replication.IsServer() || !group || !faction || group.GetAgentsCount() != 0) return false; + Resource resource = Resource.Load(profile); + if (!resource || !resource.IsValid()) return false; + IEntitySource source = resource.GetResource().ToEntitySource(); + array roster = {}; + if (!source || !source.Get("m_aUnitPrefabSlots", roster)) return false; + group.m_aUnitPrefabSlots = Members(roster, Math.RandomIntInclusive(3, 5)); + if (group.m_aUnitPrefabSlots.IsEmpty()) return false; + group.SetFaction(faction); + group.SetSpawnImmediately(true); + group.RequestSpawn(group.m_aUnitPrefabSlots.Count()); + return true; + } + + // Existing faction RTO loadouts are preferred. If a mod's fireteam has none, + // equip one soldier with a real large radio, replacing its backpack if needed. + ERR_PrepareResult Radio(ERR_GameMode mode, SCR_AIGroup group, float deadline) + { + array agents = {}; group.GetAgents(agents); + foreach (AIAgent member : agents) + if (member && ERR_HQRadio.Capability(member.GetControlledEntity()) == 2) return ERR_PrepareResult.READY; + if (GetGame().GetWorld().GetWorldTime() >= deadline) return ERR_PrepareResult.FAILED; + if (m_RequestedRadio) return ERR_PrepareResult.PENDING; + if (agents.IsEmpty() || !agents[0]) return ERR_PrepareResult.PENDING; + IEntity actor = agents[0].GetControlledEntity(); + if (!actor) return ERR_PrepareResult.PENDING; + SCR_InventoryStorageManagerComponent inventory = SCR_InventoryStorageManagerComponent.Cast(actor.FindComponent(SCR_InventoryStorageManagerComponent)); + if (!inventory) return ERR_PrepareResult.PENDING; + m_RequestedRadio = true; + ResourceName radio = "{54C68E438DD34265}Prefabs/Items/Equipment/Radios/Radio_R107M.et"; + if (group.GetFaction().GetFactionKey() == "US" || group.GetFaction().GetFactionKey() == "RHS_USAF") + radio = "{9B6B61BB3FE3DFB0}Prefabs/Items/Equipment/Radios/Radio_ANPRC77.et"; + m_Radio = ERR_Actors.Spawn(radio, actor.GetOrigin()); + if (!m_Radio) return ERR_PrepareResult.FAILED; + mode.Own(m_Radio); + inventory.EquipCloth(m_Radio); + return ERR_PrepareResult.PENDING; + } +} diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_RadioDefenceService.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_RadioDefenceService.c index 19fa6f0..a63fba0 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_RadioDefenceService.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_RadioDefenceService.c @@ -56,7 +56,7 @@ class ERR_RadioDefenceService int DurationSeconds() { if (!m_Mode || !m_Mode.Settings()) return 0; - return m_Mode.Settings().Get("radioDefenceSeconds"); + return m_Mode.Settings().Runtime("radioDefenceSeconds"); } int SecondsRemaining(float now) { diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_ScenarioComponent.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_ScenarioComponent.c index 5df7ee6..648cbd3 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_ScenarioComponent.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_ScenarioComponent.c @@ -6,12 +6,17 @@ class ERR_ScenarioComponent : ScriptComponent protected ResourceName m_Definition; protected ref ERR_SiteService m_Sites; protected bool m_Started; + protected ref ERR_ScenarioDefinition m_RuntimeDefinition; + protected ref ERR_GeneratedSites m_Generated; protected bool m_Finished; protected bool m_Failed; ERR_SiteService Sites() { return m_Sites; } string FailureReason() { + ERR_HQ hq = ERR_HQ.Get(); + if (hq && hq.Scouts() && !hq.Scouts().Error().IsEmpty()) return hq.Scouts().Error(); + if (m_Generated && !m_Generated.Error().IsEmpty()) return m_Generated.Error(); if (m_Sites && !m_Sites.FailureReason().IsEmpty()) return m_Sites.FailureReason(); return "Scenario or additional force preparation failed."; } @@ -23,12 +28,35 @@ class ERR_ScenarioComponent : ScriptComponent if (!m_Started) { m_Started = true; - ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab(m_Definition)); + #ifdef WORKBENCH + string historicalFixture; + if (System.GetCLIParam("errSmokeOutcome", historicalFixture) && historicalFixture != "generated-sites" && historicalFixture != "opening-tools" && historicalFixture != "airfield-markers" && historicalFixture != "air-recon" && historicalFixture != "air-logistics" && historicalFixture != "air-assault") + m_Definition = "{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf"; + #endif + m_RuntimeDefinition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab(m_Definition)); + if (!m_RuntimeDefinition || !m_RuntimeDefinition.Valid()) { m_Failed = true; return ERR_PrepareResult.FAILED; } + #ifdef WORKBENCH + // Existing deterministic probes intentionally use authored coordinates. + string fixture; + if (System.GetCLIParam("errSmokeOutcome", fixture) && fixture != "generated-sites" && fixture != "airfield-markers" && fixture != "air-recon" && fixture != "air-logistics" && fixture != "air-assault") + foreach (ERR_SiteDefinition fixedSite : m_RuntimeDefinition.Sites) fixedSite.RandomClear = false; + #endif + m_Generated = new ERR_GeneratedSites(); + if (!m_Generated.Begin(m_RuntimeDefinition)) { m_Failed = true; return ERR_PrepareResult.FAILED; } + } + ERR_PrepareResult placement = m_Generated.Tick(); + if (placement == ERR_PrepareResult.FAILED) m_Failed = true; + if (placement != ERR_PrepareResult.READY) return placement; + if (!m_Sites) + { m_Sites = new ERR_SiteService(mode); - if (!m_Sites.Begin(definition, FieldMap())) { m_Failed = true; return ERR_PrepareResult.FAILED; } + if (!m_Sites.Begin(m_RuntimeDefinition, FieldMap())) { m_Failed = true; return ERR_PrepareResult.FAILED; } } ERR_PrepareResult result = m_Sites.Prepare(); if (result != ERR_PrepareResult.READY) return result; + ERR_PrepareResult population = PrepareAdditionalPopulation(); + if (population == ERR_PrepareResult.FAILED) { m_Failed = true; m_Sites.Rollback(); } + if (population != ERR_PrepareResult.READY) return population; if (!PrepareAdditionalForces()) { m_Failed = true; @@ -47,6 +75,7 @@ class ERR_ScenarioComponent : ScriptComponent ERR_GameMode mode = ERR_GameMode.Get(); if (mode && mode.Active() && m_Sites) m_Sites.Activate(); } + protected ERR_PrepareResult PrepareAdditionalPopulation() { return ERR_PrepareResult.READY; } protected bool PrepareAdditionalForces() { return true; } protected ResourceName FieldMap() { return ERR_Supplies.Map(); } protected override void OnDelete(IEntity owner) diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c index 2ee2d56..1fc5b99 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c @@ -3,16 +3,19 @@ class ERR_Settings { static const string PATH = "$profile:rereintroduced.json"; protected ref map m_Values = new map(); + protected ref map m_Resolved = new map(); protected bool m_ReadOnly; protected string m_Path; static array Keys() { - return {"reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "stingerChanceOneIn", "automaticConvoyEnabled"}; + return {"reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "stingerChanceOneIn", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"}; } static bool Valid(string key, int value) { + if (key == "heliTimerMinutes") return value >= 1 && value <= 1440; + if (key == "convoyCount") return value >= 0 && value <= 20; if (key == "automaticConvoyEnabled") return value == 0 || value == 1; if (key == "stingerChanceOneIn") return value >= 0 && value <= 10000; if (key == "reportIntervalSeconds") return value >= 5 && value <= 60; @@ -44,8 +47,57 @@ class ERR_Settings return false; } + // Saved modes are distinct from validated concrete gameplay values. + static bool Randomizable(string key) + { + return key == "heliTimerMinutes" || key == "convoyCount" || key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres" || key == "aiIntelChancePercent" || key == "intelRevealChancePercent" || key == "radioDefenceSeconds" || key == "startTimeMinutes" || key == "timeMultiplier" || key == "startDateCode" || key == "weatherProfile"; + } + static bool ValidSelection(string key, int value) + { + if (value == -1) return Randomizable(key); + if (value == -2) return key == "startTimeMinutes"; + return Valid(key, value); + } + static string SelectionText(int value) + { + if (value == -1) return "Random"; + if (value == -2) return "Random day"; + return value.ToString(); + } + static bool RandomBounds(string key, out int minimum, out int maximum) + { + minimum = 0; + if (key == "heliTimerMinutes") { minimum = 1; maximum = 1440; } + else if (key == "convoyCount") maximum = 20; + else if (key == "reportIntervalSeconds") { minimum = 5; maximum = 60; } + else if (key == "supportSquadLimit") maximum = 8; + else if (key == "responseRadiusMetres") { minimum = 500; maximum = 5000; } + else if (key == "aiIntelChancePercent" || key == "intelRevealChancePercent") maximum = 100; + else if (key == "radioDefenceSeconds") { minimum = 30; maximum = 1800; } + else if (key == "startTimeMinutes") maximum = 1439; + else if (key == "timeMultiplier") { minimum = 1; maximum = 24; } + else if (key == "weatherProfile") { minimum = 1; maximum = 3; } + else return false; + return true; + } + // Server-only, once per setting per round. Polls and gameplay reads never reroll. + // Date and daylight depend on the world and are resolved by their services. + int Runtime(string key) + { + int value = Get(key); + if (value != -1 || !Replication.IsServer()) return value; + if (m_Resolved.Contains(key)) return m_Resolved.Get(key); + int minimum, maximum; + if (!RandomBounds(key, minimum, maximum)) return value; + value = Math.RandomInt(minimum, maximum + 1); + m_Resolved.Set(key, value); + return value; + } + static int AddedDefault(string key) { + if (key == "heliTimerMinutes") return 15; + if (key == "convoyCount") return 3; if (key == "automaticConvoyEnabled") return 1; if (key == "stingerChanceOneIn") return 15; if (key == "startTimeMinutes") return 840; @@ -69,6 +121,8 @@ class ERR_Settings m_Values.Set("weatherProfile", AddedDefault("weatherProfile")); m_Values.Set("stingerChanceOneIn", AddedDefault("stingerChanceOneIn")); m_Values.Set("automaticConvoyEnabled", AddedDefault("automaticConvoyEnabled")); + m_Values.Set("convoyCount", AddedDefault("convoyCount")); + m_Values.Set("heliTimerMinutes", AddedDefault("heliTimerMinutes")); if (!Replication.IsServer()) { m_ReadOnly = true; return; } map loaded; if (Read(m_Path, loaded) || Read(m_Path + ".bak", loaded)) m_Values = loaded; @@ -97,7 +151,7 @@ class ERR_Settings float number; if (!json.ReadValue(key, number)) return false; int value = Math.Round(number); - if (number != value || !Valid(key, value)) return false; + if (number != value || !ValidSelection(key, value)) return false; values.Set(key, value); } return true; @@ -105,7 +159,7 @@ class ERR_Settings bool Set(string key, int value) { - if (!Replication.IsServer() || m_ReadOnly || !Valid(key, value)) return false; + if (!Replication.IsServer() || m_ReadOnly || !ValidSelection(key, value)) return false; JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false); foreach (string option : Keys()) @@ -139,6 +193,7 @@ class ERR_Settings return false; } } + if (Get(key) != value) m_Resolved.Remove(key); m_Values = checked; return true; } diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_SiteDefinition.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_SiteDefinition.c index 1298f99..5458c93 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_SiteDefinition.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_SiteDefinition.c @@ -57,8 +57,21 @@ class ERR_SiteDefinition ResourceName Prefab; [Attribute("0 0 0", UIWidgets.Coords)] vector Position; + [Attribute("0", UIWidgets.CheckBox, "Choose dry clear terrain once per round (prison, radio or mortar)")] + bool RandomClear; + [Attribute("0", UIWidgets.EditBox, "Composition heading in degrees")] + float Heading; + [Attribute("0", UIWidgets.EditBox, "Height of composition root above its surveyed ground")] + float RootHeight; + bool PlacementResolved; + [Attribute("12 0 0", UIWidgets.Coords, "Extraction aircraft offset from its site")] + vector AircraftOffset; + [Attribute("0", UIWidgets.CheckBox, "Require a clear pad on airfield grounds before aircraft creation")] + bool RequireAirfield; [Attribute("", UIWidgets.ResourceNamePicker, "Optional physical guard group", "et")] ResourceName Guards; + [Attribute("0", UIWidgets.CheckBox, "Choose a guard group from enabled content factions; retain catalog fallback when none enabled")] + bool AllowFactionPool; [Attribute()] ref array Patrol; [Attribute("0", UIWidgets.CheckBox, "Populate a finite squad from the faction catalog") ] bool Guarded; @@ -81,6 +94,8 @@ class ERR_ScenarioDefinition ref array Sites; [Attribute("2", UIWidgets.EditBox, "Independent accessible caches containing enough radio documents") ] int MinimumRadioSources; + [Attribute("0", UIWidgets.EditBox, "Finite hill scouts per round (0-32); each has a radio, binoculars and a sniper rifle")] + int ScoutCount; ERR_SiteDefinition Find(string id) { @@ -91,6 +106,7 @@ class ERR_ScenarioDefinition bool Valid() { + if (ScoutCount < 0 || ScoutCount > 32) return false; if (!Sites || Sites.IsEmpty() || Sites.Count() > 32) return false; set ids = new set(); int prisons, radios, extractions, groups, items, sources, maps, props, transports; @@ -99,6 +115,9 @@ class ERR_ScenarioDefinition foreach (ERR_SiteDefinition site : Sites) { if (!site || site.Id.IsEmpty() || ids.Contains(site.Id) || site.Prefab.IsEmpty()) return false; + if (site.RequireAirfield && site.Kind != ERR_LocationKind.EXTRACTION) return false; + if (site.Kind == ERR_LocationKind.RADIO && !site.Guarded && site.Guards.IsEmpty()) return false; + if (site.RandomClear && site.Kind != ERR_LocationKind.PRISON && site.Kind != ERR_LocationKind.RADIO && site.Kind != ERR_LocationKind.MORTAR) return false; if (site.Label.IsEmpty() || site.Label.Length() > 64) return false; if (!ERR_SiteDiscoveryRules.ValidRadius(site.Kind, site.DiscoverRadius)) return false; bool optionalSite = site.Kind == ERR_LocationKind.DEPOT || site.Kind == ERR_LocationKind.OUTPOST; @@ -118,7 +137,7 @@ class ERR_ScenarioDefinition foreach (ERR_SiteProp prop : site.Props) if (!prop || prop.Prefab.IsEmpty() || prop.Offset.Length() > 40) return false; } - if (site.Kind < ERR_LocationKind.PRISON || site.Kind > ERR_LocationKind.DEPOT) return false; + if (site.Kind < ERR_LocationKind.PRISON || site.Kind > ERR_LocationKind.MORTAR) return false; ids.Insert(site.Id); if (site.Kind == ERR_LocationKind.PRISON) prisons++; if (site.Kind == ERR_LocationKind.RADIO) radios++; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_SiteDiscoveryRules.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_SiteDiscoveryRules.c index 8926179..61529c1 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_SiteDiscoveryRules.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_SiteDiscoveryRules.c @@ -7,7 +7,7 @@ class ERR_SiteDiscoveryRules { if (radius < 0 || radius > 60) return false; if (kind == ERR_LocationKind.RADIO) return radius <= RadioRadiusLimit(); - if (kind == ERR_LocationKind.DEPOT || kind == ERR_LocationKind.OUTPOST) return true; + if (kind == ERR_LocationKind.DEPOT || kind == ERR_LocationKind.OUTPOST || kind == ERR_LocationKind.MORTAR) return true; return (kind == ERR_LocationKind.PRISON || kind == ERR_LocationKind.EXTRACTION) && radius == 0; } static bool InRange(float radius, float horizontalDistance, float verticalDistance) diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_SiteService.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_SiteService.c index 46f3a0b..b2c36b8 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_SiteService.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_SiteService.c @@ -13,18 +13,22 @@ class ERR_SiteRuntime ERR_LocationComponent Location; IEntity Container; SCR_AIGroup Group; + string ExpectedGuardFaction; int InitialGuardCount; + ref ERR_PrisonPatrol PrisonPatrol; int LastInitialPhysical = -1; int LastInitialConscious = -1; Vehicle Transport; AIWaypointCycle PatrolCycle; ref array PatrolWaypoints = {}; + ref array Props = {}; } // One owner for finite physical site contents. No population or stock refill tick. class ERR_SiteService { protected ERR_GameMode m_Mode; + protected int m_ContentGuardIndex; protected ref ERR_ExtractionPlacement m_ExtractionPlacement; ERR_ExtractionPlacement ExtractionPlacement() { return m_ExtractionPlacement; } protected ref array m_Sites = {}; @@ -43,6 +47,11 @@ class ERR_SiteService void ERR_SiteService(ERR_GameMode mode) { m_Mode = mode; } int Count() { return m_Sites.Count(); } + ERR_SiteRuntime At(int index) + { + if (index < 0 || index >= m_Sites.Count()) return null; + return m_Sites[index]; + } bool Owns(IEntity entity) { return entity && m_Entities.Contains(entity); } // Snapshot only nearby mission-owned objects. Distant sites must not block // arrival, and vanilla map streaming remains the native preload's job. @@ -100,12 +109,23 @@ class ERR_SiteService PrintFormat("Reintroduced [sites]: cache clearance position=%1 clear=%2", ground, !m_Obstructed); return !m_Obstructed; } + protected bool BackpackClearance(vector position) + { + vector floor = ERR_Actors.Ground(position); + TraceBox trace = new TraceBox(); + trace.Start = floor + "0 0.15 0"; trace.End = floor + "0 1.15 0"; + trace.Mins = "-0.5 0 -0.5"; trace.Maxs = "0.5 0.1 0.5"; + trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD; + return GetGame().GetWorld().TraceMove(trace, null) >= 1; + } protected bool CreateGuardOrders(ERR_SiteRuntime site, vector anchor) { + if (site.Definition.Kind == ERR_LocationKind.RADIO) anchor = ERR_Actors.Ground(site.Location.Position()); if (!site.Definition.Patrol || site.Definition.Patrol.IsEmpty()) { AIWaypoint defend = AIWaypoint.Cast(Spawn(ERR_Resources.DefendWaypoint(), anchor)); if (!defend) return false; + if (site.Definition.Kind == ERR_LocationKind.RADIO) defend.SetCompletionRadius(20); site.Group.AddWaypoint(defend); return true; } @@ -247,25 +267,35 @@ class ERR_SiteService { vector position = ERR_Actors.Ground(spec.Position); if (position[1] < 1) return Fail("site below sea level: " + spec.Id); - IEntity root = Spawn(spec.Prefab, position); + vector rootPosition = position; + if (spec.PlacementResolved && spec.Kind == ERR_LocationKind.PRISON) rootPosition = spec.Position; + rootPosition[1] = rootPosition[1] + spec.RootHeight; + IEntity root = ERR_Actors.Spawn(spec.Prefab, rootPosition, Vector(spec.Heading, 0, 0)); + if (root) m_Entities.Insert(root); if (!root) return Fail("site composition: " + spec.Id); ERR_SiteRuntime site = new ERR_SiteRuntime(); site.Definition = spec; site.Location = ERR_LocationComponent.Cast(root.FindComponent(ERR_LocationComponent)); if (!site.Location || !site.Location.Configure(spec.Id, spec.Kind, spec.Label)) return Fail("location identity: " + spec.Id); m_Sites.Insert(site); - if (spec.Kind == ERR_LocationKind.PRISON) m_Mode.ConfigureStart(position); + if (spec.Kind == ERR_LocationKind.PRISON) m_Mode.ConfigureStart(position, spec.Heading); if (spec.Kind == ERR_LocationKind.EXTRACTION) { - Vehicle helicopter = Vehicle.Cast(Spawn(m_Mode.Factions().ExtractionPrefab, position + "12 0 0")); + vector aircraftPosition = ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(spec.AircraftOffset, spec.Heading)); + if (spec.RequireAirfield && !ERR_AirfieldPlacement.Clear(aircraftPosition)) return Fail("extraction airfield pad is missing or obstructed; no off-airfield fallback"); + Vehicle helicopter = Vehicle.Cast(Spawn(m_Mode.Factions().ExtractionPrefab, aircraftPosition)); if (!helicopter) return Fail("extraction vehicle"); if (!m_Mode.SetExtractionVehicle(helicopter)) return Fail("extraction vehicle registration"); m_ExtractionPlacement = new ERR_ExtractionPlacement(m_Mode, helicopter); } if (!spec.Container.IsEmpty()) { - if (!CacheClearance(position + spec.ContainerOffset)) return Fail("obstructed cache position: " + spec.Id); - site.Container = Spawn(spec.Container, position + spec.ContainerOffset); + vector containerPosition = position + ERR_CampTerrain.Rotate(spec.ContainerOffset, spec.Heading); + bool clearContainer; + if (spec.Kind == ERR_LocationKind.PRISON) clearContainer = BackpackClearance(containerPosition); + else clearContainer = CacheClearance(containerPosition); + if (!clearContainer) return Fail("obstructed cache position: " + spec.Id); + site.Container = Spawn(spec.Container, position + ERR_CampTerrain.Rotate(spec.ContainerOffset, spec.Heading)); if (!site.Container) return Fail("container: " + spec.Id); InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(site.Container.FindComponent(InventoryStorageManagerComponent)); if (!inventory) return Fail("container inventory: " + spec.Id); @@ -301,21 +331,39 @@ class ERR_SiteService { foreach (ERR_SiteProp prop : spec.Props) { - vector propPosition = ERR_Actors.Ground(position + prop.Offset); - if (!CacheClearance(propPosition)) return Fail("obstructed scenery position: " + spec.Id); + vector propPosition = ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(prop.Offset, spec.Heading)); + // Radio furniture intentionally surrounds/supports its own elevated radio. + // Generated camp terrain was checked before any of these objects existed. + if (spec.Kind != ERR_LocationKind.RADIO && !CacheClearance(propPosition)) return Fail("obstructed scenery position: " + spec.Id); propPosition[1] = propPosition[1] + prop.Offset[1]; - IEntity scenery = ERR_Actors.Spawn(prop.Prefab, propPosition, prop.Angles); + IEntity scenery = ERR_Actors.Spawn(prop.Prefab, propPosition, prop.Angles + Vector(spec.Heading, 0, 0)); if (!scenery) return Fail("scenery prefab: " + spec.Id); - m_Entities.Insert(scenery); + m_Entities.Insert(scenery); site.Props.Insert(scenery); } } if (!CreateTransport(site, position)) return false; if (spec.Guarded || !spec.Guards.IsEmpty()) { - vector guardPosition = position + spec.GuardOffset; + vector guardPosition = position + ERR_CampTerrain.Rotate(spec.GuardOffset, spec.Heading); ResourceName groupPrefab = spec.Guards; + site.ExpectedGuardFaction = m_Mode.Factions().EnemyFactionKey; + if (spec.AllowFactionPool && m_Mode.Content()) + { + ResourceName selectedGroup = m_Mode.Content().GuardGroup(m_ContentGuardIndex++); + if (!selectedGroup.IsEmpty()) + { + groupPrefab = selectedGroup; + site.ExpectedGuardFaction = m_Mode.Content().GuardFaction(selectedGroup); + } + } if (groupPrefab.IsEmpty()) groupPrefab = m_Mode.Factions().GuardGroupPrefab; - site.Group = SCR_AIGroup.Cast(Spawn(groupPrefab, guardPosition)); + if (spec.Kind == ERR_LocationKind.PRISON) + { + site.Group = SCR_AIGroup.Cast(Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", guardPosition)); + if (!ERR_PrisonPatrol.Configure(site.Group, groupPrefab, m_Mode.EnemyFaction())) return Fail("prison patrol roster: " + spec.Id); + site.PrisonPatrol = new ERR_PrisonPatrol(); + } + else site.Group = SCR_AIGroup.Cast(Spawn(groupPrefab, guardPosition)); if (!site.Group) return Fail("guard group resource: " + spec.Id); if (!site.Group.m_aUnitPrefabSlots || site.Group.m_aUnitPrefabSlots.IsEmpty() || !site.Group.GetSpawnImmediately()) return Fail("guard profile must request an initial physical squad: " + spec.Id); site.InitialGuardCount = site.Group.m_aUnitPrefabSlots.Count(); @@ -337,11 +385,16 @@ class ERR_SiteService Fail("initial guard group disappeared or has no configured members: " + site.Definition.Id); return m_State; } - if (site.Group.GetFaction() != m_Mode.EnemyFaction()) + if (!m_Mode.IsMissionEnemy(site.Group.GetFaction())) { Fail("guard group faction disagrees with catalog: " + site.Definition.Id); return m_State; } + if (site.Group.GetFaction().GetFactionKey() != site.ExpectedGuardFaction) + { + Fail("guard prefab faction disagrees with its content definition: " + site.Definition.Id); + return m_State; + } array agents = {}; array physical = {}; site.Group.GetAgents(agents); @@ -352,7 +405,7 @@ class ERR_SiteService IEntity actor = agent.GetControlledEntity(); if (!actor || physical.Contains(actor)) continue; physical.Insert(actor); - if (SCR_FactionManager.SGetFaction(actor) != m_Mode.EnemyFaction()) + if (SCR_FactionManager.SGetFaction(actor) != site.Group.GetFaction()) { Fail("initial guard actor faction disagrees with catalog: " + site.Definition.Id); return m_State; @@ -381,6 +434,15 @@ class ERR_SiteService PrintFormat("Reintroduced [guards]: timeout detail site=%1 navmesh=%2 tileLoaded=%3 tileRequested=%4 dormantAlive=%5 dormantDead=%6", site.Definition.Id, navmesh != null, tileLoaded, tileRequested, site.Group.GetDormantAliveCount(), site.Group.GetDormantDeadCount()); Fail(string.Format("initial guard population incomplete or invalid: site=%1 requested=%2 physical=%3 conscious=%4 nativeComplete=%5; limit=90s; no replacement requested", site.Definition.Id, site.InitialGuardCount, physical.Count(), conscious, complete)); } + if (result == ERR_PrepareResult.READY && site.PrisonPatrol) + { + if (!site.PrisonPatrol.Place(site.Group, site.Location.Position())) + { + Fail("no clear perimeter positions for prison patrol"); return ERR_PrepareResult.FAILED; + } + result = site.PrisonPatrol.Radio(m_Mode, site.Group, m_GuardDeadline); + if (result == ERR_PrepareResult.FAILED) Fail("prison patrol large radio could not be equipped"); + } return result; } protected void CancelInitialGuardRequests() diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnLogic.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnLogic.c index 50ea2a8..547937d 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnLogic.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnLogic.c @@ -146,7 +146,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic protected bool SpawnPosition(ERR_GameMode mode, out vector position) { - if (mode.Phase() == ERR_Phase.PRISON) return m_Placement.Prison(mode.PrisonPosition(), m_AuthorizedPositions, position); + if (mode.Phase() == ERR_Phase.PRISON) return m_Placement.Prison(mode.PrisonPosition(), m_AuthorizedPositions, position, mode.PrisonHeading()); array survivors = {}; ERR_Actors.Players(survivors, true); foreach (IEntity survivor : survivors) @@ -155,7 +155,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic if (!mode.Players() || !mode.Players().CurrentActor(survivor) || !mode.PlayerFaction() || SCR_FactionManager.SGetFaction(survivor) != mode.PlayerFaction()) continue; if (m_Placement.Near(survivor, m_AuthorizedPositions, position)) return true; } - return m_Placement.Prison(mode.PrisonPosition(), m_AuthorizedPositions, position); + return m_Placement.Prison(mode.PrisonPosition(), m_AuthorizedPositions, position, mode.PrisonHeading()); } ERR_SpawnTicket CreateSpawnTicket(SCR_SpawnRequestComponent request, SCR_SpawnData data) @@ -328,7 +328,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic m_Attempts.Remove(playerId); m_Retries.Remove(playerId); ERR_GameMode mode = ERR_GameMode.Get(); - if (mode) mode.PlayerSpawned(playerId, entity); + if (mode) { ERR_Actors.SetFaction(entity, mode.PlayerFaction()); mode.PlayerSpawned(playerId, entity); } SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(playerId)); if (controller) controller.ERR_SetObserver(ERR_ObserverReason.NONE); PrintFormat("Reintroduced [spawn]: player %1 at %2", playerId, entity.GetOrigin()); diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnPlacement.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnPlacement.c index ec23f54..6d44c59 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnPlacement.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnPlacement.c @@ -2,18 +2,25 @@ class ERR_SpawnPlacement { protected IEntity m_Exclude; + protected vector m_Candidate; protected bool m_Blocked; - protected bool Obstacle(IEntity entity) + protected bool Occupied(IEntity entity) { - if (!entity || (!entity.GetVObject() && !ChimeraCharacter.Cast(entity))) return true; + // Character controller geometry is not included in every native shape trace. + // Narrow the broad-phase result by actual body position, not its query bounds. + if (entity != m_Exclude && ChimeraCharacter.Cast(entity) && vector.DistanceXZ(m_Candidate, entity.GetOrigin()) < 1.2 && Math.AbsFloat(m_Candidate[1] - entity.GetOrigin()[1]) < 2) m_Blocked = true; + return !m_Blocked; + } + protected bool Obstacle(IEntity entity, vector start = "0 0 0", vector direction = "0 0 0") + { + if (!entity) return false; IEntity parent = entity; for (int depth = 0; parent && depth < 32; depth++) { - if (parent == m_Exclude) return true; + if (parent == m_Exclude) return false; parent = parent.GetParent(); } - m_Blocked = true; - return false; + return true; } bool Clear(vector position, map reservations, int ownReservation = -1, IEntity exclude = null) { @@ -30,16 +37,24 @@ class ERR_SpawnPlacement vector sample = ERR_Actors.Ground(position + Vector(x * 0.45, 0, z * 0.45)); if (Math.AbsFloat(sample[1] - ground[1]) > 0.3) return false; } - m_Blocked = false; m_Exclude = exclude; - GetGame().GetWorld().QueryEntitiesByAABB(ground + "-0.45 0.15 -0.45", ground + "0.45 1.9 0.45", Obstacle); + m_Candidate = ground; m_Blocked = false; + GetGame().GetWorld().QueryEntitiesByAABB(ground + "-1.2 -2 -1.2", ground + "1.2 2 1.2", Occupied); + if (m_Blocked) { m_Exclude = null; return false; } + // Broad entity bounds overlap clear slots when the prison is rotated. + // Test the filled standing volume against physical geometry instead. + TraceBox volume = new TraceBox(); + volume.Start = ground + "0 0.15 0"; volume.End = volume.Start + "0 0.01 0"; + volume.Mins = "-0.45 0 -0.45"; volume.Maxs = "0.45 1.75 0.45"; + volume.Flags = TraceFlags.ENTS | TraceFlags.WORLD; + volume.Exclude = exclude; + float fraction = GetGame().GetWorld().TraceMove(volume, Obstacle); m_Exclude = null; - return !m_Blocked; + return ERR_ForceRules.Finite(fraction) && fraction >= 1; } - bool Prison(vector center, map reservations, out vector position) + bool Prison(vector center, map reservations, out vector position, float heading = 0) { - // Native character query bounds are about 4m wide. A 3m lattice leaves - // clearance between that conservative bound and the 0.9m query column. + // A 3m lattice leaves clearance around the 0.9m standing volume. // Search the four inner positions, then eight edge positions, then corners. // All sixteen centers remain at least 1.5m inside the authored +/-6m walls. for (int band = 0; band <= 2; band++) @@ -50,7 +65,7 @@ class ERR_SpawnPlacement if (row == 0 || row == 3) outerAxes++; if (column == 0 || column == 3) outerAxes++; if (outerAxes != band) continue; - vector candidate = ERR_Actors.Ground(center + Vector(-4.5 + column * 3, 0, -4.5 + row * 3)); + vector candidate = ERR_Actors.Ground(center + ERR_CampTerrain.Rotate(Vector(-4.5 + column * 3, 0, -4.5 + row * 3), heading)); if (!Clear(candidate, reservations)) continue; position = candidate; return true; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_TimeService.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_TimeService.c index 6285b01..20afd2c 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_TimeService.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_TimeService.c @@ -33,6 +33,14 @@ class ERR_TimeService minutes = hours * 60 + remainder; return true; } + // Whole minutes strictly after sunrise and before sunset; exclusive end. + static bool DaylightBounds(float sunrise, float sunset, out int first, out int end) + { + if (sunrise < 0 || sunset > 24 || sunrise >= sunset) return false; + first = Math.Floor(sunrise * 60) + 1; + end = Math.Ceil(sunset * 60); + return first >= 0 && first < end && end <= 1440; + } bool Start(ERR_GameMode mode) { if (!Replication.IsServer() || !mode || !mode.Settings()) return false; @@ -41,8 +49,15 @@ class ERR_TimeService if (!world) return false; m_Manager = world.GetTimeAndWeatherManager(); if (!m_Manager) return false; - int startMinutes = mode.Settings().Get("startTimeMinutes"); - int multiplier = mode.Settings().Get("timeMultiplier"); + int startMinutes = mode.Settings().Runtime("startTimeMinutes"); + if (startMinutes == -2) + { + float sunrise, sunset; + int first, end; + if (!m_Manager.GetSunriseHour(sunrise) || !m_Manager.GetSunsetHour(sunset) || !DaylightBounds(sunrise, sunset, first, end)) return false; + startMinutes = Math.RandomInt(first, end); + } + int multiplier = mode.Settings().Runtime("timeMultiplier"); if (!ERR_Settings.Valid("startTimeMinutes", startMinutes) || !ERR_Settings.Valid("timeMultiplier", multiplier)) return false; float previousDuration = m_Manager.GetDayDuration(); bool previousAdvancement = m_Manager.GetIsDayAutoAdvanced(); diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_AirAmmoMission.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_AirAmmoMission.c new file mode 100644 index 0000000..4cb785c --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_AirAmmoMission.c @@ -0,0 +1,239 @@ +// A unit's low-ammo radio check-in can request this airport-loaded delivery. +// Each trip creates at most twelve compatible magazines at the airfield; those +// exact identities must physically arrive before any native inventory transfer. +class ERR_AirAmmoMission +{ + protected ERR_HQ m_HQ; + protected ERR_GameMode m_Mode; + protected ERR_ReconFlightComponent m_Flight; + protected ERR_HQUnit m_Unit; + protected ref ERR_HQRoute m_Order, m_RecipientOrder; + protected ref array m_Cargo = {}, m_Spent = {}, m_Members = {}; + protected ref array m_Loads = {}; + protected ref ERR_InfantryAmmoTransfer m_Move; + protected ref array m_Operations = {}; + protected ref array m_PreviouslySubmitted; + protected float m_NextDiagnostic; + bool PendingNative() + { + foreach (ERR_ConvoyCargoCallback loading : m_Loads) if (!loading.Finished) return true; + foreach (ERR_InfantryAmmoTransfer operation : m_Operations) if (operation.Unresolved()) return true; + return false; + } + protected AIWaypoint m_Waypoint; + protected vector m_Point, m_Pad; + protected int m_Message, m_Token, m_State, m_Created, m_Delivered; + protected float m_Deadline, m_NextTick; + protected bool m_Finished; + bool Finished() { return m_Finished; + } + void ERR_AirAmmoMission(ERR_HQ hq, ERR_GameMode mode, ERR_ReconFlightComponent flight, ERR_HQUnit recipient, vector reported, int message, int trip, array submitted) + { + m_PreviouslySubmitted = submitted; + m_HQ = hq; + m_Mode = mode; + m_Flight = flight; + m_Unit = recipient; + m_Point = reported; + m_Message = message; + m_Token = 200000 + trip; + m_Deadline = GetGame().GetWorld().GetWorldTime() + 600000; + if (!m_HQ.ReserveAirAmmo(recipient, m_Token)) { m_Finished = true; + return; + } + array agents = {}; + recipient.Group.GetAgents(agents); + foreach (AIAgent agent : agents) if (agent && ERR_Actors.CanFight(agent.GetControlledEntity())) m_Members.Insert(agent.GetControlledEntity()); + bool clear; + for (int i = 0; i < 12; i++) + { + float angle = i * Math.PI / 6; + if (ERR_ReconAirports.FindClearPad(reported + Vector(Math.Sin(angle), 0, Math.Cos(angle)) * 80, m_Pad) && vector.DistanceXZ(reported, m_Pad) <= 200) { clear = true; + break; + } + } + if (!clear || m_Members.IsEmpty()) { Finish("no safe landing area near delivered request"); + return; + } + m_Order = hq.Downlink(flight.Crew(), "AIR AMMUNITION", "load twelve magazines and fly to reported unit position " + reported.ToString(), message); + m_RecipientOrder = hq.Downlink(recipient.Group, "AMMUNITION RENDEZVOUS", "meet supply helicopter at " + m_Pad.ToString(), message); + if (!m_Order || !m_RecipientOrder) Finish("physical radio downlink unavailable"); + } + protected ResourceName Magazine(IEntity actor) + { + if (!actor) return ""; + BaseWeaponManagerComponent manager = BaseWeaponManagerComponent.Cast(actor.FindComponent(BaseWeaponManagerComponent)); + if (!manager) return ""; + array weapons = {}; + manager.GetWeapons(weapons); + foreach (BaseWeaponComponent weapon : weapons) + { + if (!weapon || (weapon.GetWeaponType() != EWeaponType.WT_RIFLE && weapon.GetWeaponType() != EWeaponType.WT_MACHINEGUN && weapon.GetWeaponType() != EWeaponType.WT_SNIPERRIFLE)) continue; + array muzzles = {}; + weapon.GetMuzzlesList(muzzles); + foreach (BaseMuzzleComponent muzzle : muzzles) + { + BaseMagazineComponent magazine = muzzle.GetMagazine(); + if (magazine && magazine.GetOwner().GetPrefabData()) return magazine.GetOwner().GetPrefabData().GetPrefabName(); + } + } + return ""; + } + protected bool Owned() + { + return m_Unit && m_Unit.Group && m_Unit.Group.GetPlayerCount(true) == 0 && m_Unit.Group.GetFaction() == m_Mode.EnemyFaction() + && m_HQ.LogisticsToken(m_Unit.Group) == m_Token && m_Flight && !m_Flight.Lost() && m_Flight.Phase() != ERR_ReconPhase.RELEASED; + } + protected void Finish(string reason) + { + if (m_Finished) return; + m_Finished = true; + if (m_Move) m_Move.Stop(); + if (m_Waypoint) { if (m_Unit && m_Unit.Group) m_Unit.Group.RemoveWaypoint(m_Waypoint); + ERR_Actors.DeleteSpawned(m_Waypoint); + m_Waypoint = null; + } + if (m_Unit) m_HQ.ReleaseLogistics(m_Unit.Group, m_Token); + if (m_Unit && m_Unit.Group) + { + array releasedAgents = {}; + m_Unit.Group.GetAgents(releasedAgents); + foreach (AIAgent releasedAgent : releasedAgents) if (releasedAgent) releasedAgent.SetPermanentLOD(-1); + } + if (m_Flight) m_Flight.ReturnHome(); + ERR_HQ.Event("AIR AMMUNITION", string.Format("delivered=%1; %2; remaining original cargo retained", m_Delivered, reason)); + } + void Tick(float now) + { + if (m_Finished || now < m_NextTick) return; + m_NextTick = now + 1000; + if (!m_Mode.Active() || !Owned() || now >= m_Deadline) { Finish("mission expired or ownership lost"); + return; + } + if (m_HQ.AirAssault().MortarsBusy(m_Pad, now)) { Finish("mortar fire conflicts with landing corridor"); + return; + } + if (m_State == 0) + { + int aircraft = 1, recipient = 1; + if (m_Order) aircraft = m_Order.Advance(now, m_HQ.Journal()); + if (m_RecipientOrder) recipient = m_RecipientOrder.Advance(now, m_HQ.Journal()); + if (aircraft == 1) m_Order = null; + if (recipient == 1) m_RecipientOrder = null; + if (aircraft < 0 || recipient < 0) { Finish("radio order interrupted"); + return; + } + if (aircraft == 0 || recipient == 0) return; + m_Unit.ReleaseAssignment(); + m_State = 1; + } + InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(m_Flight.Aircraft().FindComponent(InventoryStorageManagerComponent)); + if (!inventory) { Finish("aircraft cargo storage unavailable"); + return; + } + if (m_State == 1) + { + if (!m_Flight.AtAirport()) { Finish("loading requires airport"); + return; + } + if (m_Created < 12) + { + ResourceName prefab = Magazine(m_Members[m_Created % m_Members.Count()]); + m_Created++; + if (prefab.IsEmpty()) return; + ERR_ConvoyCargoCallback callback = new ERR_ConvoyCargoCallback(); + m_Loads.Insert(callback); + if (!inventory.TrySpawnPrefabToStorage(prefab, null, -1, EStoragePurpose.PURPOSE_DEPOSIT, callback)) callback.Finished = true; + return; + } + foreach (ERR_ConvoyCargoCallback operation : m_Loads) if (!operation.Finished) return; + array items = {}; + ERR_Inventory.Items(inventory, items); + foreach (IEntity item : items) if (item && item.FindComponent(BaseMagazineComponent) && !m_Cargo.Contains(item) && !m_PreviouslySubmitted.Contains(item)) m_Cargo.Insert(item); + if (m_Cargo.IsEmpty()) { Finish("no physical cargo loaded"); + return; + } + m_Flight.LaunchDelivery(m_Pad); + m_State = 2; + ERR_HQ.Event("AIR AMMUNITION DEPARTURE", "native cargo aboard; destination from delivered unit request"); + return; + } + if (m_State == 2) + { + if (m_Flight.Phase() != ERR_ReconPhase.PARKED || vector.DistanceXZ(m_Flight.Aircraft().GetOrigin(), m_Pad) > 10) return; + array anchors = {}; + if (!ERR_InfantryAmmoAccess.Points(m_Flight.Aircraft(), anchors)) { Finish("no authored aircraft cargo access"); + return; + } + vector accessPoint = anchors[0]; + vector outward = accessPoint - m_Flight.Aircraft().GetOrigin(); + outward[1] = 0; + if (outward.Length() < 0.1) outward = "1 0 0"; + accessPoint = ERR_Actors.Ground(accessPoint + outward.Normalized() * 2); + m_Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), accessPoint)); + if (!m_Waypoint) { Finish("rendezvous waypoint unavailable"); + return; + } + m_Waypoint.SetCompletionRadius(0.5); + m_Unit.Group.AddWaypointAt(m_Waypoint, 0); + m_State = 3; + } + if (m_State == 3) + { + if (now >= m_NextDiagnostic) + { + m_NextDiagnostic = now + 15000; + float closest = 1e20; + foreach (IEntity approaching : m_Members) + if (ERR_Actors.CanFight(approaching)) closest = Math.Min(closest, vector.Distance(approaching.GetOrigin(), m_Flight.Aircraft().GetOrigin())); + ERR_HQ.Event("AIR AMMUNITION RENDEZVOUS", string.Format("nearest original recipient=%1m; confirmed magazines=%2", closest, m_Delivered)); + } + SCR_AIGroupUtilityComponent utility = m_Unit.Group.GetGroupUtilityComponent(); + if (utility && utility.m_Perception && !utility.m_Perception.m_aTargets.IsEmpty()) { Finish("recipient engaged in combat; delivery aborted"); return; } + m_Unit.Group.ActivateAI(); + array receivingAgents = {}; + m_Unit.Group.GetAgents(receivingAgents); + foreach (AIAgent receivingAgent : receivingAgents) + { + receivingAgent.SetPermanentLOD(0); + receivingAgent.ActivateAI(); + } + } + if (m_Move) + { + int result = m_Move.Tick(now); + if (result == 0) return; + if (result > 0) m_Delivered++; + m_Move = null; + if (m_Delivered >= 12) { Finish("delivery complete"); + return; + } + } + foreach (IEntity member : m_Members) + { + if (!ERR_Actors.CanFight(member) || ERR_Actors.Player(member) || ERR_Actors.Group(member) != m_Unit.Group || vector.Distance(member.GetOrigin(), m_Flight.Aircraft().GetOrigin()) > 15) continue; + foreach (IEntity magazine : m_Cargo) + { + if (!magazine || m_Spent.Contains(magazine) || !ERR_InfantryAmmoTransfer.Compatible(member, magazine)) continue; + m_Move = new ERR_InfantryAmmoTransfer(m_Mode, m_Flight.Aircraft(), m_Flight.Crew(), member, m_Unit.Group, magazine); + m_Move.Tick(now); + if (m_Move.Submitted()) { m_Spent.Insert(magazine); + m_PreviouslySubmitted.Insert(magazine); + m_Operations.Insert(m_Move); + return; + } + if (now >= m_NextDiagnostic) + { + m_NextDiagnostic = now + 15000; + ERR_HQ.Event("AIR AMMUNITION WAIT", m_Move.Reason()); + } + m_Move = null; + } + } + if (m_Spent.Count() >= m_Cargo.Count()) Finish("all original cargo operations completed"); + } + bool BlocksMortar(vector point) { return !m_Finished && vector.DistanceXZ(point, m_Pad) < 400; + } + void Stop() { Finish("mission stopped"); + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultLoad.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultLoad.c new file mode 100644 index 0000000..1f9f364 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultLoad.c @@ -0,0 +1,350 @@ +class ERR_AirPassenger +{ + IEntity Actor; + BaseCompartmentSlot Seat; + bool Locked = true; + bool Jumped; + ERR_AssaultParachute Chute; +} +class ERR_AirAssaultTeam +{ + SCR_AIGroup Group; + ref array Members = {}; + ref ERR_AirDropOrder Order; + bool Released; + void Landed() {} // Canopy completion is observed by the owning load for the entire original team. + void Update() + { + if (Released || !Group || !Order || Group.GetPlayerCount(true) > 0) return; + bool survivor; + foreach (ERR_AirPassenger member : Members) + { + if (!ERR_Actors.Alive(member.Actor)) continue; + if (!member.Jumped || CompartmentAccessComponent.GetVehicleIn(member.Actor) || member.Actor.GetOrigin()[1] - SCR_TerrainHelper.GetTerrainY(member.Actor.GetOrigin()) > 3) return; + survivor = survivor || ERR_Actors.CanFight(member.Actor); + } + if (!survivor) return; + Released = true; + Group.ActivateAI(); + Group.SetPermanentLOD(-1); + ERR_HQ.Get().LandAirTeam(Group, Order); + } +} +class ERR_AirDropGeometry +{ + static float Along(vector position, vector center, vector direction) + { vector offset = position - center; + offset[1] = 0; + return vector.Dot(offset, direction); + } + static bool Corridor(vector position, vector center, vector direction, vector velocity) + { + float along = Along(position, center, direction); + vector lateral = position - center - direction * along; + lateral[1] = 0; + velocity[1] = 0; + return along >= -175 && along <= 250 && lateral.Length() <= 45 && velocity.Length() >= 15 && vector.Dot(velocity.Normalized(), direction) >= 0.8; + } + static bool LandingLine(vector center, vector direction) + { + for (int step = -4; step <= 5; step++) + { + vector point = ERR_Actors.Ground(center + direction * (step * 50)); + if (point[1] < 2 || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point) || !ERR_HQRoadIntercept.Standable(point)) return false; + TraceBox trace = new TraceBox(); + trace.Start = point + "0 3 0"; + trace.End = point + "0 190 0"; + trace.Mins = "-4 -1 -4"; + trace.Maxs = "4 1 4"; + trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD; + if (GetGame().GetWorld().TraceMove(trace, null) < 1) return false; + } + return true; + } +} +// Eight original passengers, two original teams, one consumed load per aircraft. +class ERR_AirAssaultLoad +{ + static const ResourceName PARACHUTE = "{C31F210ABE0781A0}Prefabs/ERR/AssaultParachute.et"; + protected ERR_GameMode m_Mode; + protected ERR_ReconFlightComponent m_Flight; + protected Vehicle m_Aircraft; + protected int m_Serial, m_NextPassenger; + protected ref array m_Seats = {}; + protected ref array m_Passengers = {}; + protected ref array m_Teams = {}; + protected ref ERR_AirDropOrder m_Order; + protected float m_CoverUntil; + bool Cover(float now, out vector center) { if (!m_Order || !m_Used || m_Approaching || now >= m_CoverUntil) return false; + center = m_Order.Target; + return true; + } + bool NeedsService(float now) { return m_Used && !m_Approaching && now >= m_CoverUntil; + } + protected float m_LoadDeadline, m_NextBoard, m_NextJump, m_AirspaceUntil; + protected bool m_Ready, m_Failed, m_Stopped, m_Used, m_Approaching, m_RunCommitted; + protected vector m_Center, m_Direction, m_RunStart, m_RunEnd; + static void SetLock(IEntity actor, bool locked) + { + ERR_HQ hq = ERR_HQ.Get(); + if (hq && hq.AirAssault()) hq.AirAssault().Lock(actor, locked); + } + void ReuseRemaining() + { + m_Used = false; + m_Order = null; + m_CoverUntil = 0; + foreach (ERR_AirPassenger member : m_Passengers) + if (member.Actor && CompartmentAccessComponent.GetVehicleIn(member.Actor) == m_Aircraft) { member.Jumped = false; + member.Locked = true; + } + } + int Count() { return m_Passengers.Count(); + } + ERR_AirPassenger At(int index) { return m_Passengers[index]; + } + bool Used() { return m_Used; + } + bool Preparing() { return !m_Ready && !m_Failed && !m_Stopped; + } + bool Ready() + { + return m_Ready && !m_Failed && !m_Stopped && !m_Used && m_Flight && !m_Flight.Lost() + && (m_Flight.Phase() == ERR_ReconPhase.SEARCH || m_Flight.Phase() == ERR_ReconPhase.SHADOW) && Aboard() > 0; + } + void ERR_AirAssaultLoad(ERR_GameMode mode, ERR_ReconFlightComponent flight, int serial) + { m_Mode = mode; + m_Flight = flight; + m_Aircraft = flight.Aircraft(); + m_Serial = serial; + } + protected void Seats(IEntity entity) + { + BaseCompartmentManagerComponent manager = BaseCompartmentManagerComponent.Cast(entity.FindComponent(BaseCompartmentManagerComponent)); + if (manager) + { + array seats = {}; + manager.GetCompartments(seats); + foreach (BaseCompartmentSlot seat : seats) + if (seat && seat.GetType() == ECompartmentType.CARGO && seat.IsCompartmentAccessible() && !seat.IsOccupied() && !m_Seats.Contains(seat)) m_Seats.Insert(seat); + } + IEntity child = entity.GetChildren(); + while (child) { Seats(child); + child = child.GetSibling(); + } + } + protected void Failure(string reason) + { + m_Failed = true; + m_Approaching = false; + foreach (ERR_AirPassenger member : m_Passengers) member.Locked = false; + ERR_HQ.Event("AIR LOAD UNAVAILABLE", reason + "; original partial entities retained, reconnaissance remains available"); + } + void Load(vector pad) + { + m_LoadDeadline = GetGame().GetWorld().GetWorldTime() + 30000; + Seats(m_Aircraft); + ResourceName soldier = m_Mode.FactionSelection().Soldier(); + Resource chute = Resource.Load(PARACHUTE); + if (m_Seats.Count() < 8 || soldier.IsEmpty() || !chute || !chute.IsValid()) { Failure("eight usable cargo seats, soldier or canopy unavailable"); + return; + } + for (int t = 0; t < 2; t++) + { + ERR_AirAssaultTeam team = new ERR_AirAssaultTeam(); + team.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn("{C31F210ABE078195}Prefabs/ERR/ReconCrew.et", pad)); + m_Teams.Insert(team); + if (!team.Group) { Failure("passenger group spawn failed"); + return; + } + m_Mode.Own(team.Group); + team.Group.SetFaction(m_Mode.EnemyFaction()); + team.Group.SetPermanentLOD(0); + } + for (int i = 0; i < 8; i++) + { + ERR_AirPassenger member = new ERR_AirPassenger(); + member.Seat = m_Seats[i]; + AIGroup group = m_Teams[i % 2].Group; + member.Actor = member.Seat.SpawnCharacterInCompartment(soldier, group, "{C31F210ABE078195}Prefabs/ERR/ReconCrew.et"); + m_Passengers.Insert(member); + m_Teams[i % 2].Members.Insert(member); + if (!member.Actor || group != m_Teams[i % 2].Group) { Failure("native passenger creation or group identity failed"); + return; + } + } + } + protected void Boarding(float now) + { + foreach (ERR_AirAssaultTeam loadingTeam : m_Teams) + { + if (!loadingTeam.Group) continue; + loadingTeam.Group.ActivateAI(); + array loadingAgents = {}; + loadingTeam.Group.GetAgents(loadingAgents); + foreach (AIAgent loadingAgent : loadingAgents) { loadingAgent.SetPermanentLOD(0); + loadingAgent.ActivateAI(); + } + } + if (now >= m_LoadDeadline) { Failure(string.Format("native passenger boarding timed out; seated=%1/%2", Aboard(), Count())); + return; + } + bool all = m_Passengers.Count() == 8; + foreach (ERR_AirPassenger member : m_Passengers) + { + if (!ERR_Actors.CanFight(member.Actor)) { all = false; + continue; + } + if (CompartmentAccessComponent.GetVehicleIn(member.Actor) == m_Aircraft && member.Seat.GetOccupant() == member.Actor) continue; + all = false; + if (now < m_NextBoard || CompartmentAccessComponent.GetVehicleIn(member.Actor) || member.Seat.IsOccupied()) continue; + SCR_CompartmentAccessComponent access = SCR_CompartmentAccessComponent.Cast(member.Actor.FindComponent(SCR_CompartmentAccessComponent)); + if (access) access.MoveInVehicle(m_Aircraft, ECompartmentType.CARGO, false, member.Seat); + } + if (now >= m_NextBoard) m_NextBoard = now + 1000; + if (!all) return; + for (int t = 0; t < m_Teams.Count(); t++) + { + ERR_AirAssaultTeam team = m_Teams[t]; + foreach (ERR_AirPassenger original : team.Members) + if (ERR_Actors.Group(original.Actor) != team.Group || !ERR_Actors.SetFaction(original.Actor, m_Mode.EnemyFaction())) { Failure("original passenger faction/group changed"); + return; + } + ERR_ForceRecord record = m_Mode.Forces().Register(string.Format("airborne-%1-team-%2", m_Serial, t), ERR_ForceKind.PATROL, m_Aircraft.GetOrigin(), 4, null, team.Group, now); + if (!record) { Failure("finite passenger ledger refused registration"); + return; + } + record.Pinned = true; + m_Mode.RegisterGroup(team.Group); + ERR_HQ.Get().ReserveGroup(team.Group); + team.Group.ActivateAI(); + array agents = {}; + team.Group.GetAgents(agents); + foreach (AIAgent agent : agents) { agent.SetPermanentLOD(0); + agent.ActivateAI(); + } + } + m_Ready = true; + ERR_HQ.Event("AIR LOAD READY", "eight original passengers seated in two four-person teams"); + } + int Aboard() + { + if (!m_Aircraft) return 0; + int count; + foreach (ERR_AirPassenger member : m_Passengers) + if (ERR_Actors.CanFight(member.Actor) && !ERR_Actors.Player(member.Actor) && CompartmentAccessComponent.GetVehicleIn(member.Actor) == m_Aircraft) count++; + return count; + } + bool HoldsSeat(IEntity actor) + { + if (m_Stopped || m_Failed || !ERR_Actors.CanFight(actor) || ERR_Actors.Player(actor)) return false; + foreach (ERR_AirAssaultTeam team : m_Teams) + foreach (ERR_AirPassenger member : team.Members) + if (member.Actor == actor && member.Locked && ERR_Actors.Group(actor) == team.Group) + return (m_Flight && m_Flight.Phase() < ERR_ReconPhase.PARKED && CompartmentAccessComponent.GetVehicleIn(actor) == m_Aircraft) || (member.Chute && CompartmentAccessComponent.GetVehicleIn(actor) == member.Chute); + return false; + } + void Lock(IEntity actor, bool locked) + { foreach (ERR_AirPassenger member : m_Passengers) if (member.Actor == actor) member.Locked = locked; + } + bool Begin(ERR_AirDropOrder order, float now) + { + if (!Ready() || !order || now >= order.ExpiresAt) return false; + vector radial = m_Aircraft.GetOrigin() - order.Target; + radial[1] = 0; + float heading = Math.Atan2(radial[0], radial[2]); + bool found; + for (int attempt = 0; attempt < 12; attempt++) + { + float angle = heading + attempt * Math.PI / 6; + radial = Vector(Math.Sin(angle), 0, Math.Cos(angle)); + m_Center = ERR_Actors.Ground(order.Target + radial * 450); + m_Direction = Vector(-radial[2], 0, radial[0]); + if (ERR_AirDropGeometry.LandingLine(m_Center, m_Direction)) { found = true; + break; + } + } + if (!found) return false; + m_RunStart = m_Center - m_Direction * 350; + m_RunEnd = m_Center + m_Direction * 450; + m_CoverUntil = now + 300000; + m_Order = order; + m_Used = true; + m_Approaching = true; + m_RunCommitted = false; + m_NextPassenger = 0; + m_NextJump = now; + foreach (ERR_AirAssaultTeam team : m_Teams) team.Order = order; + m_AirspaceUntil = order.ExpiresAt + 150000; + return true; + } + bool Approach(out vector point) + { + point = m_RunStart; + if (m_RunCommitted) point = m_RunEnd; + return m_Approaching && !m_Stopped; + } + void Abort() + { if (m_Approaching && m_Order) m_CoverUntil = GetGame().GetWorld().GetWorldTime() + 300000; + m_Approaching = false; + m_RunCommitted = false; + } + bool BlocksMortar(vector impact, float now) + { + if (!m_Order || vector.DistanceXZ(impact, m_Order.Target) >= 1200) return false; + foreach (ERR_AirPassenger member : m_Passengers) + if (member.Chute && member.Actor && CompartmentAccessComponent.GetVehicleIn(member.Actor) == member.Chute) return true; + return now < m_AirspaceUntil; + } + void Update(float now) + { + if (m_Stopped || !Replication.IsServer() || !m_Mode.Active()) return; + foreach (ERR_AirAssaultTeam team : m_Teams) team.Update(); + if (Preparing()) { Boarding(now); + return; + } + if (!m_Approaching) return; + if (!m_Flight || m_Flight.Lost() || m_Flight.Phase() >= ERR_ReconPhase.RETURNING || !ERR_Actors.Alive(m_Aircraft) || now >= m_Order.ExpiresAt) + { Abort(); + ERR_HQ.Event("AIR DROP ABORTED", "authorization expired or aircraft unavailable; no new passengers created"); + return; + } + foreach (ERR_AirAssaultTeam originalTeam : m_Teams) + if (!originalTeam.Group || originalTeam.Group.GetPlayerCount(true) > 0 || originalTeam.Group.GetFaction() != m_Mode.EnemyFaction()) { Abort(); + return; + } + vector position = m_Aircraft.GetOrigin(); + if (!m_RunCommitted && vector.DistanceXZ(position, m_RunStart) <= 80) m_RunCommitted = true; + if (m_RunCommitted && ERR_AirDropGeometry.Along(position, m_Center, m_Direction) > 300) { Abort(); + return; + } + float height = position[1] - SCR_TerrainHelper.GetTerrainY(position); + if (!m_RunCommitted || now < m_NextJump || height < 90 || height > 180 || !m_Aircraft.GetPhysics() + || !ERR_AirDropGeometry.Corridor(position, m_Center, m_Direction, m_Aircraft.GetPhysics().GetVelocity())) return; + while (m_NextPassenger < m_Passengers.Count()) + { + int index = m_NextPassenger++; + ERR_AirPassenger passenger = m_Passengers[index]; + if (passenger.Jumped || !ERR_Actors.CanFight(passenger.Actor) || ERR_Actors.Player(passenger.Actor) || CompartmentAccessComponent.GetVehicleIn(passenger.Actor) != m_Aircraft) continue; + vector exit = m_Aircraft.CoordToParent("12 -4 0"); + ERR_AssaultParachute chute = ERR_AssaultParachute.Cast(ERR_Actors.Spawn(PARACHUTE, exit)); + m_NextJump = now + 2000; + if (!chute) { ERR_HQ.Event("AIR DROP FAILED", "canopy creation refused; original passenger retained"); + continue; + } + m_Mode.Own(chute); + passenger.Chute = chute; + vector landing = m_Center + m_Direction * ERR_AirDropGeometry.Along(position, m_Center, m_Direction); + passenger.Jumped = chute.BeginJump(passenger.Actor, m_Teams[index % 2], landing); + ERR_HQ.Event("AIR DROP JUMP", string.Format("original passenger=%1 transfer requested=%2", index, passenger.Jumped)); + break; + } + if (m_NextPassenger >= m_Passengers.Count()) Abort(); + } + void Stop() + { + m_Stopped = true; + Abort(); + foreach (ERR_AirPassenger passenger : m_Passengers) passenger.Locked = false; + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultSystem.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultSystem.c new file mode 100644 index 0000000..541dae6 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultSystem.c @@ -0,0 +1,223 @@ +class ERR_AirAssaultSystem +{ + protected ERR_HQ m_HQ; + protected ERR_GameMode m_Mode; + protected ref array m_Loads = {}; + protected ref ERR_AirSupportPolicy m_Policy = new ERR_AirSupportPolicy(); + protected ref ERR_HQRoute m_Route; + protected ref ERR_AirDropOrder m_Order; + protected ERR_AirAssaultLoad m_OrderedLoad; + protected float m_NextDecision; + protected bool m_Stopped; + protected string m_LoggedReason; + void ERR_AirAssaultSystem(ERR_HQ hq, ERR_GameMode mode) { m_HQ = hq; + m_Mode = mode; + } + string Status() { return "Airborne support: " + m_Policy.Reason + string.Format("; finite loads %1/4", m_Loads.Count()); + } + ERR_AirAssaultLoad Load(ERR_ReconFlightComponent flight, vector pad, int serial) + { + if (!Replication.IsServer() || m_Stopped || !m_Mode.Active() || m_Loads.Count() >= 4 || m_Mode.Forces().Count() > 253) return null; + ERR_AirAssaultLoad load = new ERR_AirAssaultLoad(m_Mode, flight, m_Loads.Count() + 1); + m_Loads.Insert(load); + // Consume the finite load before any native spawn; never refund partial failures. + load.Load(pad); + return load; + } + bool HoldsSeat(IEntity actor) + { + if (m_Stopped || !m_Mode.Active()) return false; + foreach (ERR_AirAssaultLoad load : m_Loads) if (load.HoldsSeat(actor)) return true; + return false; + } + void Lock(IEntity actor, bool locked) + { + foreach (ERR_AirAssaultLoad load : m_Loads) load.Lock(actor, locked); + } + bool MortarsBusy(vector point, float now) + { + array batteries = {}; + m_HQ.Mortars().Batteries(batteries); + foreach (ERR_MortarBattery battery : batteries) + { + if (now < battery.AirspaceUntil && vector.DistanceXZ(point, battery.AirspaceImpact) < battery.AirspaceRadius + 900) return true; + ERR_HQReport report = battery.Report; + if (battery.PendingRadioReport) report = battery.PendingRadioReport; + if ((battery.Activity || battery.RadioOrder || battery.CommandOpen || battery.Released) && report && vector.DistanceXZ(point, report.Position) < 1200) return true; + } + return false; + } + bool BlocksMortar(vector impact, float now) + { + if (m_Stopped) return false; + if (m_Order && vector.DistanceXZ(impact, m_Order.Target) < 1200) return true; + foreach (ERR_AirAssaultLoad load : m_Loads) if (load.BlocksMortar(impact, now)) return true; + return false; + } + void Maintain(float now) + { + if (!Replication.IsServer() || m_Stopped || !m_Mode.Active()) return; + foreach (ERR_AirAssaultLoad load : m_Loads) load.Update(now); + } + protected void CancelOrder(float now, string reason) + { + m_Route = null; + m_Order = null; + m_OrderedLoad = null; + m_Policy.Retry(now); + m_Policy.Reason = reason; + ERR_HQ.Event("AIR DROP WITHHELD", reason); + } + void Tick(float now) + { + if (!Replication.IsServer() || m_Stopped || !m_Mode.Active() || m_Mode.Phase() < ERR_Phase.RADIO_SEARCH) return; + if (m_Route) + { + if (!m_OrderedLoad || !m_OrderedLoad.Ready() || now >= m_Order.ExpiresAt) { CancelOrder(now, "order expired or original passenger load unavailable"); + return; + } + int delivery = m_Route.Advance(now, m_HQ.Journal()); + if (delivery < 0) { CancelOrder(now, "helicopter downlink interrupted; troops retained"); + return; + } + if (delivery == 0) return; + ERR_HQBelief belief = m_HQ.Memory().GetById(m_Order.BeliefId); + ERR_AirSupportSituation situation; + if (belief) situation = m_HQ.AirSituation(belief, m_OrderedLoad, now); + if (!belief || !ERR_AirSupportPolicy.Refusal(situation).IsEmpty() || vector.DistanceXZ(belief.PredictedPosition, m_Order.Target) > 150) + { CancelOrder(now, "memory, ground response or airspace changed during radio delivery"); + return; + } + if (!m_OrderedLoad.Begin(m_Order, now)) { CancelOrder(now, "no clear drop corridor; continue reconnaissance"); + return; + } + m_Policy.Committed(now); + m_Policy.Reason = "HQ-authorized drop underway"; + ERR_HQ.Event("AIR DROP AUTHORIZED", string.Format("belief=%1 report=M%2 target=%3; immutable delivered order", m_Order.BeliefId, m_Order.MessageId, m_Order.Target)); + m_Route = null; + m_Order = null; + m_OrderedLoad = null; + return; + } + if (now < m_NextDecision) return; + m_NextDecision = now + 5000; + ERR_ReconFlightComponent flight = m_HQ.Recon().Current(); + ERR_AirAssaultLoad load; + if (flight) load = flight.Assault(); + ERR_HQBelief best; + ERR_AirSupportSituation bestSituation; + float score = -1; + for (int index = 0; index < m_HQ.Memory().Count(); index++) + { + ERR_HQBelief candidate = m_HQ.Memory().At(index); + if (!candidate || candidate.Kind != ERR_HQMemoryKind.SIGHTING) continue; + ERR_AirSupportSituation candidateSituation = m_HQ.AirSituation(candidate, load, now); + float value = candidate.Confidence / (1 + candidate.Radius / 500); + if (!ERR_AirSupportPolicy.Refusal(candidateSituation).IsEmpty()) value -= 10; + if (value <= score && best) continue; + score = value; + best = candidate; + bestSituation = candidateSituation; + } + bool approve = m_Policy.Consider(bestSituation, now); + if (m_Mode.Settings().Runtime("supportSquadLimit") <= 0) { approve = false; + m_Policy.Reason = "HQ support disabled"; + } + if (m_LoggedReason != m_Policy.Reason) { m_LoggedReason = m_Policy.Reason; + ERR_HQ.Event("AIR DROP DECISION", Status()); + } + if (!approve || !best || !load) return; + ERR_HQRoute route = m_HQ.Downlink(flight.Crew(), "AIR DROP", m_Policy.Reason, best.MessageId); + if (!route) { m_Policy.Reason = "no physical radio downlink to helicopter"; + return; + } + m_Route = route; + m_OrderedLoad = load; + m_Order = new ERR_AirDropOrder(); + m_Order.BeliefId = best.BeliefId; + m_Order.BeliefRevision = best.BeliefRevision; + m_Order.MessageId = best.MessageId; + m_Order.Target = best.PredictedPosition; + m_Order.ExpiresAt = now + 120000; + m_Order.Report = best.Report.Snapshot(); + } + void Stop() + { + m_Stopped = true; + m_Route = null; + m_Order = null; + foreach (ERR_AirAssaultLoad load : m_Loads) load.Stop(); + } +} + +modded class ERR_HQ +{ + ERR_AirSupportSituation AirSituation(ERR_HQBelief belief, ERR_AirAssaultLoad load, float now) + { + ERR_AirSupportSituation result = new ERR_AirSupportSituation(); + result.AircraftReady = load && load.Ready(); + if (!belief) return result; + result.BeliefId = belief.BeliefId; + result.Samples = belief.SampleCount; + result.Age = (now - belief.ObservedAt) / 1000; + result.Confidence = belief.Confidence; + result.Radius = belief.Radius; + result.MotionKnown = belief.HasVelocity; + result.Speed = belief.Speed; + result.Infantry = belief.TravelMode == "on foot"; + if (belief.Report) result.Spotted = belief.Report.SeenEscapists; + result.ArtilleryBusy = m_AirAssault.MortarsBusy(belief.PredictedPosition, now); + foreach (ERR_HQUnit unit : m_Units) + { + // HQ knows friendly positions/strength from delivered check-ins, never live locations. + ERR_HQWatchEntry status = m_Watch.Find(unit.ForceSourceId); + if (!status || status.ObservedAt < 0 || now < status.ObservedAt || now - status.ObservedAt > 180000) continue; + if (vector.DistanceXZ(status.Position, belief.PredictedPosition) < 1000) result.ConfirmedLosses += status.ConfirmedLosses; + if (status.Closed || status.Fit <= 0 || unit.ReservedForSupport || unit.HomeGuard || unit.LogisticsReservation > 0 || m_Watch.ArmedSearchBlocked(unit.ForceSourceId, now)) continue; + ERR_HQTask task = unit.PlannedTask(); + float distance = vector.DistanceXZ(status.Position, belief.PredictedPosition); + if (task) + { + if (task.BeliefId == belief.BeliefId && now < task.ExpiresAt && distance <= 900) result.CommittedFit += status.Fit; + } + else if (distance <= 600) result.GroundFit += status.Fit; + } + return result; + } + void LandAirTeam(SCR_AIGroup group, ERR_AirDropOrder order) + { + foreach (ERR_HQUnit unit : m_Units) + { + if (unit.Group != group) continue; + unit.ReservedForSupport = false; + if (!order || !unit.Eligible(m_Mode)) return; + AIWaypoint waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.SearchWaypoint(), ERR_Actors.Ground(order.Target))); + if (!waypoint) return; + unit.Release(); + unit.Waypoint = waypoint; + unit.Assignment = order.Report.Snapshot(); + unit.Task = new ERR_HQTask(); + unit.Task.BeliefId = order.BeliefId; + unit.Task.BeliefRevision = order.BeliefRevision; + unit.Task.Role = ERR_HQPursuitRole.SEARCH; + unit.Task.Destination = order.Target; + unit.Task.QueuedAt = GetGame().GetWorld().GetWorldTime(); + unit.Task.ExpiresAt = unit.Task.QueuedAt + 120000; + group.AddWaypointAt(waypoint, 0); + ERR_HQ.Event("AIR TEAM LANDED", "original team searching carried HQ order at " + order.Target.ToString()); + return; + } + } +} + +modded class ERR_MortarSystem +{ + override bool Safe(ERR_MortarBattery battery, vector impact, float flight) + { + if (m_HQ.AirAssault().BlocksMortar(impact, GetGame().GetWorld().GetWorldTime()) || m_HQ.Airfield().BlocksMortar(impact)) + { SafetyReason = "HQ airborne drop corridor or descending troops"; + return false; + } + return super.Safe(battery, impact, flight); + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_AirReconSystem.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_AirReconSystem.c new file mode 100644 index 0000000..c4e95a2 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_AirReconSystem.c @@ -0,0 +1,109 @@ +// Owner's original search-helicopter launch/loss cadence, connected to this mission's HQ owner. +class ERR_ReconSchedule +{ + float Deadline = -1; + int Minutes = 15; + bool Started; + bool Stopped; + void Begin(float now) { if (!Started && !Stopped) { Started = true; Deadline = now + Minutes * 60000.0; } } + bool Due(float now, bool active) { return Started && !Stopped && !active && Deadline >= 0 && now >= Deadline; } + void Launched() { Deadline = -1; } + void Lost(float now) { if (!Stopped) Deadline = now + Minutes * 60000.0; } + void Change(int minutes, float now, bool active) { Minutes = minutes; if (Started && !Stopped && !active) Deadline = now + Minutes * 60000.0; } + void Stop() { Stopped = true; Deadline = -1; } +} +class ERR_AirReconSystem +{ + protected ERR_GameMode m_Mode; + protected ref ERR_ReconAirports m_Airports = new ERR_ReconAirports(); + protected ref ERR_ReconSchedule m_Schedule = new ERR_ReconSchedule(); + protected ref array m_Flights = {}; + protected ERR_ReconFlightComponent m_Current; + protected int m_Serial, m_PadAttempts; + protected float m_NextPad; + protected string m_Reason = "countdown starts after prison escape"; + void ERR_AirReconSystem(ERR_GameMode mode) { m_Mode = mode; m_Schedule.Minutes = mode.Settings().Runtime("heliTimerMinutes"); } + bool SupportPad(out vector point) + { + foreach (vector airport : m_Airports.m_Positions) + if (ERR_ReconAirports.FindClearPad(airport, point)) return true; + return false; + } + ERR_ReconFlightComponent Current() { return m_Current; } + bool Owns(SCR_AIGroup group) + { + foreach (ERR_ReconFlightComponent flight : m_Flights) if (flight && flight.Crew() == group) return true; + return false; + } + bool SetDelay(int minutes) + { + if (!Replication.IsServer() || m_Schedule.Stopped || !m_Mode.Settings().Set("heliTimerMinutes", minutes)) return false; + m_Schedule.Change(minutes, GetGame().GetWorld().GetWorldTime(), m_Current != null); + if (!m_Current) m_PadAttempts = 0; + ERR_HQ.Event("HELICOPTER TIMER", Status()); + return true; + } + void Return() { if (Replication.IsServer() && m_Current) m_Current.ReturnHome(); } + string Status() + { + string state = m_Reason; + if (m_Current) state = m_Current.Status(); + else if (m_Schedule.Started && !m_Schedule.Stopped) + { + float due = m_Schedule.Deadline; if (m_PadAttempts > 0) due = m_NextPad; + state += string.Format("; next attempt in %1 seconds", Math.Ceil(Math.Max(0, due - GetGame().GetWorld().GetWorldTime()) / 1000)); + } + return string.Format("Search helicopter: delay %1 minutes; %2", m_Schedule.Minutes, state); + } + void Tick(float now) + { + if (!Replication.IsServer() || m_Schedule.Stopped || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active()) return; + m_Airports.Update(); + if (m_Current) + { + m_Current.Update(now); + if (!m_Current.Lost()) return; + m_Reason = m_Current.Status(); m_Current.Release(false); + m_Current = null; m_Schedule.Lost(now); m_PadAttempts = 0; + ERR_HQ.Event("HELICOPTER LOST", Status()); + return; + } + if (m_Mode.Phase() < ERR_Phase.RADIO_SEARCH) return; + if (!m_Schedule.Started) { m_Schedule.Begin(now); ERR_HQ.Event("HELICOPTER TIMER", Status()); } + if (m_Schedule.Due(now, false) && m_PadAttempts == 0) + { m_PadAttempts = 3; m_NextPad = now; m_Schedule.Lost(now); } + if (m_PadAttempts == 0 || now < m_NextPad) return; + m_PadAttempts--; m_NextPad = now + 30000; + // All waves share the normal finite ledger cap. Never create an untracked crew. + if (m_Serial >= 64 || m_Mode.Forces().Count() >= 256) { m_Reason = "round aircraft/force bookkeeping capacity reached"; m_Schedule.Stop(); return; } + vector pad; bool found; + if (!m_Airports.m_Positions.IsEmpty()) + { + int first = Math.RandomInt(0, m_Airports.m_Positions.Count()); + for (int attempt = 0; attempt < m_Airports.m_Positions.Count(); attempt++) + if (ERR_ReconAirports.FindClearPad(m_Airports.m_Positions[(first + attempt) % m_Airports.m_Positions.Count()], pad)) { found = true; break; } + } + if (!found) { m_Reason = "no clear airfield departure pad; bounded retry pending"; ERR_HQ.Event("HELICOPTER PAD", Status()); return; } + m_PadAttempts = 0; m_Serial++; + Vehicle aircraft = Vehicle.Cast(ERR_Actors.Spawn("{C31F210ABE078192}Prefabs/ERR/ReconHelicopter.et", pad, Vector(Math.RandomFloat(0, 360), 0, 0))); + if (!aircraft) { m_Reason = "aircraft creation failed; next attempt after full delay"; return; } + m_Mode.Own(aircraft); + ERR_ReconFlightComponent flight = ERR_ReconFlightComponent.Cast(aircraft.FindComponent(ERR_ReconFlightComponent)); + if (!flight) { m_Reason = "flight component missing; partial aircraft retained"; return; } + m_Current = flight; m_Flights.Insert(flight); m_Schedule.Launched(); + flight.Initialize(m_Mode, pad, m_Serial); + ERR_HQ.Event("HELICOPTER LAUNCH", string.Format("wave=%1 airfield pad=%2", m_Serial, pad)); + } + void Removed(ERR_ReconFlightComponent flight) + { + if (!Replication.IsServer() || m_Schedule.Stopped || !flight || m_Current != flight || !m_Mode || !m_Mode.Active()) return; + m_Current = null; m_PadAttempts = 0; m_Reason = "aircraft removed; replacement delayed"; + m_Schedule.Lost(GetGame().GetWorld().GetWorldTime()); + ERR_HQ.Event("HELICOPTER LOST", Status()); + } + void Stop() + { + m_Schedule.Stop(); m_Reason = "mission stopped"; + foreach (ERR_ReconFlightComponent flight : m_Flights) if (flight) flight.Release(false); + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_AirSupportPolicy.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_AirSupportPolicy.c new file mode 100644 index 0000000..d003992 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_AirSupportPolicy.c @@ -0,0 +1,79 @@ +// Decisions consume detached HQ memory and delivered friendly check-ins only. +class ERR_AirSupportSituation +{ + int BeliefId; + int Samples; + int Spotted; + int GroundFit; + int CommittedFit; + int ConfirmedLosses; + float Age; + float Confidence; + float Radius; + float Speed; + bool MotionKnown; + bool Infantry; + bool AircraftReady; + bool ArtilleryBusy; +} +class ERR_AirSupportPolicy +{ + int Candidate; + float ConsideringSince = -1; + float NextDrop; + string Reason = "waiting for delivered intelligence"; + static string Refusal(ERR_AirSupportSituation s) + { + if (!s || !s.AircraftReady) return "no ready helicopter passenger load"; + if (!ERR_HQMemoryRules.Finite(s.Age) || !ERR_HQMemoryRules.Finite(s.Confidence) || !ERR_HQMemoryRules.Finite(s.Radius) || !ERR_HQMemoryRules.Finite(s.Speed)) return "invalid memory sample"; + if (s.BeliefId <= 0 || s.Samples < 2 || s.Age < 0 || s.Age > 90 || s.Confidence < 0.7 || s.Confidence > 1 || s.Radius < 0 || s.Radius > 160) return "memory is too uncertain; continue reconnaissance"; + if (!s.Infantry || !s.MotionKnown || s.Speed < 0 || s.Speed > 6) return "target movement does not justify an infantry drop"; + int needed = Math.Clamp(s.Spotted * 2, 4, 12); + if (s.CommittedFit >= needed) return "ground forces already committed in sufficient strength"; + if (s.GroundFit + s.CommittedFit >= needed && s.ConfirmedLosses < 2) return "nearby ground forces can handle this contact"; + if (s.ArtilleryBusy) return "mortar order or shells occupy the approach area"; + return ""; + } + bool Consider(ERR_AirSupportSituation s, float now) + { + if (!ERR_HQMemoryRules.Finite(now) || now < 0) return false; + string refusal = Refusal(s); + if (!refusal.IsEmpty()) { Candidate = 0; + ConsideringSince = -1; + Reason = refusal; + return false; + } + if (now < NextDrop) { Reason = "reinforcement cooldown; retain remaining troops"; + return false; + } + if (Candidate != s.BeliefId || ConsideringSince < 0) { Candidate = s.BeliefId; + ConsideringSince = now; + } + float delay = 45000; + if (s.ConfirmedLosses >= 2) delay = 20000; + if (now - ConsideringSince < delay) { Reason = "confirming persistent contact and ground-force shortfall"; + return false; + } + Reason = "persistent infantry contact exceeds available ground response"; + return true; + } + void Committed(float now) { NextDrop = now + 480000; + Candidate = 0; + ConsideringSince = -1; + } + void Retry(float now) { NextDrop = Math.Max(NextDrop, now + 15000); + Candidate = 0; + ConsideringSince = -1; + } +} + +// One immutable authorization; receiver revalidates memory and physical safety. +class ERR_AirDropOrder +{ + int BeliefId; + int BeliefRevision; + int MessageId; + vector Target; + float ExpiresAt; + ref ERR_HQReport Report; +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_AircraftAmmo.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_AircraftAmmo.c new file mode 100644 index 0000000..c314111 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_AircraftAmmo.c @@ -0,0 +1,90 @@ +// Aircraft weapons are physical magazines. Only the landed airport service +// calls Service; the covering orbit consumes normal native weapon ammunition. +class ERR_AircraftAmmo +{ + static void Magazines(IEntity entity, array magazines, bool gunsOnly = false) + { + if (!entity || ChimeraCharacter.Cast(entity)) + return; + BaseWeaponManagerComponent manager = BaseWeaponManagerComponent.Cast(entity.FindComponent(BaseWeaponManagerComponent)); + array weapons = {}; + if (manager) + manager.GetWeapons(weapons); + BaseWeaponComponent mounted = BaseWeaponComponent.Cast(entity.FindComponent(BaseWeaponComponent)); + if (mounted && !weapons.Contains(mounted)) + weapons.Insert(mounted); + foreach (BaseWeaponComponent weapon : weapons) + { + if (!weapon || (gunsOnly && weapon.GetWeaponType() != EWeaponType.WT_MACHINEGUN && weapon.GetWeaponType() != EWeaponType.WT_AUTOCANNON)) + continue; + array muzzles = {}; + weapon.GetMuzzlesList(muzzles); + foreach (BaseMuzzleComponent muzzle : muzzles) + { + BaseMagazineComponent magazine = muzzle.GetMagazine(); + if (magazine && !magazines.Contains(magazine)) + magazines.Insert(magazine); + } + } + IEntity child = entity.GetChildren(); + while (child) + { + Magazines(child, magazines, gunsOnly); + child = child.GetSibling(); + } + } + + static bool HasUsableGun(IEntity aircraft) + { + array magazines = {}; + Magazines(aircraft, magazines, true); + foreach (BaseMagazineComponent magazine : magazines) + { + if (magazine.GetAmmoCount() > magazine.GetMaxAmmoCount() * 0.25) + return true; + } + return false; + } + + // Native rocket pods hold projectile entities rather than magazines. + static int ServicePods(IEntity entity, int available) + { + if (!Replication.IsServer() || !entity || available <= 0 || ChimeraCharacter.Cast(entity)) return 0; + int consumed; + SCR_RocketEjectorMuzzleComponent pod = SCR_RocketEjectorMuzzleComponent.Cast(entity.FindComponent(SCR_RocketEjectorMuzzleComponent)); + if (pod && !pod.GetDefaultRocketPrefab().IsEmpty()) + { + for (int barrel = 0; barrel < pod.GetBarrelsCount() && consumed < available; barrel++) + { + if (!pod.CanReloadBarrel(barrel)) continue; + consumed++; + IEntity rocket = ERR_Actors.Spawn(pod.GetDefaultRocketPrefab(), entity.GetOrigin()); + if (!rocket) continue; + pod.ReloadBarrel(barrel, rocket); + if (pod.GetBarrelProjectile(barrel) != rocket && !rocket.GetParent()) ERR_Actors.DeleteSpawned(rocket); + } + } + IEntity child = entity.GetChildren(); + while (child && consumed < available) + { + consumed += ServicePods(child, available - consumed); + child = child.GetSibling(); + } + return consumed; + } + + static int Service(IEntity aircraft, int available) + { + if (!Replication.IsServer() || available <= 0) return 0; + array magazines = {}; + Magazines(aircraft, magazines); + int used; + foreach (BaseMagazineComponent magazine : magazines) + { + int moved = Math.Min(available - used, Math.Max(0, magazine.GetMaxAmmoCount() - magazine.GetAmmoCount())); + magazine.SetAmmoCount(magazine.GetAmmoCount() + moved); + used += moved; + } + return used; + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_AirfieldSupport.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_AirfieldSupport.c new file mode 100644 index 0000000..d5f2269 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_AirfieldSupport.c @@ -0,0 +1,196 @@ +// One physical airport depot and a separate logistics airframe per round. +class ERR_AirfieldSupport +{ + protected ERR_HQ m_HQ; + protected ERR_GameMode m_Mode; + protected IEntity m_Store, m_FuelTruck; + protected ERR_ReconFlightComponent m_Cargo; + protected ref ERR_HQRoute m_ReturnOrder; + protected ERR_ReconFlightComponent m_ReturnFlight; + protected ref ERR_AirAmmoMission m_Mission; + protected ref array m_RetiredMissions = {}; + protected ref array m_SubmittedCargo = {}; + protected float m_ServiceAt = -1, m_NextTick, m_NextAttempt, m_NextDepot; + protected bool m_Started, m_Stopped, m_Serviced; + protected int m_Rounds = 24000, m_Rockets = 512, m_Services, m_Trips; + protected float m_Fuel = 12000; + protected float m_CargoServiceAt = -1, m_NextCargo; + protected int m_CargoAttempts; + protected vector m_Airport; + protected bool m_CargoSpawned, m_CargoServiced; + protected ref map m_Requests = new map(); + bool BlocksMortar(vector point) { return m_Mission && m_Mission.BlocksMortar(point); + } + ERR_ReconFlightComponent Cargo() { return m_Cargo; + } + IEntity Store() { return m_Store; + } + string Status() { return string.Format("Airport: %1 services; gun rounds=%2 fuel=%3; ammo trips=%4 rockets=%5", m_Services, m_Rounds, Math.Round(m_Fuel), m_Trips, m_Rockets); + } + void ERR_AirfieldSupport(ERR_HQ hq, ERR_GameMode mode) { m_HQ = hq; + m_Mode = mode; + } + protected IEntity Spawn(ResourceName prefab, vector point) + { IEntity entity = ERR_Actors.Spawn(prefab, ERR_Actors.Ground(point)); + if (entity) m_Mode.Own(entity); + return entity; + } + protected void Start(vector airport) + { + m_Started = true; + m_Airport = airport; + m_Store = Spawn("{EBBC859CCD94D96D}Prefabs/Props/EmptyStorages/USSR_AmmoBox_empty.et", airport + "35 0 0"); + m_FuelTruck = Spawn("{16C1F16C9B053801}Prefabs/Vehicles/Wheeled/Ural4320/Ural4320_transport.et", airport + "40 0 12"); + if (m_FuelTruck) ERR_Actors.SetFaction(m_FuelTruck, m_Mode.EnemyFaction()); + Spawn("{BE5D5CFA90224646}Prefabs/Structures/Military/Camps/TentUSSR_01/TentUSSR_01_base.et", airport + "35 0 8"); + } + protected void SpawnCargo(float now) + { + if (m_CargoSpawned || m_CargoAttempts >= 3 || now < m_NextCargo || !DepotAvailable()) return; + m_CargoAttempts++; m_NextCargo = now + 30000; + vector pad; + if (!ERR_ReconAirports.FindClearPad(m_Airport, pad) || vector.DistanceXZ(pad, m_Airport) < 30 || m_Mode.Forces().Count() >= 254) return; + m_CargoSpawned = true; + Vehicle aircraft = Vehicle.Cast(Spawn("{C31F210ABE078192}Prefabs/ERR/ReconHelicopter.et", pad)); + if (!aircraft) return; + m_Cargo = ERR_ReconFlightComponent.Cast(aircraft.FindComponent(ERR_ReconFlightComponent)); + if (m_Cargo) m_Cargo.Initialize(m_Mode, pad, 10001, true); + ERR_HQ.Event("AIRFIELD SUPPORT", "physical service depot and separate ammunition helicopter initialized"); + } + bool DepotAvailable() + { return m_Store && m_FuelTruck && ERR_Actors.Alive(m_FuelTruck) && SCR_FactionManager.SGetFaction(m_FuelTruck) == m_Mode.EnemyFaction(); + } + protected void Service(ERR_ReconFlightComponent flight, float now) + { + if (!flight.AtAirport()) { m_ServiceAt = -1; + m_Serviced = false; + return; + } + if (m_Serviced || !DepotAvailable() || flight.Lost() || vector.DistanceXZ(flight.Aircraft().GetOrigin(), m_Airport) > 500) return; + if (m_ServiceAt < 0) { m_ServiceAt = now; + return; + } + if (now - m_ServiceAt < 60000) return; + m_Serviced = true; + m_Services++; + Replenish(flight); + flight.ReloadPassengers(); + ERR_HQ.Event("AIRFIELD SERVICE", Status() + "; original airframe and crew retained"); + } + protected void Replenish(ERR_ReconFlightComponent flight) + { + m_Rounds -= ERR_AircraftAmmo.Service(flight.Aircraft(), m_Rounds); + m_Rockets -= ERR_AircraftAmmo.ServicePods(flight.Aircraft(), m_Rockets); + FuelManagerComponent fuel = FuelManagerComponent.Cast(flight.Aircraft().FindComponent(FuelManagerComponent)); + if (fuel) + { + array nodes = {}; + fuel.GetFuelNodesList(nodes); + foreach (BaseFuelNode node : nodes) + { float amount = Math.Min(m_Fuel, node.GetMaxFuel() - node.GetFuel()); + if (amount > 0) { node.SetFuel(node.GetFuel() + amount); + m_Fuel -= amount; + } } + } + } + protected bool CargoServiced(float now) + { + if (!m_Cargo || !m_Cargo.AtAirport()) { m_CargoServiceAt = -1; m_CargoServiced = false; return false; } + if (m_Trips == 0 || m_CargoServiced) return true; + if (!DepotAvailable()) return false; + if (m_CargoServiceAt < 0) m_CargoServiceAt = now; + if (now - m_CargoServiceAt < 60000) return false; + Replenish(m_Cargo); m_CargoServiced = true; + return true; + } + void Maintain(float now) + { + if (m_Stopped || !Replication.IsServer() || !m_Mode.Active()) return; + if (m_Cargo) m_Cargo.Update(now); + if (m_Mission) m_Mission.Tick(now); + } + void Tick(float now) + { + if (m_Stopped || !Replication.IsServer() || !m_Mode.Active() || now < m_NextTick) return; + m_NextTick = now + 1000; + ERR_ReconFlightComponent recon = m_HQ.Recon().Current(); + if (!m_Started && now >= m_NextDepot) + { + m_NextDepot = now + 30000; + vector airport; + if (m_HQ.Recon().SupportPad(airport)) Start(airport); + } + if (m_Started) SpawnCargo(now); + bool cargoReady = CargoServiced(now); + if (recon) + { + Service(recon, now); + if (m_Serviced && recon.AtAirport() && (!recon.Assault() || !recon.Assault().Preparing())) recon.RestartFlight(); + if (m_ReturnOrder) + { + int delivered = m_ReturnOrder.Advance(now, m_HQ.Journal()); + if (delivered != 0) { if (delivered == 1 && m_ReturnFlight == recon) recon.ReturnHome(); + m_ReturnOrder = null; + m_ReturnFlight = null; + } + } + else if (recon.Assault() && recon.Assault().NeedsService(now) && (recon.Phase() == ERR_ReconPhase.SEARCH || recon.Phase() == ERR_ReconPhase.SHADOW)) + { m_ReturnOrder = m_HQ.Downlink(recon.Crew(), "AIRCRAFT RELOAD", "cover complete; return to airport for another squad and ammunition", 0); + m_ReturnFlight = recon; + } + } + if (!cargoReady || !m_Cargo || m_Cargo.Lost() || !m_Cargo.AtAirport() || !DepotAvailable() || now < m_NextAttempt || m_Trips >= 12) return; + if (m_Mission && (!m_Mission.Finished() || m_Mission.PendingNative())) return; + m_NextAttempt = now + 10000; + ERR_HQUnit recipient = m_HQ.AirAmmoRecipient(now, m_Requests); + if (!recipient) return; + ERR_HQWatchEntry demand = m_HQ.Watch().Find(recipient.ForceSourceId); + if (!demand) return; + m_Trips++; + m_Requests.Set(demand.SourceId, demand.MessageId); + if (m_Mission) m_RetiredMissions.Insert(m_Mission); + m_Mission = new ERR_AirAmmoMission(m_HQ, m_Mode, m_Cargo, recipient, demand.Position, demand.MessageId, m_Trips, m_SubmittedCargo); + } + void Stop() + { + m_Stopped = true; + m_ReturnOrder = null; + if (m_Mission) m_Mission.Stop(); + if (m_Cargo) m_Cargo.Release(false); + } +} +modded class ERR_HQ +{ + bool ReserveAirAmmo(ERR_HQUnit unit, int token) + { + if (!Replication.IsServer() || !MissionActive() || !unit || token <= 0 || !unit.Eligible(m_Mode) || unit.HomeGuard || !unit.Radio || unit.PendingOrder || unit.Downlink || Fighting(unit)) return false; + // Keep its delivered search task until the replacement order actually arrives. + unit.LogisticsReservation = token; + return true; + } + ERR_HQUnit AirAmmoRecipient(float now, map served) + { + foreach (ERR_HQUnit unit : m_Units) + { + ERR_HQWatchEntry request = m_Watch.Find(unit.ForceSourceId); + if (!unit.Eligible(m_Mode) || unit.ReservedForSupport || unit.HomeGuard || unit.LogisticsReservation > 0 || unit.PendingOrder || unit.Downlink || Fighting(unit)) continue; + if (!ERR_InfantryResupplyRules.Need(request, now) || request.MessageId <= served.Get(request.SourceId)) continue; + return unit; + } + return null; + } + ERR_HQRoute SupportUplink(SCR_AIGroup group, string detail) + { + foreach (ERR_HQUnit unit : m_Units) + { + if (unit.Group != group || !unit.Radio || unit.Uplink || OriginOccupied(unit.Radio, unit)) continue; + ERR_HQRoute route = m_Network.Route(unit.Radio); + if (!route) return null; + route.MessageId = m_Journal.Message(); + route.Payload = detail; + m_Journal.Write("SUPPORT REPORT QUEUED", detail, route.MessageId); + return route; + } + return null; + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_AssaultParachute.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_AssaultParachute.c new file mode 100644 index 0000000..fa6ace1 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_AssaultParachute.c @@ -0,0 +1,321 @@ +class ERR_AssaultParachuteClass : ParachuteDeployedEntityClass +{ +} + +// Reuse the framework's canopy, cargo compartment and movement replication. +// AI owns no PlayerController: flight, transfer and landing run on the server. +class ERR_AssaultParachute : ParachuteDeployedEntity +{ + protected ref ERR_AirAssaultTeam m_ERR_Team; + protected IEntity m_ERR_Soldier; + protected IEntity m_ERR_DepartureVehicle; + protected BaseCompartmentSlot m_ERR_DepartureSeat; + protected vector m_vERR_Landing; + protected float m_fERR_TransferDeadline; + protected float m_fERR_NextTransfer; + protected float m_fERR_NextExit; + protected float m_fERR_FlightDeadline; + protected float m_fERR_NextFlightLog; + protected vector m_vERR_LastProgress; + protected float m_fERR_ProgressAt; + protected float m_fERR_EscapeUntil; + protected int m_iERR_EscapeAttempts; + protected vector m_vERR_Escape; + protected bool m_bERR_Started; + protected bool m_bERR_VerticalDescent; + protected bool m_bERR_Transferring; + protected bool m_bERR_DidBoard; + protected bool m_bERR_Finished; + protected bool m_bERR_Broken; + protected bool m_bERR_Cleanup; + + override void EOnInit(IEntity owner) + { + super.EOnInit(owner); + if (SCR_Global.IsEditMode()) + return; + SetFlags(EntityFlags.ACTIVE); + // The framework's generic destruction path deletes its vehicle after + // 100ms. AI must first confirm the native compartment is empty. + if (m_DamageManager) + { + m_DamageManager.GetOnDamage().Remove(OnParachuteDamaged); + m_DamageManager.GetOnDamage().Insert(ERR_OnDamage); + } + } + + bool BeginJump(IEntity soldier, ERR_AirAssaultTeam team, vector landing) + { + if (!Replication.IsServer()) + return false; + m_bERR_Started = true; + m_vERR_LastProgress = GetOrigin(); + m_fERR_ProgressAt = GetGame().GetWorld().GetWorldTime(); + m_ERR_Soldier = soldier; + m_ERR_Team = team; + m_vERR_Landing = landing; + CompartmentAccessComponent access; + if (soldier) + access = CompartmentAccessComponent.Cast(soldier.FindComponent(CompartmentAccessComponent)); + // Physics handles are local-only; reacquire them instead of retaining + // the framework's cached pointer across engine callbacks. + Physics physics = GetPhysics(); + if (!access || !m_Compartment || m_Compartment.IsOccupied() || !physics || !physics.IsDynamic()) + { + m_bERR_Finished = true; + Print("Reintroduced [air assault]: invalid canopy seat/physics; passenger retained", LogLevel.WARNING); + return false; + } + physics.ChangeSimulationState(SimulationState.SIMULATION); + physics.SetActive(ActiveState.ALWAYS_ACTIVE); + m_ERR_DepartureVehicle = CompartmentAccessComponent.GetVehicleIn(soldier); + m_ERR_DepartureSeat = access.GetCompartment(); + m_fERR_TransferDeadline = GetGame().GetWorld().GetWorldTime() + 2000; + m_fERR_FlightDeadline = m_fERR_TransferDeadline + 120000; + m_bERR_Transferring = true; + ERR_AirAssaultLoad.SetLock(soldier, false); + vector exitTransform[4]; + GetWorldTransform(exitTransform); + if (!access.GetOutVehicle_NoDoor(exitTransform, false, true, true)) + { + m_bERR_Transferring = false; + m_bERR_Finished = true; + ERR_AirAssaultLoad.SetLock(soldier, true); + Print("Reintroduced [air assault]: helicopter exit rejected; passenger retained", LogLevel.WARNING); + return false; + } + return true; + } + + protected void ERR_OnDamage(BaseDamageContext damageContext) + { + if (Replication.IsServer() && m_DamageManager && m_DamageManager.IsDestroyed()) + DestroyParachute(); + } + + override void DestroyParachute() + { + if (!Replication.IsServer()) + return; + m_bERR_Broken = true; + // Leave the damaged canopy/occupant to physics until native ejection + // succeeds. Never delete an occupied replicated hierarchy. + AskServerExit(); + } + + protected void ERR_Transfer(float now) + { + CompartmentAccessComponent access = CompartmentAccessComponent.Cast(m_ERR_Soldier.FindComponent(CompartmentAccessComponent)); + if (m_Compartment.GetOccupant() == m_ERR_Soldier) + { + m_bERR_Transferring = false; + m_bERR_DidBoard = true; + m_ERR_DepartureVehicle = null; + m_ERR_DepartureSeat = null; + ERR_AirAssaultLoad.SetLock(m_ERR_Soldier, true); + ERR_HQ.Event("PARACHUTE BOARDED", string.Format("canopy=%1 soldier=%2 position=%3", this, m_ERR_Soldier, GetOrigin())); + return; + } + if (now >= m_fERR_TransferDeadline || !access || !ERR_Actors.CanFight(m_ERR_Soldier)) + { + // If the native transfer fails, try to return the same soldier to + // their original seat. Do not spawn a replacement or delete them. + if (access && !access.IsInCompartment() && m_ERR_DepartureVehicle && m_ERR_DepartureSeat && !m_ERR_DepartureSeat.IsOccupied()) + access.GetInVehicle(m_ERR_DepartureVehicle, m_ERR_DepartureSeat, true, 0, ECloseDoorAfterActions.INVALID, true); + m_bERR_Transferring = false; + m_bERR_Finished = true; + Print("Reintroduced [air assault]: canopy transfer failed; attempted return to original seat", LogLevel.WARNING); + return; + } + if (now >= m_fERR_NextTransfer && !access.IsInCompartment()) + { + m_fERR_NextTransfer = now + 100; + access.GetInVehicle(this, m_Compartment, true, 0, ECloseDoorAfterActions.INVALID, true); + } + } + + override void AskServerExit() + { + if (!Replication.IsServer() || m_bERR_Transferring || !m_Compartment || !m_Compartment.IsOccupied()) + return; + float now = GetGame().GetWorld().GetWorldTime(); + if (now < m_fERR_NextExit) + return; + vector position = GetOrigin(); + float ground = SCR_TerrainHelper.GetTerrainY(position); + if (!m_bERR_Broken && position[1] - ground > 2) + return; + m_fERR_NextExit = now + 1000; + IEntity occupant = m_Compartment.GetOccupant(); + // A GM/player takeover must never be controlled by this AI controller. + if (occupant != m_ERR_Soldier || ERR_Actors.Player(occupant)) + return; + CompartmentAccessComponent access = CompartmentAccessComponent.Cast(occupant.FindComponent(CompartmentAccessComponent)); + if (!access) + return; + vector exitTransform[4]; + GetWorldTransform(exitTransform); + if (!m_bERR_Broken) + exitTransform[3] = Vector(position[0], ground + 0.2, position[2]); + ERR_AirAssaultLoad.SetLock(occupant, false); + access.GetOutVehicle_NoDoor(exitTransform, m_bERR_Broken, true, true); + } + + protected void ERR_Finish() + { + if (m_Compartment && m_Compartment.IsOccupied()) + return; + if (m_bERR_Cleanup) + return; + m_bERR_Finished = true; + ERR_HQ.Event("PARACHUTE FINISHED", string.Format("canopy=%1 soldier=%2 position=%3 boarded=%4 damaged=%5", this, m_ERR_Soldier, GetOrigin(), m_bERR_DidBoard, m_bERR_Broken)); + if (m_ERR_Soldier && CompartmentAccessComponent.GetVehicleIn(m_ERR_Soldier) == null) + { + ERR_AirAssaultLoad.SetLock(m_ERR_Soldier, false); + AIControlComponent control = AIControlComponent.Cast(m_ERR_Soldier.FindComponent(AIControlComponent)); + AIAgent agent; if (control) agent = control.GetControlAIAgent(); + if (agent) + { + agent.SetPermanentLOD(-1); + agent.ActivateAI(); + } + vector position = GetOrigin(); + if (m_bERR_DidBoard && !m_bERR_Broken && position[1] - SCR_TerrainHelper.GetTerrainY(position) < 3 && m_ERR_Team && ERR_Actors.CanFight(m_ERR_Soldier)) + m_ERR_Team.Landed(); + } + SCR_GarbageSystem garbage = SCR_GarbageSystem.GetByEntityWorld(this); + if (garbage) + m_bERR_Cleanup = garbage.Insert(this); + if (m_bERR_Cleanup) + { + Physics physics = GetPhysics(); + if (physics) + physics.SetActive(ActiveState.ACTIVE); + m_ERR_Soldier = null; + m_ERR_Team = null; + ClearEventMask(EntityEvent.FRAME | EntityEvent.SIMULATE); + } + } + + // Transfer, landing and replication must run even if physics was asleep. + // Do not call the parent's frame handler: it installs human input controls. + override void EOnFrame(IEntity owner, float timeSlice) + { + if (!Replication.IsServer() || !m_bERR_Started || !m_Compartment) + return; + float now = GetGame().GetWorld().GetWorldTime(); + if (m_bERR_Transferring && !m_ERR_Soldier) + { + m_bERR_Transferring = false; + m_bERR_Finished = true; + } + if (m_bERR_Transferring && m_ERR_Soldier) + ERR_Transfer(now); + // A native get-in completion can arrive after the timeout tick. + // Resume descent for any late occupant instead of freezing them aloft. + if (m_bERR_Finished && m_Compartment.IsOccupied()) + { + m_bERR_Finished = false; + m_bERR_DidBoard = true; + } + if (m_bERR_Finished || (!m_bERR_Transferring && !m_Compartment.IsOccupied())) + { + ERR_Finish(); + return; + } + if (m_Compartment.IsOccupied() && (m_Compartment.GetOccupant() != m_ERR_Soldier || ERR_Actors.Player(m_Compartment.GetOccupant()))) + return; + Physics physics = GetPhysics(); + if (!physics) + return; + if (physics.GetSimulationState() != SimulationState.SIMULATION) + physics.ChangeSimulationState(SimulationState.SIMULATION); + physics.SetActive(ActiveState.ALWAYS_ACTIVE); + if (m_fERR_FlightDeadline > 0 && now >= m_fERR_FlightDeadline) + { + // A guidance timeout is not canopy damage. Keep the soldier seated + // and descend vertically instead of cutting them loose at altitude. + m_fERR_FlightDeadline = 0; + m_bERR_VerticalDescent = true; + Print("Reintroduced [air assault]: canopy guidance expired; continuing vertical descent", LogLevel.WARNING); + } + vector progressPosition = GetOrigin(); + if (m_bERR_Transferring || vector.Distance(progressPosition, m_vERR_LastProgress) > 2) + { + m_vERR_LastProgress = progressPosition; + m_fERR_ProgressAt = now; + } + else if (!m_bERR_Broken && now - m_fERR_ProgressAt > 5000 && progressPosition[1] - SCR_TerrainHelper.GetTerrainY(progressPosition) > 2) + { + // A roof/tree collision must not eject a soldier at terrain altitude. + // Try a short lateral escape using collision-aware physics, retaining + // the occupied canopy. No teleport, forced damage, or deletion. + int escapeSide = m_iERR_EscapeAttempts++ % 4; + float angle = escapeSide * Math.PI / 2; + m_vERR_Escape = Vector(Math.Cos(angle) * 3, 0, Math.Sin(angle) * 3); + m_fERR_EscapeUntil = now + 5000; + m_fERR_ProgressAt = now; + ERR_HQ.Event("PARACHUTE OBSTRUCTED", string.Format("canopy=%1 soldier=%2 position=%3 lateral attempt=%4", this, m_ERR_Soldier, progressPosition, m_iERR_EscapeAttempts)); + } + AskServerExit(); + if (now >= m_fERR_NextFlightLog) + { + m_fERR_NextFlightLog = now + 10000; + vector position = GetOrigin(); + ERR_HQ.Event("PARACHUTE STATUS", string.Format("canopy=%1 soldier=%2 position=%3 height=%4 velocity=%5 occupied=%6 transferring=%7 damaged=%8", this, m_ERR_Soldier, position, Math.Round(position[1] - SCR_TerrainHelper.GetTerrainY(position)), physics.GetVelocity(), m_Compartment.IsOccupied(), m_bERR_Transferring, m_bERR_Broken)); + } + // Preserve the framework's proxy movement protocol, at five updates + // per second for at most eight canopies per aircraft. + m_NetAccTime += timeSlice; + if (m_NetAccTime >= 0.2) + { + m_NetAccTime = 0; + vector transform[4]; + GetWorldTransform(transform); + Rpc(RpcDo_SyncMovement, transform, physics.GetVelocity(), physics.GetAngularVelocity(), 0.0, 0.0); + } + } + + override void EOnSimulate(IEntity owner, float timeSlice) + { + if (!Replication.IsServer() || !m_bERR_Started || m_bERR_Finished || m_bERR_Broken || !m_Compartment) + return; + if (m_Compartment.IsOccupied() && (m_Compartment.GetOccupant() != m_ERR_Soldier || ERR_Actors.Player(m_Compartment.GetOccupant()))) + return; + if (!m_bERR_Transferring && !m_Compartment.IsOccupied()) + return; + Physics physics = GetPhysics(); + if (!physics) + return; + vector position = GetOrigin(); + vector toward = m_vERR_Landing - position; + toward[1] = 0; + float height = position[1] - SCR_TerrainHelper.GetTerrainY(position); + vector velocity; + if (!m_bERR_VerticalDescent && toward.LengthSq() > 0.01) + velocity = toward.Normalized() * Math.Min(7, toward.Length() * 0.4); + if (GetGame().GetWorld().GetWorldTime() < m_fERR_EscapeUntil) velocity = m_vERR_Escape; + velocity[1] = -5; + if (height < 10) + velocity[1] = -2; + if (m_bERR_Transferring) + velocity = vector.Zero; + // Let physics move the entire occupied hierarchy without resetting its + // transform between fixed steps. + physics.SetAngularVelocity(vector.Zero); + physics.SetVelocity(velocity); + } + + void ~ERR_AssaultParachute() + { + if (m_DamageManager) + m_DamageManager.GetOnDamage().Remove(ERR_OnDamage); + } + + override void EOnDeactivate(IEntity owner) + { + if (m_DamageManager) + m_DamageManager.GetOnDamage().Remove(ERR_OnDamage); + super.EOnDeactivate(owner); + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_BodyRecovery.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_BodyRecovery.c new file mode 100644 index 0000000..612b0f0 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_BodyRecovery.c @@ -0,0 +1,300 @@ +class ERR_BodyRecoveryJob +{ + IEntity Body; + vector Position; + SCR_AIGroup Team; + AIWaypoint Waypoint; + ref ERR_PrisonPatrol Radio = new ERR_PrisonPatrol(); + ref array Missing = {}; + ref ERR_HQRoute Order, Report; + float Started, SearchAt = -1, RetryAt; + int State; + bool Delivered; + float NextDiagnostic; + // 0 native team, 1 radio order, 2 walk/search, 3 evidence uplink, 4 done +} +class ERR_BodyRecovery +{ + protected ERR_HQ m_HQ; + protected ERR_GameMode m_Mode; + protected ref array m_Jobs = {}; + protected ref set m_ReportedBodies = new set(); + protected int m_Waves; + protected float m_NextTick; + protected bool m_Stopped; + void ERR_BodyRecovery(ERR_HQ hq, ERR_GameMode mode) { m_HQ = hq; + m_Mode = mode; + } + string Status() { return string.Format("Recovery: %1/%2 reports searched; teams dispatched=%3/8", Completed(), m_Jobs.Count(), m_Waves); + } + protected int Completed() + { + int count; + foreach (ERR_BodyRecoveryJob job : m_Jobs) { if (job.Delivered) count++; + } + return count; + } + void ReportBody(ERR_HQReport report) + { + if (m_Stopped || !report || !report.Target || m_ReportedBodies.Contains(report.Target) || m_Jobs.Count() >= 128 || !m_Mode.Evidence().Find(report.Target)) return; + ERR_BodyRecoveryJob job = new ERR_BodyRecoveryJob(); + job.Body = report.Target; + job.Position = report.Position; + m_ReportedBodies.Insert(report.Target); + m_Jobs.Insert(job); + ERR_HQ.Event("BODY RECOVERY REQUEST", "delivered body sighting; search ordered at reported position " + job.Position.ToString()); + } + protected bool SpawnTeam(ERR_BodyRecoveryJob job, float now) + { + foreach (ERR_BodyRecoveryJob previous : m_Jobs) + { + if (previous == job || previous.State != 4 || !previous.Team || previous.Team.GetPlayerCount(true) > 0 || previous.Team.GetFaction() != m_Mode.EnemyFaction() || m_Mode.ForceLiving(previous.Team) == 0) continue; + job.Team = previous.Team; + previous.Team = null; + job.State = 1; + job.Started = now; + m_HQ.ReserveGroup(job.Team); + return true; + } + if (m_Waves >= 8 || m_Mode.Forces().Count() >= 254) return false; + array locations = {}; + m_Mode.Locations(locations); + ERR_LocationComponent base; + float best = 1e20; + foreach (ERR_LocationComponent location : locations) + { + if (!location || (location.Kind() != ERR_LocationKind.RADIO && location.Kind() != ERR_LocationKind.DEPOT)) continue; + float distance = vector.DistanceXZ(location.Position(), job.Position); + if (distance < best) { base = location; + best = distance; + } + } + if (!base) return false; + vector point = ERR_Actors.Ground(base.Position() + "20 0 20"); + if (!ERR_HQRoadIntercept.Standable(point)) return false; + // Only reject visible pop-in; these live player positions never choose a search destination. + array players = {}; + ERR_Actors.Players(players); + foreach (IEntity player : players) if (vector.DistanceXZ(player.GetOrigin(), point) < 300) return false; + m_Waves++; + job.Team = SCR_AIGroup.Cast(ERR_Actors.Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", point)); + job.Started = now; + if (!job.Team) { job.State = 4; + return false; + } + m_Mode.Own(job.Team); + if (!ERR_PrisonPatrol.Configure(job.Team, m_Mode.FactionSelection().Choice("enemy").Group, m_Mode.EnemyFaction())) { job.State = 4; + return false; + } + ERR_HQ.Event("BODY RECOVERY TEAM", "special search team departing friendly site; bodies remain retained"); + return true; + } + protected void RemoveWaypoint(ERR_BodyRecoveryJob job) + { + if (job.Waypoint) { if (job.Team) job.Team.RemoveWaypoint(job.Waypoint); + ERR_Actors.DeleteSpawned(job.Waypoint); + job.Waypoint = null; + } + } + protected void Finish(ERR_BodyRecoveryJob job, bool delivered) + { + RemoveWaypoint(job); + job.State = 4; + job.Delivered = delivered; + job.Order = null; + job.Report = null; + if (delivered) { m_HQ.CompromisedSites(job.Missing, job.Position); + m_Mode.Evidence().Delivered(job.Body); + } + if (!delivered) m_HQ.ReleaseRecovery(job.Team); + } + void Tick(float now) + { + if (m_Stopped || !Replication.IsServer() || !m_Mode.Active() || now < m_NextTick) return; + m_NextTick = now + 1000; + m_HQ.SecureKnownSites(now); + int active; + foreach (ERR_BodyRecoveryJob existing : m_Jobs) if (existing.Team && existing.State < 4) active++; + foreach (ERR_BodyRecoveryJob job : m_Jobs) + { + if (job.State == 4 || now < job.RetryAt) continue; + if (!job.Body) { Finish(job, false); + continue; + } + if (!job.Team) + { + if (active >= 2) continue; + job.RetryAt = now + 30000; + if (SpawnTeam(job, now)) active++; + continue; + } + if (job.Team.GetPlayerCount(true) > 0 || job.Team.GetFaction() != m_Mode.EnemyFaction() || now - job.Started > 1800000) { Finish(job, false); + continue; + } + job.Team.ActivateAI(); + array activeAgents = {}; + job.Team.GetAgents(activeAgents); + foreach (AIAgent activeAgent : activeAgents) + { + if (!activeAgent) continue; + activeAgent.SetPermanentLOD(0); + activeAgent.ActivateAI(); + } + if (job.State > 0 && m_Mode.ForceLiving(job.Team) == 0) { Finish(job, false); continue; } + if (job.State == 0) + { + if (now - job.Started > 60000) { Finish(job, false); + continue; + } + if (!job.Team.IsExpandComplete() || job.Team.GetAgentsCount() == 0) continue; + ERR_PrepareResult radio = job.Radio.Radio(m_Mode, job.Team, job.Started + 60000); + if (radio != ERR_PrepareResult.READY) continue; + m_Mode.RegisterGroup(job.Team); + ERR_ForceRecord recoveryRecord = m_Mode.Forces().FindGroup(job.Team); + if (recoveryRecord) ERR_HQ.Event("SPAWN PURPOSE", "force=" + recoveryRecord.SourceId + "; purpose=body inspection; position=" + job.Team.GetOrigin().ToString() + "; reported body=" + job.Position.ToString()); + m_HQ.ReserveGroup(job.Team); + job.Team.ActivateAI(); + job.State = 1; + } + if (job.State == 1) + { + if (!job.Order) { job.Order = m_HQ.Downlink(job.Team, "BODY SEARCH", "inspect reported military body " + job.Position.ToString(), 0); + if (!job.Order) continue; + } + int order = job.Order.Advance(now, m_HQ.Journal()); + if (order < 0) { job.Order = null; + job.RetryAt = now + 15000; + continue; + } + if (order == 0) continue; + job.Order = null; + job.Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), job.Position)); + if (!job.Waypoint) { Finish(job, false); + continue; + } + job.Waypoint.SetCompletionRadius(1.5); + job.Team.AddWaypointAt(job.Waypoint, 0); + job.State = 2; + } + if (job.State == 2) + { + SCR_AIGroupUtilityComponent utility = job.Team.GetGroupUtilityComponent(); + if (utility && utility.m_Perception && !utility.m_Perception.m_aTargets.IsEmpty()) { job.SearchAt = -1; + continue; + } + array agents = {}; + job.Team.GetAgents(agents); + IEntity searcher; + foreach (AIAgent agent : agents) + if (agent && ERR_Actors.CanFight(agent.GetControlledEntity()) && !ERR_Actors.Player(agent.GetControlledEntity()) && vector.Distance(agent.GetControlledEntity().GetOrigin(), job.Body.GetOrigin()) <= 3) searcher = agent.GetControlledEntity(); + if (now >= job.NextDiagnostic) + { + job.NextDiagnostic = now + 30000; + float nearest = 1e20; + foreach (AIAgent nearbyAgent : agents) + if (nearbyAgent && nearbyAgent.GetControlledEntity()) nearest = Math.Min(nearest, vector.Distance(nearbyAgent.GetControlledEntity().GetOrigin(), job.Body.GetOrigin())); + ERR_HQ.Event("BODY SEARCH PROGRESS", "nearest searcher=" + nearest.ToString() + "m; physical inspection required"); + } + if (!searcher) { job.SearchAt = -1; + continue; + } + if (job.SearchAt < 0) job.SearchAt = now; + if (now - job.SearchAt < 10000) continue; + if (!m_Mode.Evidence().Inspect(job.Body, searcher, job.Missing)) continue; + RemoveWaypoint(job); + job.State = 3; + ERR_HQ.Event("BODY SEARCH COMPLETE", "local inspection complete; missing document report awaits radio"); + } + if (job.State == 3) + { + if (!job.Report) { job.Report = m_HQ.SupportUplink(job.Team, "searched body; missing documents=" + job.Missing.Count().ToString()); + if (!job.Report) continue; + } + int receipt = job.Report.Advance(now, m_HQ.Journal()); + if (receipt < 0) { job.Report = null; + job.RetryAt = now + 15000; + } + else if (receipt > 0) Finish(job, true); + } + } + } + void Stop() { m_Stopped = true; + foreach (ERR_BodyRecoveryJob job : m_Jobs) { RemoveWaypoint(job); + job.Order = null; + job.Report = null; + } } +} +modded class ERR_HQ +{ + protected ref map m_Compromised = new map(); + protected float m_NextSecurity; + void SecureKnownSites(float now) + { + if (now < m_NextSecurity) return; + m_NextSecurity = now + 30000; + array sites = {}; + foreach (string id, float until : m_Compromised) if (now < until) sites.Insert(id); + TrySecureSites(sites); + } + void CompromisedSites(array sites, vector bodyPosition) + { + foreach (string id : sites) + { + if (m_Compromised.Count() >= 64 && !m_Compromised.Contains(id)) continue; + m_Compromised.Set(id, GetGame().GetWorld().GetWorldTime() + 1800000); + } + m_NextSecurity = 0; + SecureKnownSites(GetGame().GetWorld().GetWorldTime()); + } + void ReleaseRecovery(SCR_AIGroup group) + { foreach (ERR_HQUnit unit : m_Units) if (unit.Group == group) unit.ReservedForSupport = false; + } + protected void TrySecureSites(array sites) + { + // A missing issued document means the site MAY be compromised. Never read + // player discoveries and never turn this hypothesis into an artillery target. + foreach (string id : sites) + { + array locations = {}; + m_Mode.Locations(locations); + ERR_LocationComponent target; + foreach (ERR_LocationComponent location : locations) if (location.Id() == id) target = location; + if (!target) continue; + bool secured; + foreach (ERR_HQUnit assigned : m_Units) + { + ERR_HQTask assignedTask = assigned.PlannedTask(); + if (assignedTask && assignedTask.SecuritySite == id && GetGame().GetWorld().GetWorldTime() < assignedTask.ExpiresAt) secured = true; + } + if (secured) continue; + ERR_HQ.Event("INTEL COMPROMISE", "missing issued document concerns " + id + "; defensive reinforcement requested"); + ERR_HQUnit best; + float distance = 1e20; + foreach (ERR_HQUnit unit : m_Units) + { + ERR_HQWatchEntry status = m_Watch.Find(unit.ForceSourceId); + if (!unit.Eligible(m_Mode) || unit.HomeGuard || unit.Assignment || unit.PendingOrder || unit.Downlink || Fighting(unit) || !ERR_InfantryResupplyRules.Fresh(status, GetGame().GetWorld().GetWorldTime())) continue; + float candidate = vector.DistanceXZ(status.Position, target.Position()); + if (candidate < distance && candidate < 2500) { best = unit; + distance = candidate; + } + } + if (!best) continue; + ERR_HQRoute route = Downlink(best.Group, "SITE SECURITY", "secure potentially compromised site " + id, 0); + if (!route) continue; + ERR_HQTask task = new ERR_HQTask(); + task.SecuritySite = id; + task.Role = ERR_HQPursuitRole.SEARCH; + task.Destination = target.Position(); + task.QueuedAt = GetGame().GetWorld().GetWorldTime(); + task.ExpiresAt = task.QueuedAt + 600000; + ERR_HQReport lineage = new ERR_HQReport(); + lineage.Position = target.Position(); + lineage.ObservedAt = task.QueuedAt; + lineage.Distress = true; + best.PendingTask = task; + best.PendingOrder = lineage; + best.Downlink = route; + } + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyBudget.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyBudget.c index 4d35484..327c946 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyBudget.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyBudget.c @@ -13,10 +13,14 @@ class ERR_ConvoyPool { protected ERR_GameMode m_Mode; protected int m_Limit; + protected bool m_Supply; + protected int m_BodyLimit = 60; + protected int m_GroupLimit = 128; protected ref array m_Receipts = {}; - void ERR_ConvoyPool(ERR_GameMode mode, int maxConvoys) + void ERR_ConvoyPool(ERR_GameMode mode, int maxConvoys, bool supply = false) { - m_Mode = mode; m_Limit = Math.Clamp(maxConvoys, 0, 8); + m_Mode = mode; m_Supply = supply; m_Limit = Math.Clamp(maxConvoys, 0, 20); + if (supply) { m_BodyLimit = 60 + 25 * m_Limit; m_GroupLimit = 256; } } static int CrewCount(int vehicle) { @@ -26,6 +30,22 @@ class ERR_ConvoyPool if (vehicle == 0 || vehicle == 3 || vehicle == 5) return 2; return 0; } + int PlannedCount(int index) + { + if (m_Supply && index == 2) return 1; + if (m_Supply && (index == 6 || index == 7)) return 7; + return CrewCount(index); + } + int PlannedBodies() + { + if (m_Supply) return 25; + return 19; + } + int PlannedGroups() + { + if (m_Supply) return 8; + return 6; + } protected bool Current() { return Replication.IsServer() && m_Mode && m_Mode == ERR_GameMode.Get() && m_Mode.Active() && m_Mode.Forces(); @@ -57,25 +77,26 @@ class ERR_ConvoyPool { if (!Current() || sourceId.IsEmpty() || sourceId.Length() > 64) return null; foreach (ERR_ConvoyReceipt known : m_Receipts) if (known.SourceId == sourceId) return known; - if (m_Receipts.Count() >= m_Limit || CommittedBodies() + 19 > 60 || CommittedGroups() + 6 > 128) return null; + if (m_Receipts.Count() >= m_Limit || CommittedBodies() + PlannedBodies() > m_BodyLimit || CommittedGroups() + PlannedGroups() > m_GroupLimit) return null; ERR_ConvoyReceipt receipt = new ERR_ConvoyReceipt(); receipt.Id = m_Receipts.Count() + 1; receipt.SourceId = sourceId; + if (m_Supply) { receipt.OutstandingBodies = 25; receipt.OutstandingGroups = 8; receipt.Records.Insert(-1); receipt.Records.Insert(-1); } m_Receipts.Insert(receipt); return receipt; } bool Consume(ERR_ConvoyReceipt receipt) { - if (!Current() || !Owns(receipt) || receipt.Consumed || CommittedBodies() > 60 || CommittedGroups() > 128) return false; + if (!Current() || !Owns(receipt) || receipt.Consumed || CommittedBodies() > m_BodyLimit || CommittedGroups() > m_GroupLimit) return false; receipt.Consumed = true; return true; } bool BindRecord(ERR_ConvoyReceipt receipt, int vehicle, ERR_ForceRecord record) { - if (!Current() || !Owns(receipt) || !receipt.Consumed || vehicle < 0 || vehicle >= 6 || !record) return false; + if (!Current() || !Owns(receipt) || !receipt.Consumed || vehicle < 0 || vehicle >= PlannedGroups() || !record) return false; if (receipt.Records[vehicle] >= 0) return receipt.Records[vehicle] == record.Id; if (m_Mode.Forces().Find(record.Id) != record || record.Kind != ERR_ForceKind.CONVOY || !record.Group || !record.Physical || record.Eliminated) return false; - if (record.SourceId != receipt.SourceId + "-vehicle-" + vehicle.ToString() || record.InitialStrength != CrewCount(vehicle) || record.Strength != record.InitialStrength) return false; + if (record.SourceId != receipt.SourceId + "-vehicle-" + vehicle.ToString() || record.InitialStrength != PlannedCount(vehicle) || record.Strength != record.InitialStrength) return false; receipt.Records[vehicle] = record.Id; - receipt.OutstandingBodies -= CrewCount(vehicle); receipt.OutstandingGroups--; + receipt.OutstandingBodies -= PlannedCount(vehicle); receipt.OutstandingGroups--; return true; } // There is intentionally no refund/reset method. A consumed startup attempt diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyCombat.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyCombat.c index d1456c6..e92333e 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyCombat.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyCombat.c @@ -8,6 +8,7 @@ class ERR_ConvoyCombatOrder string SourceId; SCR_AIGroup Group; bool Hold; + vector Position; float ObservedAt = -1; float IssuedAt = -1; float ExpiresAt = -1; diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c index fecbcbf..24a623a 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c @@ -11,7 +11,11 @@ class ERR_ConvoyResources return ""; } static ResourceName GroupPrefab() { return "{7A968AB297475FA7}Prefabs/ERR/ConvoyCrew.et"; } - static ResourceName Soldier() { return "{DCB41B3746FDD1BE}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_Rifleman.et"; } + static ResourceName Soldier() + { + ERR_GameMode mode = ERR_GameMode.Get(); + if (mode && mode.FactionSelection() && mode.FactionSelection().Ready() && mode.FactionSelection().Active("enemy") != "ussr") return mode.FactionSelection().Soldier(); + return "{DCB41B3746FDD1BE}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_Rifleman.et"; } static ResourceName Rifle() { return "{FA5C25BF66A53DCF}Prefabs/Weapons/Rifles/AK74/Rifle_AK74.et"; } static ResourceName Magazine() { return "{BBB50A815A2F916B}Prefabs/Weapons/Magazines/Magazine_545x39_AK_30rnd_Ball.et"; } static bool Available() @@ -225,6 +229,18 @@ class ERR_ConvoyService { return !m_Stopped && Replication.IsServer() && m_Mode && m_Mode == ERR_GameMode.Get() && m_Mode.Active() && m_Pool; } + protected ERR_ConvoyTask NewTask() { return new ERR_ConvoyTask(); } + protected ERR_ConvoyVehicle NewVehicle() { return new ERR_ConvoyVehicle(); } + ERR_ConvoyTask At(int index) + { + if (index < 0 || index >= m_Tasks.Count()) return null; + return m_Tasks[index]; + } + ERR_ConvoyTask Find(string source) + { + foreach (ERR_ConvoyTask task : m_Tasks) if (task.Receipt && task.Receipt.SourceId == source) return task; + return null; + } protected bool FillCrew(ERR_ConvoyVehicle unit) { array all = {}; @@ -259,14 +275,13 @@ class ERR_ConvoyService ERR_ConvoyTask Dispatch(ERR_ConvoyReceipt receipt, array positions, array angles, ERR_ForceRoute route, float now) { if (!Current() || !ERR_ForceRules.Finite(now) || now < 0 || !ERR_ConvoyRules.Plan(positions, angles, route) || !ERR_ConvoyResources.Available() || !ERR_ConvoyRules.GroundClear(positions, angles)) return null; - // The selected stock crew and group are USSR. Other factions require a new - // reviewed catalog; never silently rewrite affiliations on incompatible kit. - if (!m_Mode.EnemyFaction() || m_Mode.EnemyFaction().GetFactionKey() != "USSR" || !m_Pool.Consume(receipt)) return null; - ERR_ConvoyTask task = new ERR_ConvoyTask(); task.Receipt = receipt; task.Route = route; task.Deadline = now + 30000; + // The server role selection owns the memberless group affiliation. + if (!m_Mode.EnemyFaction() || !m_Pool.Consume(receipt)) return null; + ERR_ConvoyTask task = NewTask(); task.Receipt = receipt; task.Route = route; task.Deadline = now + 30000; m_Tasks.Insert(task); for (int index = 0; index < 6; index++) { - ERR_ConvoyVehicle unit = new ERR_ConvoyVehicle(); unit.Index = index; task.Vehicles.Insert(unit); + ERR_ConvoyVehicle unit = NewVehicle(); unit.Index = index; task.Vehicles.Insert(unit); unit.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.GroupPrefab(), positions[index])); if (unit.Group) m_Mode.Own(unit.Group); unit.Entity = Vehicle.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.VehiclePrefab(index), positions[index], angles[index])); @@ -278,7 +293,7 @@ class ERR_ConvoyService AICarMovementComponent movement = AICarMovementComponent.Cast(unit.Entity.FindComponent(AICarMovementComponent)); if (movement) movement.SetCruiseSpeed(35); } - ERR_HQ.Event("CONVOY CREATED", "six physical vehicles and 19 finite crew; native readiness and ledger admission pending"); + ERR_HQ.Event("CONVOY CREATED", "six physical vehicles; finite crew created; native readiness and ledger admission pending"); return task; } protected string ReadinessReason(ERR_ConvoyVehicle unit) diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyEquipment.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyEquipment.c index f8d99e4..0575d5f 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyEquipment.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyEquipment.c @@ -7,12 +7,11 @@ class ERR_EnemyEquipmentRules if (!Replication.IsServer() || SCR_Global.IsEditMode() || !mode || mode != ERR_GameMode.Get() || !mode.Active()) return false; if (!group || !actor || !ERR_Actors.CanFight(actor) || ERR_Actors.Player(actor) || ERR_Actors.Group(actor) != group) return false; SCR_AIGroupUtilityComponent utility = group.GetGroupUtilityComponent(); - return utility && utility.IsMilitary() && group.GetPlayerCount(true) == 0 && group.GetFaction() == mode.EnemyFaction() && !ERR_EnemyGunnerSkill.CapturedGroup(mode, group); + return utility && utility.IsMilitary() && group.GetPlayerCount(true) == 0 && mode.IsMissionEnemy(group.GetFaction()) && !ERR_EnemyGunnerSkill.CapturedGroup(mode, group); } static EAISkill Floor(EAISkill skill) { - if (skill < EAISkill.EXPERT) return EAISkill.EXPERT; - return skill; + return EAISkill.CYLON; } } @@ -158,7 +157,7 @@ class ERR_EnemyEquipment void ERR_EnemyEquipment(ERR_GameMode mode) { m_Mode = mode; } void RegisterGroup(SCR_AIGroup group, int radioId = 0, bool fitRadios = true) { - if (m_Stopped || !Replication.IsServer() || !group || m_Groups.Count() >= 128) return; + if (m_Stopped || !Replication.IsServer() || !group || m_Groups.Count() >= 256) return; foreach (ERR_EnemyEquipmentGroup existing : m_Groups) if (existing.Group == group) return; ERR_EnemyEquipmentGroup state = new ERR_EnemyEquipmentGroup(); state.Group = group; state.ReadyAt = GetGame().GetWorld().GetWorldTime() + 2000; diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyGunnerSkill.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyGunnerSkill.c index c0dfa9c..239b6a8 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyGunnerSkill.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyGunnerSkill.c @@ -2,12 +2,24 @@ // Owned extension and inspected vanilla API provenance: docs/ai-equipment-migration.md. class ERR_EnemyGunnerSkill { + static ERR_ConvoyTask TaskForGroup(ERR_HQ hq, SCR_AIGroup group) + { + if (!hq || !group || !hq.Convoys()) return null; + for (int i = 0; i < hq.Convoys().Count(); i++) + { + ERR_ConvoyTask task = hq.Convoys().At(i); + if (!task || task.Vehicles.Count() > 6) continue; + foreach (ERR_ConvoyVehicle vehicle : task.Vehicles) + if (vehicle && vehicle.Group == group) return task; + } + return null; + } static bool CapturedGroup(ERR_GameMode mode, SCR_AIGroup group) { if (!mode || !group) return false; ERR_HQ hq = ERR_HQ.Cast(mode.Command()); if (!hq) return false; - ERR_ConvoyTask task = hq.ConvoyTask(); + ERR_ConvoyTask task = TaskForGroup(hq, group); if (!task || task.Vehicles.Count() > 6) return false; foreach (ERR_ConvoyVehicle unit : task.Vehicles) if (unit && unit.Group == group && ERR_ConvoyRules.Captured(unit.Entity, group)) return true; @@ -18,7 +30,7 @@ class ERR_EnemyGunnerSkill if (!ERR_EnemyEquipmentRules.Eligible(mode, group, actor)) return false; ERR_HQ hq = ERR_HQ.Cast(mode.Command()); if (!hq) return false; - ERR_ConvoyTask task = hq.ConvoyTask(); + ERR_ConvoyTask task = TaskForGroup(hq, group); if (!task || !task.Ready || task.Stopped || !task.Receipt || !task.Receipt.Consumed || task.Vehicles.Count() > 6 || !mode.Forces()) return false; foreach (ERR_ConvoyVehicle unit : task.Vehicles) { diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyOptics.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyOptics.c index 776d2d7..13ae588 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyOptics.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyOptics.c @@ -114,6 +114,7 @@ class ERR_EnemyOptics int draw = Math.RandomInt(0, remaining.Count()); int index = remaining[draw]; remaining.Remove(draw); ResourceName prefab = candidates[index]; + if (!mode.Content() || !mode.Content().Allows(prefab)) continue; for (int slotIndex = 0; slotIndex < storage.GetSlotsCount(); slotIndex++) { InventoryStorageSlot slot = storage.GetSlot(slotIndex); diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemySkillAwareness.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemySkillAwareness.c new file mode 100644 index 0000000..457f052 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemySkillAwareness.c @@ -0,0 +1,38 @@ +// Mission-owned military AI use maximum decision skill, independently of aim error. +modded class SCR_AICombatComponent +{ + bool ERR_EnhancedEnemy() + { + return m_ERRSkillMode && ERR_EnemyEquipmentRules.Eligible(m_ERRSkillMode, m_ERRSkillGroup, GetOwner()); + } + override void ERR_RefreshEnemySkill() + { + super.ERR_RefreshEnemySkill(); + if (ERR_EnhancedEnemy() && m_Perception && m_Utility && m_Utility.m_ThreatSystem) + UpdatePerceptionFactor(m_Perception, m_Utility.m_ThreatSystem); + } + override void UpdatePerceptionFactor(PerceptionComponent perceptionComp, SCR_AIThreatSystem threatSystem) + { + if (!ERR_EnhancedEnemy()) { super.UpdatePerceptionFactor(perceptionComp, threatSystem); return; } + // Use native alert recognition even while safe/suppressed. Native visibility, + // vegetation, darkness, viewing direction and binocular equipment still apply. + if (perceptionComp) perceptionComp.SetPerceptionFactor(PERCEPTION_FACTOR_ALERTED * m_fEquipmentPerceptionFactor * m_fPerceptionFactor); + } + override void ERR_ReleaseEnemySkill(ERR_GameMode mode) + { + super.ERR_ReleaseEnemySkill(mode); + if (m_Perception && m_Utility && m_Utility.m_ThreatSystem) + UpdatePerceptionFactor(m_Perception, m_Utility.m_ThreatSystem); + } +} + +modded class SCR_AIGetAimErrorOffset +{ + override float GetRandomFactor(EAISkill skill, float mu) + { + // CYLON normally eliminates aim error. Keep the ordinary REGULAR distribution + // for these soldiers while leaving skill-dependent decisions at maximum. + if (m_CombatComponent && m_CombatComponent.ERR_EnhancedEnemy()) skill = EAISkill.REGULAR; + return super.GetRandomFactor(skill, mu); + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c index f1e43fc..c801b82 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c @@ -3,6 +3,8 @@ class ERR_HQUnit int Id; SCR_AIGroup Group; SCR_AIGroupUtilityComponent Utility; + SCR_AIGroupPerception Detection; + float NextDetectionLog; IEntity Radio; float NextReport; ref ERR_HQRoute Uplink; @@ -88,6 +90,20 @@ class ERR_HQ : ERR_CommandService protected ERR_GameMode m_Mode; protected ref ERR_MortarSystem m_Mortars; protected ref ERR_EnemyEquipment m_Equipment; + protected ref ERR_HunterService m_Hunters; + protected ref ERR_ObjectiveDefence m_ObjectiveDefence; + protected ref array m_SpecialSkills = {}; + ERR_HunterService Hunters() { return m_Hunters; } + protected ref ERR_ScoutSystem m_Scouts; + protected ref ERR_AirfieldSupport m_Airfield; + ERR_AirfieldSupport Airfield() { return m_Airfield; } + protected ref ERR_BodyRecovery m_Recovery; + ERR_BodyRecovery Recovery() { return m_Recovery; } + protected ref ERR_AirAssaultSystem m_AirAssault; + ERR_AirAssaultSystem AirAssault() { return m_AirAssault; } + protected ref ERR_AirReconSystem m_Recon; + ERR_AirReconSystem Recon() { return m_Recon; } + ERR_ScoutSystem Scouts() { return m_Scouts; } protected ref ERR_ConvoyPool m_ConvoyPool; protected ref ERR_ConvoyService m_Convoys; protected ref ERR_ConvoyTask m_ConvoyTask; @@ -100,6 +116,7 @@ class ERR_HQ : ERR_CommandService protected ref ERR_MortarLogistics m_Logistics; protected ref ERR_InfantryResupply m_Resupply; protected ref ERR_ConvoyStartup m_ConvoyStartup; + protected ref ERR_SupplyConvoySystem m_SupplyConvoys; protected ref array m_Units = {}; protected ref array m_Contacts = {}; protected ref ERR_HQMemory m_Memory = new ERR_HQMemory(); @@ -113,7 +130,42 @@ class ERR_HQ : ERR_CommandService protected float m_NextNetwork; protected float m_NextAssignment; - void ERR_HQ(ERR_GameMode mode) { m_Mode = mode; m_Equipment = new ERR_EnemyEquipment(mode); m_ConvoyPool = new ERR_ConvoyPool(mode, 1); m_Convoys = new ERR_ConvoyService(mode, m_ConvoyPool); m_Mortars = new ERR_MortarSystem(this); m_Logistics = new ERR_MortarLogistics(this, mode); m_Resupply = new ERR_InfantryResupply(this, mode); m_ConvoyStartup = new ERR_ConvoyStartup(mode.Settings().Get("automaticConvoyEnabled") == 1); } + void ERR_HQ(ERR_GameMode mode) + { + m_Mode = mode; + m_Equipment = new ERR_EnemyEquipment(mode); + m_Hunters = new ERR_HunterService(mode, null); + m_ObjectiveDefence = new ERR_ObjectiveDefence(mode, this); + m_Scouts = new ERR_ScoutSystem(mode); + m_Recon = new ERR_AirReconSystem(mode); + m_AirAssault = new ERR_AirAssaultSystem(this, mode); + m_Airfield = new ERR_AirfieldSupport(this, mode); m_Recovery = new ERR_BodyRecovery(this, mode); + bool legacyFixture; +#ifdef WORKBENCH + // These pre-existing diagnostic scenarios specifically exercise the retained + // finite-route controller. Ordinary Workbench sessions use the full fleet. + string fixtureEnabled, fixtureOutcome; + legacyFixture = System.GetCLIParam("errSmoke", fixtureEnabled) && fixtureEnabled == "1" + && System.GetCLIParam("errSmokeOutcome", fixtureOutcome) && (fixtureOutcome == "convoy" || fixtureOutcome == "hq-memory"); +#endif + if (legacyFixture) + { + m_ConvoyPool = new ERR_ConvoyPool(mode, 1); + m_Convoys = new ERR_ConvoyService(mode, m_ConvoyPool); + } + else + { + int convoyCount = mode.Settings().Runtime("convoyCount"); + m_ConvoyPool = new ERR_ConvoyPool(mode, convoyCount, true); + ERR_SupplyConvoyService supply = new ERR_SupplyConvoyService(mode, m_ConvoyPool); + m_Convoys = supply; + m_SupplyConvoys = new ERR_SupplyConvoySystem(mode, m_ConvoyPool, supply, convoyCount, mode.Settings().Get("automaticConvoyEnabled") == 1); + } + m_Mortars = new ERR_MortarSystem(this); + m_Logistics = new ERR_MortarLogistics(this, mode); + m_Resupply = new ERR_InfantryResupply(this, mode); + m_ConvoyStartup = new ERR_ConvoyStartup(mode.Settings().Get("automaticConvoyEnabled") == 1); + } static ERR_HQ Get() { ERR_GameMode mode = ERR_GameMode.Get(); @@ -134,6 +186,7 @@ class ERR_HQ : ERR_CommandService ERR_ConvoyPool ConvoyPool() { return m_ConvoyPool; } ERR_ConvoyService Convoys() { return m_Convoys; } ERR_ConvoyTask ConvoyTask() { return m_ConvoyTask; } + ERR_SupplyConvoySystem SupplyConvoys() { return m_SupplyConvoys; } bool PrepareConvoy(vector seed) { if (!Replication.IsServer() || !MissionActive() || m_ConvoyTask) return false; @@ -188,6 +241,12 @@ class ERR_HQ : ERR_CommandService protected void AutomaticConvoy(float now) { if (!Replication.IsServer() || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active()) return; + if (m_SupplyConvoys) + { + m_SupplyConvoys.Tick(now, ConvoyAdmissionReady()); + if (!m_ConvoyTask && m_Convoys.Count() > 0) m_ConvoyTask = m_Convoys.At(0); + return; + } if (!m_ConvoyStartup || m_ConvoyStartup.Finished()) return; string previous = m_ConvoyStartup.Status(); if (m_ConvoyStartup.Decide(now, m_ConvoyTask != null, ConvoyAdmissionReady())) @@ -197,6 +256,13 @@ class ERR_HQ : ERR_CommandService } bool StartScenarioConvoy() { + if (m_SupplyConvoys) + { + if (!ConvoyAdmissionReady()) return false; + bool started = m_SupplyConvoys.StartOne(); + if (!m_ConvoyTask && m_Convoys.Count() > 0) m_ConvoyTask = m_Convoys.At(0); + return started; + } if (!m_Mode || !m_Mode.Sites()) return false; ERR_SiteRuntime site = m_Mode.Sites().Find("everon-supply-1"); if (!site || !site.Definition) return false; @@ -205,7 +271,8 @@ class ERR_HQ : ERR_CommandService string ConvoyStatus() { string detail = m_Convoys.Status(); - if (m_ConvoyStartup) detail += "; automatic startup: " + m_ConvoyStartup.Status(); + if (m_SupplyConvoys) detail += "; " + m_SupplyConvoys.Status(); + else if (m_ConvoyStartup) detail += "; automatic startup: " + m_ConvoyStartup.Status(); if (!m_ConvoyFailure.IsEmpty()) detail += "; " + m_ConvoyFailure; return detail; } @@ -219,9 +286,45 @@ class ERR_HQ : ERR_CommandService if (unit.Group != group) continue; ReleaseUnit(unit, GetGame().GetWorld().GetWorldTime()); unit.ReservedForSupport = true; + m_Journal.Write("ROLE RESERVED", string.Format("group=%1 force=%2 home=%3; excluded from general HQ retasking", unit.Id, unit.ForceSourceId, unit.HomeId)); + } + } + void Dedicate(SCR_AIGroup group, string siteId) + { + ReserveGroup(group); + foreach (ERR_HQUnit unit : m_Units) + if (unit.Group == group) { unit.HomeId = siteId; unit.HomeGuard = true; } + } + protected void Detected(SCR_AIGroup group, SCR_AITargetInfo target) + { + if (!Replication.IsServer() || !m_Mode.Active() || m_Mode.Phase() <= ERR_Phase.PRISON || !group || !target || !target.m_Entity) return; + float now = GetGame().GetWorld().GetWorldTime(); + foreach (ERR_HQUnit unit : m_Units) + { + if (unit.Group != group) continue; + unit.NextReport = 0; + // Native combat reacts locally now; remote orders still require radio delivery. + group.ActivateAI(); + if (now >= unit.NextDetectionLog) + { + unit.NextDetectionLog = now + 10000; + m_Journal.Write("CONTACT LOCAL", string.Format("group=%1 force=%2 observed=%3 home=%4 dedicated=%5; native combat active, radio report due", unit.Id, unit.ForceSourceId, target.m_vWorldPos, unit.HomeId, unit.ReservedForSupport)); + } } } // Temporary reservations never alter the permanent support reservation. + void ReleaseMortarCrew(SCR_AIGroup group) + { + ERR_MortarBattery battery = m_Mortars.FindGroup(group); + if (!battery) return; + if (battery.Gun) + { + DamageManagerComponent damage = DamageManagerComponent.Cast(battery.Gun.FindComponent(DamageManagerComponent)); + if (!damage || !damage.IsDestroyed()) return; + } + foreach (ERR_HQUnit unit : m_Units) + if (unit.Group == group) unit.ReservedForSupport = false; + } int LogisticsToken(SCR_AIGroup group) { foreach (ERR_HQUnit unit : m_Units) if (unit.Group == group) return unit.LogisticsReservation; @@ -316,7 +419,7 @@ class ERR_HQ : ERR_CommandService ERR_HQJournal Journal() { return m_Journal; } string Status() { - return string.Format("HQ: %1 groups, %2 delivered contacts, %3 assignments", m_Units.Count(), m_Contacts.Count(), Assigned()) + string.Format("; %1 remembered tracks, %2 swept cells", m_Memory.Count(), m_Coverage.Count()) + "; " + m_Mode.Forces().Status() + "; " + m_Mortars.Status() + "; " + m_Logistics.Status() + "; " + m_Resupply.Status(); + return string.Format("HQ: %1 groups, %2 delivered contacts, %3 assignments", m_Units.Count(), m_Contacts.Count(), Assigned()) + string.Format("; %1 remembered tracks, %2 swept cells; aggression=%3/30 identified bodies=%4; ", m_Memory.Count(), m_Coverage.Count(), m_Watch.Aggression(), m_Watch.IdentifiedBodies()) + m_Hunters.Status() + "; " + m_Mode.Forces().Status() + "; " + m_Mortars.Status() + "; " + m_Logistics.Status() + "; " + m_Resupply.Status() + "; " + m_Recon.Status(); } protected void BindPhysical(ERR_HQUnit unit) @@ -328,11 +431,42 @@ class ERR_HQ : ERR_CommandService if (!unit.StatusSampler.Bound()) unit.StatusSampler.Bind(unit.Group, record.SourceId, record.InitialStrength, m_Mode.EnemyFaction()); if (unit.StatusSampler.Bound()) m_Bodies.RegisterGroup(unit.Group, unit.StatusSampler); } + protected void BindSpecialSkills(SCR_AIGroup group) + { + array specialists = {}; group.GetAgents(specialists); + foreach (AIAgent specialist : specialists) + { + if (!specialist || !specialist.GetControlledEntity()) continue; + SCR_AICombatComponent combat = SCR_AICombatComponent.Cast(specialist.GetControlledEntity().FindComponent(SCR_AICombatComponent)); + if (!combat) continue; + combat.ERR_BindEnemySkill(m_Mode, group); + if (!m_SpecialSkills.Contains(combat)) m_SpecialSkills.Insert(combat); + } + } + protected void AuditGroup(ERR_HQUnit unit) + { + SCR_AIGroup group = unit.Group; + string factionKey; + if (group.GetFaction()) factionKey = group.GetFaction().GetFactionKey(); + m_Journal.Write("REGISTER", string.Format("group=%1 force=%2 faction=%3 position=%4 members=%5 home=%6 dedicated=%7; policy=maximum skill/regular aim/alert recognition", unit.Id, unit.ForceSourceId, factionKey, group.GetOrigin(), m_Mode.ForceLiving(group), unit.HomeId, unit.ReservedForSupport)); + array auditAgents = {}; group.GetAgents(auditAgents); + foreach (AIAgent auditAgent : auditAgents) + { + if (!auditAgent || !auditAgent.GetControlledEntity()) continue; + IEntity actor = auditAgent.GetControlledEntity(); + string prefab; if (actor.GetPrefabData()) prefab = actor.GetPrefabData().GetPrefabName(); + m_Journal.Write("SPAWN MEMBER", string.Format("group=%1 force=%2 actor=%3 prefab=%4 position=%5", unit.Id, unit.ForceSourceId, actor, prefab, actor.GetOrigin())); + } + } + override void RegisterGroup(SCR_AIGroup group) { if (!Replication.IsServer() || !group) return; + // Other hostile factions retain their own intelligence, but receive the same + // scoped skill/aim profile. Do not share enemy HQ knowledge across hostile sides. + if (m_Mode.IsMissionEnemy(group.GetFaction()) && group.GetFaction() != m_Mode.EnemyFaction()) m_Equipment.RegisterGroup(group, 0, false); foreach (ERR_HQUnit known : m_Units) if (known.Group == group) { BindPhysical(known); return; } - if (m_Units.Count() >= 128) return; + if (m_Units.Count() >= 256) return; SCR_AIGroupUtilityComponent utility = SCR_AIGroupUtilityComponent.Cast(group.FindComponent(SCR_AIGroupUtilityComponent)); if (!utility || group.GetFaction() != m_Mode.EnemyFaction()) return; ERR_HQUnit unit = new ERR_HQUnit(); @@ -345,16 +479,25 @@ class ERR_HQ : ERR_CommandService if (home) { unit.HomeId = home.Definition.Id; + // Objective security is permanent; it can report contacts but cannot be tasked + // with searches, reinforcements, resupply or courier work away from its site. + unit.ReservedForSupport = ERR_ObjectiveDefence.Objective(home.Definition.Kind); unit.HomeGuard = !home.Definition.Patrol || home.Definition.Patrol.IsEmpty(); } int phase = unit.Id % 5; unit.NextReport = GetGame().GetWorld().GetWorldTime() + phase * 1000; m_Units.Insert(unit); - m_Equipment.RegisterGroup(group, unit.Id, !m_Mortars || !m_Mortars.FindGroup(group)); + // Scouts keep their observation post and authored sniper/radio loadout. + if ((m_Scouts && m_Scouts.Contains(group)) || (m_Recon && m_Recon.Owns(group)) || (m_Airfield && m_Airfield.Cargo() && m_Airfield.Cargo().Crew() == group)) + { + unit.ReservedForSupport = true; + BindSpecialSkills(group); + } + else m_Equipment.RegisterGroup(group, unit.Id, !m_Mortars || !m_Mortars.FindGroup(group)); BindPhysical(unit); unit.NextStatus = GetGame().GetWorld().GetWorldTime() + 10000 + phase * 1000; unit.AmmoReporting.Initialize(unit.Id, GetGame().GetWorld().GetWorldTime()); - m_Journal.Write("REGISTER", string.Format("group %1", unit.Id)); + AuditGroup(unit); } override void UnregisterGroup(SCR_AIGroup group) @@ -363,6 +506,7 @@ class ERR_HQ : ERR_CommandService foreach (ERR_HQUnit unit : m_Units) { if (unit.Group != group) continue; + if (unit.Detection) { unit.Detection.GetOnEnemyDetected().Remove(Detected); unit.Detection = null; } ReleaseUnit(unit, GetGame().GetWorld().GetWorldTime()); unit.Group = null; unit.Utility = null; @@ -443,7 +587,7 @@ class ERR_HQ : ERR_CommandService protected void Observe(ERR_HQUnit unit, float now) { if (!unit.Utility || unit.Uplink || now < unit.NextReport) return; - unit.NextReport = now + m_Mode.Settings().Get("reportIntervalSeconds") * 1000; + unit.NextReport = now + m_Mode.Settings().Runtime("reportIntervalSeconds") * 1000; array samples = {}; ERR_HQObservation.Gather(unit.Utility, now, samples); if (samples.IsEmpty()) return; @@ -572,7 +716,7 @@ class ERR_HQ : ERR_CommandService if (!m_Watch.InvestigationDue(entry.SourceId, now)) continue; ERR_HQUnit best; ERR_HQRoute bestRoute; - float distance = m_Mode.Settings().Get("responseRadiusMetres") + 1; + float distance = m_Mode.Settings().Runtime("responseRadiusMetres") + 1; int first = -1; int second = -1; foreach (ERR_HQUnit existing : m_Units) @@ -616,7 +760,10 @@ class ERR_HQ : ERR_CommandService // Delivered scalar body evidence survives native corpse cleanup during radio transit. // Memory still applies its own entity-identity rules; casualty accounting needs no live target. if (ERR_HQMemoryRules.Kind(report) == ERR_HQMemoryKind.BODY && m_Watch.IngestBody(report, now)) - m_Bodies.ConfirmDelivered(report.Target); + { + m_Bodies.ConfirmDelivered(report.Target); m_Recovery.ReportBody(report); + m_Journal.Write("AGGRESSION", string.Format("identified source=%1 slot=%2 bodies=%3 aggression=%4/30 hunter allowance=%5/15", report.CasualtySourceId, report.CasualtySlot, m_Watch.IdentifiedBodies(), m_Watch.Aggression(), m_Watch.HunterTarget()), report.MessageId); + } if (!m_Memory.Ingest(report, now)) return; if (ERR_HQMemoryRules.Kind(report) == ERR_HQMemoryKind.SIGHTING) m_Coverage.Forget(report.Position); m_Journal.Write("BELIEF", string.Format("delivered report M%1 retained; tracks=%2", report.MessageId, m_Memory.Count()), report.MessageId); @@ -659,7 +806,7 @@ class ERR_HQ : ERR_CommandService { ERR_HQTask task = unit.PendingTask; unit.CancelPending(); - if (!task || task.ReleaseOnly) return; + if (!task || task.ReleaseOnly || !task.SecuritySite.IsEmpty()) return; if (task.Investigation()) { FinishWatch(task, now); return; } foreach (ERR_HQUnit other : m_Units) { @@ -687,7 +834,7 @@ class ERR_HQ : ERR_CommandService { if (!WatchTaskValid(task)) { LostOrder(unit, now); return; } } - else + else if (task.SecuritySite.IsEmpty()) { ERR_HQBelief belief = m_Memory.GetById(task.BeliefId); if (!belief || belief.BeliefRevision != task.BeliefRevision @@ -699,7 +846,7 @@ class ERR_HQ : ERR_CommandService int deliveryId; if (unit.Downlink) deliveryId = unit.Downlink.MessageId; ResourceName waypointResource = ERR_Resources.SearchWaypoint(); - if (task.RoadIntercept) waypointResource = ERR_Resources.DefendWaypoint(); + if (task.RoadIntercept || !task.SecuritySite.IsEmpty()) waypointResource = ERR_Resources.DefendWaypoint(); AIWaypoint waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(waypointResource, ERR_Actors.Ground(task.Destination))); if (!waypoint) { LostOrder(unit, now); m_Journal.Write("ORDER FAILED", "waypoint spawn failed", order.MessageId); return; } unit.Release(); @@ -718,7 +865,7 @@ class ERR_HQ : ERR_CommandService protected void Sweep(ERR_HQUnit unit, float now) { ERR_HQTask task = unit.Task; - if (!task || task.RoadIntercept || !unit.Assignment || task.SweepQueued || !unit.Waypoint || !unit.Eligible(m_Mode)) return; + if (!task || !task.SecuritySite.IsEmpty() || task.RoadIntercept || !unit.Assignment || task.SweepQueued || !unit.Waypoint || !unit.Eligible(m_Mode)) return; if (Fighting(unit) || vector.DistanceXZ(unit.Group.GetOrigin(), task.Destination) > 40) { task.ArrivedAt = -1; return; } // Quiet combat state alone does not establish a negative search. Use this @@ -866,8 +1013,12 @@ class ERR_HQ : ERR_CommandService } protected void PlanConvoy(float now) { - if (!m_ConvoyTask || m_ConvoyTask.Stopped || !m_ConvoyTask.Ready) return; - foreach (ERR_ConvoyVehicle vehicle : m_ConvoyTask.Vehicles) + for (int i = 0; i < m_Convoys.Count(); i++) PlanConvoyTask(m_Convoys.At(i), now); + } + protected void PlanConvoyTask(ERR_ConvoyTask task, float now) + { + if (!task || task.Stopped || !task.Ready) return; + foreach (ERR_ConvoyVehicle vehicle : task.Vehicles) { if (vehicle.Released || vehicle.Arrived || vehicle.SupplyToken > 0 || !vehicle.Registered || !vehicle.Group) continue; foreach (ERR_HQUnit unit : m_Units) @@ -880,11 +1031,11 @@ class ERR_HQ : ERR_CommandService if (vehicle.Combat.Held && contact.MessageId == vehicle.Combat.ReportId) break; if (contact.ObservedAt < vehicle.Combat.ObservedAt) break; } - else if (!vehicle.Combat.Held || now - vehicle.Combat.ObservedAt < 60000) break; + else if (vehicle.Combat.ReportId <= 0 || !vehicle.Combat.Held || now - vehicle.Combat.ObservedAt < 60000) break; ERR_HQRoute route = m_Network.Route(unit.Radio, true); if (!route) break; ERR_ConvoyCombatOrder order = new ERR_ConvoyCombatOrder(); - order.CommandId = m_Journal.Message(); order.SourceId = m_ConvoyTask.Receipt.SourceId; + order.CommandId = m_Journal.Message(); order.SourceId = task.Receipt.SourceId; order.VehicleIndex = vehicle.Index; order.Group = unit.Group; order.Hold = contact != null; order.IssuedAt = now; order.ExpiresAt = now + 30000; order.ReportId = vehicle.Combat.ReportId; order.ObservedAt = vehicle.Combat.ObservedAt; @@ -913,7 +1064,11 @@ class ERR_HQ : ERR_CommandService // starve a slower radio downlink. New threat evidence always invalidates RESUME. bool current = !order.Hold && !contact; if (order.Hold && contact) current = contact.ObservedAt >= order.ObservedAt; - if (current) accepted = m_Convoys.ApplyCombatOrder(m_ConvoyTask, order, now); + if (current) + { + if (contact && order.Hold) order.Position = contact.Position; + accepted = m_Convoys.ApplyCombatOrder(m_Convoys.Find(order.SourceId), order, now); + } } if (accepted) m_Journal.Write("CONVOY ORDER DELIVERED", "original crew applied radio travel command", order.CommandId); else if (order) m_Journal.Write("CONVOY ORDER REJECTED", "stale evidence or unavailable original crew; current order retained", order.CommandId); @@ -922,9 +1077,9 @@ class ERR_HQ : ERR_CommandService protected void Assign(float now) { - int globalLimit = m_Watch.SquadLimit(m_Mode.Settings().Get("supportSquadLimit")); + int globalLimit = m_Watch.SquadLimit(m_Mode.Settings().Runtime("supportSquadLimit")); int limit = ERR_HQPursuitRules.Limit(globalLimit); - int radius = m_Mode.Settings().Get("responseRadiusMetres"); + int radius = m_Mode.Settings().Runtime("responseRadiusMetres"); int assigned = Assigned(); int roadQueries; // Snapshot bounded own-force eligibility once per five-second planning pass. @@ -944,8 +1099,8 @@ class ERR_HQ : ERR_CommandService if (!unit.PlanEligible || assigned > globalLimit) { FinishWatch(unit.Task, now); FinishWatch(unit.PendingTask, now); unit.Release(); assigned--; continue; } if (unit.Task && now >= unit.Task.ExpiresAt) { FinishWatch(unit.Task, now); unit.ReleaseAssignment(); } if (unit.PendingTask && now >= unit.PendingTask.ExpiresAt) { FinishWatch(unit.PendingTask, now); unit.CancelPending(); } - if (unit.Task && !unit.Task.Investigation() && !m_Memory.GetById(unit.Task.BeliefId)) unit.ReleaseAssignment(); - if (unit.PendingTask && !unit.PendingTask.ReleaseOnly && !unit.PendingTask.Investigation() && !m_Memory.GetById(unit.PendingTask.BeliefId)) unit.CancelPending(); + if (unit.Task && unit.Task.SecuritySite.IsEmpty() && !unit.Task.Investigation() && !m_Memory.GetById(unit.Task.BeliefId)) unit.ReleaseAssignment(); + if (unit.PendingTask && !unit.PendingTask.ReleaseOnly && unit.PendingTask.SecuritySite.IsEmpty() && !unit.PendingTask.Investigation() && !m_Memory.GetById(unit.PendingTask.BeliefId)) unit.CancelPending(); } assigned = Assigned(); m_HomeAvailable.Clear(); @@ -1039,11 +1194,27 @@ class ERR_HQ : ERR_CommandService } } + override void Maintain(float now) + { + // Aircraft already airborne keep flying even when the server is empty. + m_Recon.Tick(now); + m_AirAssault.Maintain(now); + m_Airfield.Maintain(now); + } override void Tick(float now) { if (!Replication.IsServer()) return; m_Memory.Tick(now); m_Equipment.Tick(now); + foreach (SCR_AICombatComponent specialSkill : m_SpecialSkills) + { + if (!specialSkill) continue; + SCR_AIGroup specialGroup = ERR_Actors.Group(specialSkill.GetOwner()); + if (ERR_EnemyEquipmentRules.Eligible(m_Mode, specialGroup, specialSkill.GetOwner())) specialSkill.ERR_BindEnemySkill(m_Mode, specialGroup); + else specialSkill.ERR_ReleaseEnemySkill(m_Mode); + } + m_Hunters.Tick(now); + m_ObjectiveDefence.Tick(now); m_Convoys.Tick(now); AutomaticConvoy(now); m_Bodies.TickBudget(now); @@ -1053,6 +1224,11 @@ class ERR_HQ : ERR_CommandService for (int i = m_Units.Count() - 1; i >= 0; i--) { ERR_HQUnit unit = m_Units[i]; + if (!unit.Detection && unit.Utility && unit.Group) + { + unit.Detection = unit.Utility.m_Perception; + if (unit.Detection) unit.Detection.GetOnEnemyDetected().Insert(Detected); + } if (unit.Uplink) { int delivered = unit.Uplink.Advance(now, m_Journal); @@ -1084,12 +1260,23 @@ class ERR_HQ : ERR_CommandService m_Resupply.Tick(now); m_Logistics.Tick(now); m_Mortars.Tick(now); + m_AirAssault.Tick(now); + m_Airfield.Tick(now); m_Recovery.Tick(now); m_Journal.Flush(); } override void Stop() { + m_Hunters.Stop(); m_ObjectiveDefence.Stop(); + foreach (SCR_AICombatComponent specialSkill : m_SpecialSkills) if (specialSkill) specialSkill.ERR_ReleaseEnemySkill(m_Mode); + m_SpecialSkills.Clear(); + foreach (ERR_HQUnit detector : m_Units) if (detector.Detection) detector.Detection.GetOnEnemyDetected().Remove(Detected); + m_Airfield.Stop(); m_Recovery.Stop(); + m_AirAssault.Stop(); + m_Recon.Stop(); + m_Scouts.Stop(); m_ConvoyStartup.Stop(); + if (m_SupplyConvoys) m_SupplyConvoys.Stop(); m_Resupply.Stop(); m_Convoys.Stop(); m_Equipment.Stop(); diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQAdminProvider.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQAdminProvider.c index 8a69603..05bcec6 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQAdminProvider.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQAdminProvider.c @@ -45,7 +45,7 @@ class ERR_HQAdminProvider : ERR_AdminProvider protected void ConvoyAutoSetting(ERR_GameMode mode, array parts, array replies) { string key = "automaticConvoyEnabled"; - string effect = "Applies next round only; current startup and manual convoy start unchanged. At most one finite initial convoy; losses are not replaced."; + string effect = "Applies next round only; current startup and manual convoy start unchanged. The saved convoy count determines the finite initial fleet; losses are not replaced."; string range = "0 disabled, 1 enabled (default)"; if (parts.Count() == 4 && parts[3] == "show") { replies.Insert(key + "=" + mode.Settings().Get(key).ToString() + " saved for next round. " + effect); return; } @@ -55,8 +55,38 @@ class ERR_HQAdminProvider : ERR_AdminProvider else if (parts.Count() == 4 && parts[3] == "help") replies.Insert(range + ". " + effect); else replies.Insert("Invalid value or settings could not be saved; previous setting retained. " + range + ". " + effect); } + protected void CountSetting(ERR_GameMode mode, string arguments, array replies) + { + array countParts = {}; arguments.Split(" ", countParts, true); + int offset = 1; + if (countParts[0] == "hq") offset = 3; + if (countParts.Count() == offset || (countParts.Count() == offset + 1 && countParts[offset] == "show")) + replies.Insert("Convoy count saved for next round: " + ERR_Settings.SelectionText(mode.Settings().Get("convoyCount")) + ". " + m_HQ.ConvoyStatus()); + else + { + int count; + if (countParts.Count() == offset + 2 && countParts[offset] == "set" && ERR_CommandArguments.Selection("convoyCount", countParts[offset + 1], count) && mode.Settings().Set("convoyCount", count)) + replies.Insert("Convoy count saved for next round: " + ERR_Settings.SelectionText(count) + "; current convoys unchanged."); + else replies.Insert("Use convoys show|set 0..20|random. Changes apply next round; lost convoys never respawn."); + } + } override bool Execute(ERR_GameMode mode, string arguments, array replies) { + if (arguments == "hq heli show") { replies.Insert(m_HQ.Recon().Status() + "; " + m_HQ.AirAssault().Status() + "; " + m_HQ.Airfield().Status() + "; " + m_HQ.Recovery().Status()); return true; } + if (arguments == "hq heli return") { m_HQ.Recon().Return(); replies.Insert(m_HQ.Recon().Status() + "; " + m_HQ.AirAssault().Status() + "; " + m_HQ.Airfield().Status() + "; " + m_HQ.Recovery().Status()); return true; } + if (arguments.IndexOf("hq heli timer set ") == 0) + { + array timerParts = {}; arguments.Split(" ", timerParts, true); + int minutes; + if (timerParts.Count() == 5 && ERR_CommandArguments.Integer(timerParts[4], minutes) && m_HQ.Recon().SetDelay(minutes)) replies.Insert(m_HQ.Recon().Status() + "; " + m_HQ.AirAssault().Status() + "; " + m_HQ.Airfield().Status() + "; " + m_HQ.Recovery().Status()); + else replies.Insert("Use /esc hq heli timer set 1..1440 (minutes). Save failure preserves the current countdown."); + return true; + } + if (arguments == "convoys" || arguments.IndexOf("convoys ") == 0 || arguments == "hq convoy count" || arguments.IndexOf("hq convoy count ") == 0) + { + CountSetting(mode, arguments, replies); + return true; + } if (arguments == "hq convoy auto" || arguments.IndexOf("hq convoy auto ") == 0) { if (!m_HQ) { replies.Insert("HQ unavailable."); return true; } @@ -91,10 +121,11 @@ class ERR_HQAdminProvider : ERR_AdminProvider override bool Help(string topic, array replies) { if (topic != "hq") return false; + replies.Insert("/esc convoys show|set 0..20|random | hq convoy count show|set N (default three; next round)"); replies.Insert("/esc hq show | hq memory | hq log show|next|set on|off | hq convoy show|start"); replies.Insert("/esc hq interval|squads|radius|stinger show|set N | hq convoy auto show|set 0|1|help"); replies.Insert("Stinger: 0 off, 1 every eligible soldier, 2..10000 one in N; applies only before each actor rolls."); - replies.Insert("Convoy auto defaults to 1; saves apply next round only. Auto and manual start share one finite initial deployment; losses are not replaced."); + replies.Insert("Convoy auto: default 1, next round, finite fleet. /esc hq heli show | hq heli timer set 1..1440 | hq heli return"); return true; } } diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQBodyObserver.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQBodyObserver.c index 20eb3d2..e79fc16 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQBodyObserver.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQBodyObserver.c @@ -79,7 +79,7 @@ class ERR_HQBodyObserver foreach (ERR_HQBodyScan scan : m_Groups) if (scan.Group == group) known = true; if (!known) { - if (m_Groups.Count() >= 128) { Cap(); return; } + if (m_Groups.Count() >= 256) { Cap(); return; } ERR_HQBodyScan created = new ERR_HQBodyScan(); created.Group = group; m_Groups.Insert(created); } @@ -91,7 +91,7 @@ class ERR_HQBodyObserver bool found; foreach (ERR_HQBodyCandidate entry : m_Roster) if (entry.Corpse == actor) found = true; if (found) continue; - if (m_Roster.Count() >= 128) { Cap(); return; } + if (m_Roster.Count() >= 4096) { Cap(); return; } ERR_HQBodyCandidate candidate = new ERR_HQBodyCandidate(); candidate.Corpse = actor; candidate.Roster = sampler; candidate.SourceId = sampler.InitialSourceId(); candidate.Slot = member; candidate.Strength = sampler.InitialStrength(); diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQCoverage.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQCoverage.c index 7614b86..04c68a7 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQCoverage.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQCoverage.c @@ -79,8 +79,8 @@ class ERR_HQCoverage report.BeliefRevision, report.SweepCompleted, report.ContactFound, report.ObservedAt, now)) return false; if (m_Messages.Contains(report.SourceId) && report.MessageId <= m_Messages.Get(report.SourceId)) return false; // One uplink per source gives monotonic message order. Never evict replay - // watermarks; the mission already caps its total physical formations at 128. - if (!m_Messages.Contains(report.SourceId) && m_Messages.Count() >= 128) return false; + // watermarks; the mission already caps its total physical formations at 256. + if (!m_Messages.Contains(report.SourceId) && m_Messages.Count() >= 256) return false; m_Messages.Set(report.SourceId, report.MessageId); int key = ERR_HQCoverageRules.Key(report.Position); if (m_Cells.Contains(key) && m_Cells.Get(key) >= report.ObservedAt) return false; diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQObservation.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQObservation.c index 0f02471..fab1c58 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQObservation.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQObservation.c @@ -7,6 +7,7 @@ class ERR_HQVisualContact bool Escapist; string Travel = "unknown"; string Weapon = "unknown"; + string RoadSurface = "road unknown"; } class ERR_HQObservation @@ -52,6 +53,7 @@ class ERR_HQObservation seen.Entity = entity; seen.Position = target.GetLastSeenPosition(); seen.ObservedAt = now - target.GetTimeSinceSeen() * 1000; + seen.RoadSurface = ERR_ScoutRoad.Describe(seen.Position); SCR_ChimeraCharacter character = SCR_ChimeraCharacter.Cast(entity); if (character) { @@ -117,6 +119,7 @@ class ERR_HQObservation existing.ObservedAt = seen.ObservedAt; existing.Travel = seen.Travel; existing.Weapon = seen.Weapon; + existing.RoadSurface = seen.RoadSurface; } } } @@ -126,6 +129,7 @@ class ERR_HQObservation // New observations must not silently inherit old equipment/travel details. report.SeenEscapists = -1; report.TravelMode = "unknown"; + report.RoadStatus = "road unknown"; report.SeenWeapons = "unknown"; report.DetailsObservedAt = -1; if (report.Distress) @@ -143,6 +147,9 @@ class ERR_HQObservation return; report.DetailsObservedAt = anchor.ObservedAt; report.TravelMode = anchor.Travel; + report.RoadStatus = anchor.RoadSurface; + ERR_HQ hq = ERR_HQ.Get(); + if (hq && hq.Scouts()) hq.Scouts().DescribeMotion(report); report.SeenWeapons = anchor.Weapon; array counted = {}; array weapons = {}; diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQReport.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQReport.c index 85a7720..c727960 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQReport.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQReport.c @@ -51,6 +51,7 @@ class ERR_HQReport bool Distress; int SeenEscapists = -1; string TravelMode = "unknown"; + string RoadStatus = "road unknown"; // Original formation identity captured by the local observer, only used after radio delivery. string CasualtySourceId; int CasualtySlot = -1; @@ -72,6 +73,7 @@ class ERR_HQReport copy.HasVelocity = HasVelocity; copy.SeenEscapists = SeenEscapists; copy.TravelMode = TravelMode; + copy.RoadStatus = RoadStatus; copy.CasualtySourceId = CasualtySourceId; copy.CasualtySlot = CasualtySlot; copy.CasualtyStrength = CasualtyStrength; @@ -118,7 +120,7 @@ class ERR_HQReport int heading = Math.Round(azimuth); motion = string.Format("%1 km/h, azimuth %2 degrees (%3)", Math.Round(speed * 3.6), heading % 360, TravelDirection()); } - string details = count + "; " + TravelMode + "; " + motion + "; seen weapons: " + SeenWeapons; + string details = count + "; " + TravelMode + "; " + RoadStatus + "; " + motion + "; seen weapons: " + SeenWeapons; if (brief) return details; string velocity = "unknown"; diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQTask.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQTask.c index 4b6605b..2bf68ec 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQTask.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQTask.c @@ -1,6 +1,7 @@ // A radio order snapshots an inference. It never refreshes the source sighting. class ERR_HQTask { + string SecuritySite; bool ReleaseOnly; bool RoadIntercept; ref ERR_HQTask ReplacesTask; @@ -19,6 +20,7 @@ class ERR_HQTask int ApproachBearing = -1; string Name() { + if (!SecuritySite.IsEmpty()) return "secure compromised site " + SecuritySite; if (Investigation()) return "investigate missing patrol"; if (RoadIntercept) return "temporary infantry road interception"; if (Role == ERR_HQPursuitRole.INTERCEPT) return "intercept"; diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQWatch.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQWatch.c index a1f278f..5bdaafa 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQWatch.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQWatch.c @@ -17,6 +17,8 @@ class ERR_HQStatusReport class ERR_HQWatchRules { + static int Aggression(int identifiedBodies) { return Math.Clamp(identifiedBodies / 3, 0, 30); } + static int Hunters(int aggression) { return (Math.Clamp(aggression, 0, 30) + 1) / 2; } static bool Valid(ERR_HQStatusReport report, float now) { if (!report || report.SourceId.IsEmpty() || report.SourceId.Length() > 96 || report.MessageId <= 0) @@ -66,6 +68,7 @@ class ERR_HQWatchEntry int ConfirmedLosses; ref ERR_HQAmmoSnapshot AmmoState; ref ERR_HQDeliveredCasualties Casualties = new ERR_HQDeliveredCasualties(); + ref array IdentifiedSlots = {}; float LastBodyDeliveredAt = -1; float ObservedAt = -1; float LastDeliveredAt = -1; @@ -80,6 +83,10 @@ class ERR_HQWatch protected ref array m_Entries = {}; protected ref map m_BySource = new map(); protected int m_ConfirmedLosses; + protected int m_IdentifiedBodies; + int IdentifiedBodies() { return m_IdentifiedBodies; } + int Aggression() { return ERR_HQWatchRules.Aggression(m_IdentifiedBodies); } + int HunterTarget() { return ERR_HQWatchRules.Hunters(Aggression()); } protected bool m_CapLogged; int Count() { return m_Entries.Count(); } ERR_HQWatchEntry Find(string sourceId) { return m_BySource.Get(sourceId); } @@ -102,9 +109,9 @@ class ERR_HQWatch protected ERR_HQWatchEntry Create(string sourceId, int strength) { // Closed identities remain tombstones: replays cannot recreate a formation. - if (m_Entries.Count() >= 128) + if (m_Entries.Count() >= 256) { - if (!m_CapLogged) Print("Reintroduced [watch]: 128-formation check-in cap reached."); + if (!m_CapLogged) Print("Reintroduced [watch]: 256-formation check-in cap reached."); m_CapLogged = true; return null; } @@ -164,6 +171,9 @@ class ERR_HQWatch if (entry && (entry.Strength != report.CasualtyStrength || now < entry.LastBodyDeliveredAt)) return false; if (!entry) entry = Create(report.CasualtySourceId, report.CasualtyStrength); if (!entry || !entry.Casualties.AcceptBody(report.CasualtySlot)) return false; + if (entry.IdentifiedSlots.Contains(report.CasualtySlot)) return false; + entry.IdentifiedSlots.Insert(report.CasualtySlot); + m_IdentifiedBodies++; entry.LastBodyDeliveredAt = now; ApplyLosses(entry); return true; @@ -211,6 +221,6 @@ class ERR_HQWatch } void Clear() { - m_Entries.Clear(); m_BySource.Clear(); m_ConfirmedLosses = 0; m_CapLogged = false; + m_Entries.Clear(); m_BySource.Clear(); m_ConfirmedLosses = 0; m_IdentifiedBodies = 0; m_CapLogged = false; } } diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c new file mode 100644 index 0000000..aad73fa --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c @@ -0,0 +1,173 @@ +class ERR_HunterVehicle : ERR_ConvoyVehicle +{ + string Source; + float Deadline; + float NextOrder; + ref ERR_HQRoute Downlink; + vector Destination; + int SourceMessage; + override void Release(ERR_GameMode mode) + { + if (Released) return; + if (!mode || !mode.Active() || ERR_ConvoyRules.Captured(Entity, Group)) { super.Release(mode); return; } + Released = true; Downlink = null; ClearOrder(); + ERR_ForceRecord record = mode.Forces().FindGroup(Group); + if (record) record.Pinned = false; + if (Group) + { + Group.SetPermanentLOD(-1); + array agents = {}; Group.GetAgents(agents); + foreach (AIAgent agent : agents) if (agent) agent.SetPermanentLOD(-1); + ERR_HQ hq = ERR_HQ.Cast(mode.Command()); + if (hq) hq.ReleaseRecovery(Group); + } + // Surviving original crew remain registered for skill, reports, intel and + // local combat. Only the driving task ends; on-foot survivors can serve HQ. + } +} + +// An escalation allowance of fifteen physical hunter UAZs, separate from supply +// convoys. Losses remain physical; aggression never replenishes killed crews. +class ERR_HunterService : ERR_ConvoyService +{ + protected ref array m_Hunters = {}; + protected float m_NextHunter; + protected float m_NextSpawn; + protected int m_Target; + int Deployed() { return m_Hunters.Count(); } + int ActiveHunters() + { + int count; + foreach (ERR_HunterVehicle unit : m_Hunters) if (!unit.Released) count++; + return count; + } + override string Status() { return string.Format("aggression UAZs: %1 active, %2/%3 deployed allowance (maximum 15)", ActiveHunters(), Deployed(), m_Target); } + protected bool StartHunter(ERR_HQ hq, float now) + { + if (m_Hunters.Count() >= m_Target || m_Hunters.Count() >= 15 || m_Mode.Forces().Count() >= 250) return false; + array players = {}; ERR_Actors.Players(players); + ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement(); + for (int i = 0; i < m_Mode.Sites().Count(); i++) + { + ERR_SiteRuntime site = m_Mode.Sites().At((i + m_Hunters.Count()) % m_Mode.Sites().Count()); + if (!site || !site.Definition || (site.Definition.Kind != ERR_LocationKind.RADIO && site.Definition.Kind != ERR_LocationKind.DEPOT && site.Definition.Kind != ERR_LocationKind.OUTPOST)) continue; + if (site.Group && site.Group.GetFaction() != m_Mode.EnemyFaction()) continue; + vector position, angles; + vector seed = site.Definition.Position + Vector(Math.RandomFloat(-200, 200), 0, Math.RandomFloat(-200, 200)); + if (!ERR_SupplyConvoySystem.ApproachDirected(seed, position, angles) || !placement.Clear(position, angles[0])) continue; + bool nearPlayer; + foreach (IEntity player : players) if (player && vector.DistanceXZ(player.GetOrigin(), position) < 400) nearPlayer = true; + if (nearPlayer) continue; + ERR_HunterVehicle unit = new ERR_HunterVehicle(); + unit.Source = "aggression-uaz-" + (m_Hunters.Count() + 1).ToString(); + unit.Deadline = now + 30000; m_Hunters.Insert(unit); + unit.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.GroupPrefab(), position)); + unit.Entity = Vehicle.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.VehiclePrefab(0), position, angles)); + if (unit.Group) m_Mode.Own(unit.Group); + if (unit.Entity) m_Mode.Own(unit.Entity); + if (!unit.Group || !unit.Entity || !ERR_ConvoyRules.EmptyCrewTemplate(unit.Group) || !FillCrew(unit)) + { unit.Release(m_Mode); ERR_HQ.Event("HUNTER FAILED", unit.Source + " native vehicle/crew spawn failed; allowance consumed"); return false; } + FactionAffiliationComponent affiliation = FactionAffiliationComponent.Cast(unit.Entity.FindComponent(FactionAffiliationComponent)); + if (affiliation) affiliation.SetAffiliatedFaction(m_Mode.EnemyFaction()); + AICarMovementComponent movement = AICarMovementComponent.Cast(unit.Entity.FindComponent(AICarMovementComponent)); + if (movement) movement.SetCruiseSpeed(45); + ERR_HQ.Event("HUNTER SPAWN", string.Format("%1 reason=identified bodies %2 aggression=%3/30 allowance=%4/15 base=%5 position=%6 crew=%7", unit.Source, hq.Watch().IdentifiedBodies(), hq.Watch().Aggression(), m_Target, site.Definition.Id, position, unit.Members.Count())); + return true; + } + ERR_HQ.Event("HUNTER WAIT", "deployment deferred: friendly road obstructed, occupied, budget full or players within 400m"); + return false; + } + protected bool AdmitHunter(ERR_HunterVehicle unit, ERR_HQ hq, float now) + { + if (!unit.CrewValid(m_Mode)) return false; + array agents = {}; unit.Group.GetAgents(agents); + if (agents.Count() != unit.Members.Count()) return false; + foreach (AIAgent agent : agents) if (!agent || !unit.Members.Contains(agent.GetControlledEntity()) || !ERR_Actors.CanFight(agent.GetControlledEntity())) return false; + SCR_AIGroupUtilityComponent utility = unit.Group.GetGroupUtilityComponent(); + SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(unit.Entity.FindComponent(SCR_AIVehicleUsageComponent)); + if (!utility || !utility.m_VehicleMgr || !usage || !utility.m_VehicleMgr.TryAddVehicle(usage)) return false; + ERR_ForceRecord record = m_Mode.Forces().Register(unit.Source, ERR_ForceKind.CONVOY, unit.Entity.GetOrigin(), unit.Members.Count(), null, unit.Group, now); + if (!record) return false; + record.Pinned = true; unit.Registered = true; + m_Mode.RegisterGroup(unit.Group); hq.ReserveGroup(unit.Group); + utility.AddUsableVehicle(usage); unit.Group.SetPermanentLOD(0); unit.Group.ActivateAI(); + foreach (AIAgent active : agents) { active.SetPermanentLOD(0); active.ActivateAI(); } + ERR_HQ.Event("HUNTER READY", unit.Source + " driver and gunner seated; HQ radio registration active"); + return true; + } + protected void QueueDestination(ERR_HunterVehicle unit, ERR_HQ hq, float now) + { + if (now < unit.NextOrder || unit.Downlink) return; + unit.NextOrder = now + 30000; + array reports = {}; hq.SupportContacts(reports, now); + ERR_HQBelief best; + foreach (ERR_HQReport report : reports) + { + ERR_HQBelief belief = hq.Memory().Get(report.Target); + if (belief && belief.Actionable() && (!best || belief.ObservedAt > best.ObservedAt)) best = belief; + } + vector destination, angles; + string reason = "road patrol between friendly objectives"; + unit.SourceMessage = 0; + if (best) + { + if (!ERR_SupplyConvoySystem.ApproachDirected(best.PredictedPosition, destination, angles, 500)) return; + unit.SourceMessage = best.MessageId; reason = "intercept last delivered sighting; confidence=" + best.Confidence.ToString(); + } + else + { + if (unit.Waypoint && !unit.Waypoint.IsWithinCompletionRadius(unit.Group)) return; + bool found; + for (int i = 0; i < m_Mode.Sites().Count(); i++) + { + ERR_SiteRuntime site = m_Mode.Sites().At(Math.RandomInt(0, m_Mode.Sites().Count())); + if (!site || !site.Definition || !ERR_ObjectiveDefence.Objective(site.Definition.Kind) || (site.Group && site.Group.GetFaction() != m_Mode.EnemyFaction())) continue; + if (ERR_SupplyConvoySystem.ApproachDirected(site.Definition.Position, destination, angles) && vector.DistanceXZ(destination, unit.Entity.GetOrigin()) > 200) { found = true; break; } + } + if (!found) return; + } + unit.Destination = destination; + unit.Downlink = hq.Downlink(unit.Group, "HUNTER", unit.Source + " " + reason + " destination=" + destination.ToString(), unit.SourceMessage); + } + override void Tick(float now) + { + if (m_Stopped || !Replication.IsServer() || !m_Mode || !m_Mode.Active() || m_Mode != ERR_GameMode.Get() || m_Mode.Phase() <= ERR_Phase.PRISON || now < m_NextHunter) return; + m_NextHunter = now + 1000; + ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command()); if (!hq) return; + m_Target = hq.Watch().HunterTarget(); + if (now >= m_NextSpawn && Deployed() < m_Target) { m_NextSpawn = now + 30000; StartHunter(hq, now); } + foreach (ERR_HunterVehicle unit : m_Hunters) + { + if (unit.Released) continue; + if (ERR_ConvoyRules.Captured(unit.Entity, unit.Group)) { unit.Release(m_Mode); ERR_HQ.Event("HUNTER RELEASE", unit.Source + " player captured vehicle/crew; orders removed"); continue; } + if (!unit.Registered) + { + if (AdmitHunter(unit, hq, now)) continue; + if (now >= unit.Deadline) { InspectReadiness(unit, now); unit.Release(m_Mode); ERR_HQ.Event("HUNTER FAILED", unit.Source + " native readiness timed out"); } + continue; + } + if (!unit.CrewValid(m_Mode)) { unit.Release(m_Mode); ERR_HQ.Event("HUNTER RELEASE", unit.Source + " vehicle or original crew no longer operational; no replacement spawn"); continue; } + if (unit.Downlink) + { + int delivered = unit.Downlink.Advance(now, hq.Journal()); + if (delivered != 0) + { + if (delivered > 0) + { + unit.ClearOrder(); + unit.Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), unit.Destination)); + if (unit.Waypoint) { unit.Waypoint.SetCompletionRadius(50); unit.Group.AddWaypoint(unit.Waypoint); } + ERR_HQ.Event("HUNTER ORDER", unit.Source + " radio delivered; destination=" + unit.Destination.ToString(), unit.SourceMessage); + } + unit.Downlink = null; + } + } + QueueDestination(unit, hq, now); + } + } + override void Stop() + { + m_Stopped = true; + foreach (ERR_HunterVehicle unit : m_Hunters) { unit.Downlink = null; unit.Release(m_Mode); } + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryAmmoAccess.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryAmmoAccess.c index b5a6155..3208af0 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryAmmoAccess.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryAmmoAccess.c @@ -16,12 +16,15 @@ class ERR_InfantryAmmoAccess ActionsManagerComponent manager = ActionsManagerComponent.Cast(donor.FindComponent(ActionsManagerComponent)); if (!manager || !manager.IsEnabled()) return false; array contexts = {}; manager.GetContextList(contexts); - if (contexts.IsEmpty() || contexts.Count() > 64) return false; + // The stock Mi-8 alone authors 82 root contexts. Keep a finite bound + // that includes its real cargo-door action as well as ordinary trucks. + if (contexts.IsEmpty() || contexts.Count() > 128) return false; foreach (UserActionContext context : contexts) { if (!context) return false; array actions = {}; context.GetActionsList(actions); - if (actions.Count() > 16) return false; + // Native Mi-8 cargo-door context has 38 actions (including seat switches). + if (actions.Count() > 64) return false; bool storage; foreach (BaseUserAction action : actions) if (action && action.GetOwner() == donor && SCR_OpenVehicleStorageAction.Cast(action)) storage = true; diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryAmmoTransfer.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryAmmoTransfer.c index 1a1a663..86c3a65 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryAmmoTransfer.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryAmmoTransfer.c @@ -148,7 +148,8 @@ class ERR_InfantryAmmoTransfer float speed = physics.GetVelocity().Length(); float angular = physics.GetAngularVelocity().Length(); float distance = vector.Distance(m_Donor.GetOrigin(), m_Recipient.GetOrigin()); - if (!(speed >= 0 && speed <= 0.25 && angular >= 0 && angular <= 0.1 && distance >= 0 && distance <= 4)) return false; + if (!(speed >= 0 && speed <= 0.25 && angular >= 0 && angular <= 0.1 && distance >= 0 && distance <= 15)) return false; + if (distance > 4 && !m_Donor.FindComponent(VehicleHelicopterSimulation)) return false; if (!Compatible(m_Recipient, m_Magazine)) return false; m_Driver = driver; m_DonorRecord = donorRecord; m_RecipientRecord = recipientRecord; return true; diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryResupply.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryResupply.c index ffd082a..9f46788 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryResupply.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryResupply.c @@ -81,7 +81,7 @@ class ERR_InfantryResupply || !m_Truck.CrewValid(m_Mode) || ERR_ConvoyRules.Captured(m_Truck.Entity, m_Truck.Group)) return false; ERR_ForceRecord record = m_Mode.Forces().FindGroup(m_Truck.Group); return record && record.Physical && !record.Eliminated && record.Kind == ERR_ForceKind.CONVOY - && m_Convoy.Receipt.Records.Count() == 6 && record.Id == m_Convoy.Receipt.Records[1]; + && m_Convoy.Receipt.Records.Count() >= 6 && record.Id == m_Convoy.Receipt.Records[1]; } protected bool Quiet(ERR_HQUnit unit) { diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarBattery.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarBattery.c index f1a40f8..ffdef0b 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarBattery.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarBattery.c @@ -37,6 +37,7 @@ class ERR_MortarBattery ref ERR_MortarRefresh Refresh; bool RefreshRejected; // latched through an already committed projectile protected ref ERR_MortarReadiness m_Readiness; + protected ref ERR_MortarStation m_Station; protected ref ERR_MortarSupplyTransfer m_Transfer; protected ref array m_AttemptedShells = {}; protected bool m_Stopped; @@ -316,6 +317,7 @@ class ERR_MortarBattery m_Stopped = true; Cancel("HQ stopped"); if (m_Readiness) m_Readiness.Stop(); + if (m_Station) m_Station.Stop(); } ERR_MortarSupplyState SupplyState(float now) @@ -515,6 +517,7 @@ class ERR_MortarBattery void Update(float now) { if (m_Stopped) return; + if (m_Station) m_Station.Update(now); if (!StartupActivated) { if (!SpawnFailure.IsEmpty()) return; @@ -522,6 +525,8 @@ class ERR_MortarBattery ERR_MortarSupplyState startup = m_Readiness.Poll(now); if (startup != ERR_MortarSupplyState.READY) return; StartupActivated = true; + m_Station = new ERR_MortarStation(this); + m_Station.Update(now); } if (m_Transfer) m_Transfer.Poll(now); if (!Operational()) diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarLogistics.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarLogistics.c index 8c3d42d..b551c84 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarLogistics.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarLogistics.c @@ -15,7 +15,7 @@ class ERR_MortarLogisticsRules static bool Near(float distance) { return distance >= 0 && distance <= 12; } static bool ActiveTrip(bool server, bool stopped, int trip, int current, bool fresh, bool ownershipValid) { return server && !stopped && trip > 0 && trip == current && fresh && ownershipValid; } } -// One finite western-cache courier, owned and ticked exclusively by HQ. +// One finite depot courier shared by the authored batteries, owned and ticked by HQ. class ERR_MortarLogistics { protected ERR_HQ m_HQ; @@ -94,11 +94,14 @@ class ERR_MortarLogistics m_Started = true; ERR_ScenarioComponent scenario = ERR_ScenarioComponent.Cast(m_Mode.FindComponent(ERR_ScenarioComponent)); if (!scenario || !scenario.Sites()) return false; + // The retired vehicle-cache fixture retains its original courier source. + // Production uses the existing guard outpost; no replacement car/cache is created. m_Site = scenario.Sites().Find("everon-supply-2"); + if (!m_Site) m_Site = scenario.Sites().Find("everon-outpost-1"); if (!m_Site || !m_Site.Group || !m_Site.PatrolCycle || !scenario.Sites().Owns(m_Site.Container)) return false; m_Cache = m_Site.Container; m_Group = m_Site.Group; array batteries = {}; m_HQ.Mortars().Batteries(batteries); - if (batteries.Count() != 1) return false; + if (batteries.IsEmpty()) return false; m_Battery = batteries[0]; m_OriginalGun = m_Battery.Gun; m_OriginalBatteryGroup = m_Battery.Group; InventoryStorageManagerComponent inventory = Inventory(m_Cache); if (!inventory || !inventory.CanInsertResource(ERR_MortarBattery.SHELL, EStoragePurpose.PURPOSE_DEPOSIT)) return false; @@ -125,6 +128,27 @@ class ERR_MortarLogistics } return true; } + // Select only between trips. A pending radio report or native item transfer + // keeps its original battery identity and cannot be retargeted in flight. + protected void SelectBattery() + { + if (m_State != 1 || m_Report || m_Move || m_Order) return; + if (m_Battery && TargetAvailable() && ERR_MortarLogisticsRules.Need(m_Battery.CountShells())) return; + array batteries = {}; m_HQ.Mortars().Batteries(batteries); + ERR_MortarBattery selected; + float nearest = float.MAX; + foreach (ERR_MortarBattery candidate : batteries) + { + if (!candidate.StartupActivated || candidate.Stopped() || !candidate.Operational() || !ERR_MortarLogisticsRules.Need(candidate.CountShells())) continue; + float distance = vector.DistanceXZ(m_Cache.GetOrigin(), candidate.Position); + if (distance >= nearest) continue; + nearest = distance; selected = candidate; + } + if (!selected || selected == m_Battery) return; + m_Battery = selected; m_OriginalGun = selected.Gun; m_OriginalBatteryGroup = selected.Group; + m_BatteryMembers.Clear(); + m_NextReport = 0; + } protected bool OriginalGroup() { if (!m_Site || m_Site.Group != m_Group || m_Site.Container != m_Cache || !m_Group || !m_Cache || m_Group.GetPlayerCount(true) > 0 || m_Group.GetFaction() != m_Mode.EnemyFaction()) return false; @@ -294,11 +318,12 @@ class ERR_MortarLogistics foreach (IEntity originalShell : m_Stock) if (!originalShell || !shells.Contains(originalShell)) failed = true; if (m_Stock.Count() != INITIAL_SHELLS) failed = true; if (failed) { Audit("STARTUP FAILED", "one-time native stock allocation incomplete; no refill"); Stop(); return; } - m_State = 1; Audit("READY", "12 distinct original HE shells physically confirmed in existing western cache"); + m_State = 1; Audit("READY", "12 distinct original HE shells physically confirmed in existing depot cache"); } if (m_State == 1) { if (m_Move) { m_Move.Poll(now); if (m_Move.Unresolved()) return; m_Move = null; } + SelectBattery(); if (!OriginalGroup() || !BindBatteryMembers() || !TargetAvailable()) return; bool available; foreach (IEntity stocked : m_Stock) if (Contains(m_Cache, stocked) && !m_AttemptedPickup.Contains(stocked)) available = true; if (!available) return; @@ -307,7 +332,7 @@ class ERR_MortarLogistics int nextTrip = m_Trip + 1; if (!m_HQ.ReserveLogistics(m_Group, nextTrip)) return; m_Trip = nextTrip; m_ConsumedMessage = m_Report.MessageId; - m_Order = m_HQ.Downlink(m_Group, "MORTAR SUPPLY", "collect finite western-cache HE; walk to reported battery", m_Report.MessageId); + m_Order = m_HQ.Downlink(m_Group, "MORTAR SUPPLY", "collect finite depot HE; walk to reported battery", m_Report.MessageId); m_State = 2; if (!m_Order) { Finish("no native courier downlink"); return; } Audit("QUEUED", "original patrol reserved pending physical radio command delivery"); diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarSalvo.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarSalvo.c index 1a4a07b..9bcf031 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarSalvo.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarSalvo.c @@ -2,7 +2,7 @@ class ERR_MortarSalvo { static const int ROUNDS = 8; - static const float RADIUS_METRES = 40; + static const float RADIUS_METRES = 80; static const float TIMEOUT_MS = 120000; static vector Offset(int site, int order, int shot) diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarStation.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarStation.c new file mode 100644 index 0000000..2602a99 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarStation.c @@ -0,0 +1,126 @@ +// A permanent assignment to the original physical gun, independent of fire orders. +class ERR_MortarStation +{ + protected ERR_MortarBattery m_Battery; + protected ref array m_Controllers = {}; + protected ref SCR_AIGetInVehicle m_Mount; + protected IEntity m_Gunner; + protected float m_NextMount; + protected bool m_Stopped; + void ERR_MortarStation(ERR_MortarBattery battery) { m_Battery = battery; } + IEntity Gunner() { return m_Gunner; } + bool GunIntact() + { + if (!m_Battery || !m_Battery.Gun) return false; + DamageManagerComponent damage = DamageManagerComponent.Cast(m_Battery.Gun.FindComponent(DamageManagerComponent)); + return damage && !damage.IsDestroyed(); + } + bool Holds(IEntity actor) + { + return Replication.IsServer() && !m_Stopped && GunIntact() && ERR_HQ.MissionActive() && actor && !ERR_Actors.Player(actor) && ERR_Actors.Group(actor) == m_Battery.Group; + } + bool HoldsSeat(IEntity actor, IEntity vehicle) + { + return Holds(actor) && vehicle == m_Battery.Gun && CompartmentAccessComponent.GetVehicleIn(actor) == vehicle; + } + bool HoldsPosition(IEntity actor) { return Holds(actor) && actor != m_Gunner && ERR_Actors.CanFight(actor); } + void Stop() + { + if (m_Stopped) return; + m_Stopped = true; + if (m_Mount) m_Mount.Complete(); + m_Mount = null; + foreach (SCR_CharacterControllerComponent controller : m_Controllers) + if (controller) controller.ERR_ClearMortarStation(this); + m_Controllers.Clear(); m_Gunner = null; + } + void Update(float now) + { + if (m_Stopped || !Replication.IsServer()) return; + if (!GunIntact()) + { + Stop(); + ERR_HQ hq = ERR_HQ.Get(); + if (hq) hq.ReleaseMortarCrew(m_Battery.Group); + m_Battery.Audit("CREW RELEASED", "original mortar destroyed or removed; surviving crew may leave"); + return; + } + if (!m_Battery.Group || !ERR_HQ.MissionActive()) return; + array agents = {}; m_Battery.Group.GetAgents(agents); + foreach (AIAgent agent : agents) + { + if (!agent || !agent.GetControlledEntity() || ERR_Actors.Player(agent.GetControlledEntity())) continue; + SCR_CharacterControllerComponent controller = SCR_CharacterControllerComponent.Cast(agent.GetControlledEntity().FindComponent(SCR_CharacterControllerComponent)); + if (!controller || m_Controllers.Contains(controller)) continue; + controller.ERR_SetMortarStation(this); m_Controllers.Insert(controller); + } + SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(m_Battery.Gun.FindComponent(SCR_AIVehicleUsageComponent)); + if (!usage || !usage.GetTurretCompartmentSlot()) return; + TurretCompartmentSlot seat = usage.GetTurretCompartmentSlot(); + IEntity occupant = seat.GetOccupant(); + if (occupant) + { + if (Holds(occupant) && ERR_Actors.CanFight(occupant)) m_Gunner = occupant; + return; // never evict a player, incapacitated occupant or unrelated crew + } + if (!Holds(m_Gunner) || !ERR_Actors.CanFight(m_Gunner)) + { + if (m_Mount) m_Mount.Complete(); + m_Mount = null; m_Gunner = null; + foreach (AIAgent candidate : agents) + { + if (!candidate) continue; + IEntity actor = candidate.GetControlledEntity(); + if (Holds(actor) && actor != m_Battery.RadioOperator && ERR_Actors.CanFight(actor)) { m_Gunner = actor; break; } + } + } + if (!m_Gunner || m_Battery.Activity || now < m_NextMount) return; + SCR_ChimeraAIAgent gunnerAgent = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(m_Gunner)); + if (!gunnerAgent || !gunnerAgent.m_UtilityComponent) return; + // Native deselection can reset terminal action state to EVALUATED. Queue + // membership, rather than that transient state, determines whether it is pending. + array actions = {}; gunnerAgent.m_UtilityComponent.GetActions(actions); + if (m_Mount && actions.Contains(m_Mount)) return; + // Native walking/boarding only; no teleport, respawn, or ammunition creation. + m_Mount = new SCR_AIGetInVehicle(gunnerAgent.m_UtilityComponent, null, m_Battery.Gun, seat, EAICompartmentType.Turret, 1000); + gunnerAgent.m_UtilityComponent.AddAction(m_Mount); + m_NextMount = now + 10000; + } +} + +modded class SCR_CharacterControllerComponent +{ + protected ERR_MortarStation m_ERRMortarStation; + protected bool m_ERRMortarSpeedHeld; + void ERR_SetMortarStation(ERR_MortarStation station) { m_ERRMortarStation = station; } + void ERR_ClearMortarStation(ERR_MortarStation station) + { + if (m_ERRMortarStation != station) return; + m_ERRMortarStation = null; + if (m_ERRMortarSpeedHeld) OverrideMaxSpeed(-1); + m_ERRMortarSpeedHeld = false; + } + ERR_MortarStation ERR_MortarStationDuty() { return m_ERRMortarStation; } + protected override void OnApplyControls(IEntity owner, float timeSlice) + { + super.OnApplyControls(owner, timeSlice); + // Keep support crew in their positions; aiming, firing, radio and damage remain active. + bool held = m_ERRMortarStation && m_ERRMortarStation.HoldsPosition(owner); + if (held) OverrideMaxSpeed(0); + else if (m_ERRMortarSpeedHeld) OverrideMaxSpeed(-1); + m_ERRMortarSpeedHeld = held; + } +} + +modded class SCR_AIGetOutVehicle +{ + override float CustomEvaluate() + { + if (m_Utility && m_Utility.m_OwnerEntity) + { + SCR_CharacterControllerComponent controller = SCR_CharacterControllerComponent.Cast(m_Utility.m_OwnerEntity.FindComponent(SCR_CharacterControllerComponent)); + if (controller && controller.ERR_MortarStationDuty() && controller.ERR_MortarStationDuty().HoldsSeat(m_Utility.m_OwnerEntity, m_Vehicle.m_Value)) return 0; + } + return super.CustomEvaluate(); + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarSystem.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarSystem.c index b09e66e..ea34118 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarSystem.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_MortarSystem.c @@ -30,12 +30,32 @@ class ERR_MortarSystem { if (!Replication.IsServer() || m_Prepared || m_Stopped) return false; m_Prepared = true; - vector position = ERR_Actors.Ground("6000 0 7000"); - if (position[1] < 1) return false; + ERR_SiteService sites = ERR_GameMode.Get().Sites(); + if (!sites) { Print("Reintroduced [mortars]: site service unavailable", LogLevel.ERROR); return false; } + for (int index = 0; index < sites.Count(); index++) + { + ERR_SiteRuntime site = sites.At(index); + if (site.Definition.Kind != ERR_LocationKind.MORTAR) continue; + PrintFormat("Reintroduced [mortars]: preparing %1 location=%2 authored=%3", site.Definition.Id, site.Location.Position(), site.Definition.Position); + if (!CreateBattery(site.Location.Position(), m_Batteries.Count() + 1)) return false; + } + #ifdef WORKBENCH + // Old geometry-dependent fixtures explicitly load LegacySites.conf. + string fixture; + if (m_Batteries.IsEmpty() && System.GetCLIParam("errSmokeOutcome", fixture) && !fixture.IsEmpty()) + return CreateBattery("6000 0 7000", 1); + #endif + return !m_Batteries.IsEmpty(); + } + protected bool CreateBattery(vector authoredPosition, int id) + { + vector position = ERR_Actors.Ground(authoredPosition); + if (position[1] < 1) { PrintFormat("Reintroduced [mortars]: below land at %1", position); return false; } IEntity gun = ERR_Actors.Spawn(ERR_MortarBattery.GUN, position); SCR_AIGroup group = SCR_AIGroup.Cast(ERR_Actors.Spawn(MortarGroup(), ERR_Actors.Ground(position + "0 0 4"))); if (!gun || !group) { + PrintFormat("Reintroduced [mortars]: creation failed gun=%1 group=%2 at %3", gun != null, group != null, position); ERR_Actors.DeleteSpawned(gun); ERR_MortarReadiness.RetireGroup(group); ERR_Actors.DeleteSpawned(group); @@ -43,6 +63,7 @@ class ERR_MortarSystem } if (group.GetFaction() != ERR_GameMode.Get().EnemyFaction()) { + Print("Reintroduced [mortars]: crew faction mismatch", LogLevel.ERROR); ERR_Actors.DeleteSpawned(gun); ERR_MortarReadiness.RetireGroup(group); ERR_Actors.DeleteSpawned(group); @@ -51,7 +72,7 @@ class ERR_MortarSystem FactionAffiliationComponent affiliation = FactionAffiliationComponent.Cast(gun.FindComponent(FactionAffiliationComponent)); if (affiliation) affiliation.SetAffiliatedFaction(ERR_GameMode.Get().EnemyFaction()); ERR_MortarBattery battery = new ERR_MortarBattery(); - battery.Bind(1, group, gun, GetGame().GetWorld().GetWorldTime()); + battery.Bind(id, group, gun, GetGame().GetWorld().GetWorldTime()); m_Batteries.Insert(battery); ERR_GameMode mode = ERR_GameMode.Get(); mode.Own(gun); diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_NativeAirAssaultProbe.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_NativeAirAssaultProbe.c new file mode 100644 index 0000000..d4f689e --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_NativeAirAssaultProbe.c @@ -0,0 +1,114 @@ +#ifdef WORKBENCH +modded class ERR_AirAssaultLoad +{ + int ERR_TestGrounded() + { + int count; + foreach (ERR_AirPassenger passenger : m_Passengers) + if (passenger.Jumped && ERR_Actors.CanFight(passenger.Actor) && !CompartmentAccessComponent.GetVehicleIn(passenger.Actor) && passenger.Actor.GetOrigin()[1] - SCR_TerrainHelper.GetTerrainY(passenger.Actor.GetOrigin()) < 3) count++; + return count; + } + int ERR_TestReleased() + { + int count; + foreach (ERR_AirAssaultTeam team : m_Teams) { if (team.Released) count++; + } + return count; + } + void ERR_TestCoverEnd(float now) { m_CoverUntil = now; + } +} +modded class ERR_GameMode +{ + protected int m_ERRAirStage; + protected float m_ERRAirDeadline; + protected ERR_AirAssaultLoad m_ERRAirLoad; + protected Vehicle m_ERRAirframe; + protected bool m_ERRAirStopped; + protected override void EOnInit(IEntity owner) + { + super.EOnInit(owner); + string fixture; + if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", fixture) || fixture != "air-assault") return; + m_ERRAirDeadline = GetWorld().GetWorldTime() + 650000; + GetGame().GetCallqueue().CallLater(ERR_AirTestTick, 500, true); + OnStop().Insert(ERR_AirTestStop); + } + protected void ERR_AirTestStop() { m_ERRAirStopped = true; + GetGame().GetCallqueue().Remove(ERR_AirTestTick); + } + protected bool ERR_AirCheck(bool result, string detail) + { + if (result) Print("ERR SMOKE PASS: air assault " + detail); + else { Print("ERR SMOKE FAIL: air assault " + detail, LogLevel.ERROR); + ERR_AirTestStop(); + } + return result; + } + protected void ERR_AirTestTick() + { + float now = GetWorld().GetWorldTime(); + if (now > m_ERRAirDeadline) { ERR_AirCheck(false, "deadline stage=" + m_ERRAirStage.ToString() + " " + ERR_HQ.Get().Recon().Status()); + return; + } + if (m_ERRAirStopped || !Active() || Phase() == ERR_Phase.PREPARING) return; + ERR_HQ hq = ERR_HQ.Get(); + if (m_ERRAirStage == 0) + { + array players = {}; + ERR_Actors.Players(players, true); + if (players.IsEmpty()) return; + if (!UnlockPrison(players[0])) return; + if (hq.SupplyConvoys()) hq.SupplyConvoys().Stop(); + hq.Recon().ERR_TestDeparture(now + 3000); + m_ERRAirStage = 1; + return; + } + ERR_ReconFlightComponent flight = hq.Recon().Current(); + if (!flight) { if (hq.Recon().ERR_TestWaves() > 0) ERR_AirCheck(false, "aircraft lost: " + hq.Recon().Status()); + return; + } + if (m_ERRAirStage == 1) + { + if (flight.Phase() == ERR_ReconPhase.CREW) return; + m_ERRAirLoad = flight.Assault(); + m_ERRAirframe = flight.Aircraft(); + if (!ERR_AirCheck(m_ERRAirLoad && m_ERRAirLoad.Count() == 8 && m_ERRAirLoad.Aboard() == 8, "eight original passengers physically seated before takeoff")) return; + m_ERRAirStage = 2; + } + if (m_ERRAirStage == 2) + { + if (!m_ERRAirLoad.Ready()) return; + ERR_AirDropOrder order = new ERR_AirDropOrder(); + order.ExpiresAt = now + 180000; + order.Target = flight.Home(); + order.Report = new ERR_HQReport(); + order.Report.Position = order.Target; + order.Report.ObservedAt = now; + // Controlled geometry/transfer fixture; policy and physical radio authorization + // are verified independently. This does not fabricate a live sighting result. + if (!ERR_AirCheck(m_ERRAirLoad.Begin(order, now), "controlled order finds real clear landing corridor")) return; + m_ERRAirStage = 3; + return; + } + if (m_ERRAirStage == 3) + { + if (m_ERRAirLoad.ERR_TestGrounded() < 8 || m_ERRAirLoad.ERR_TestReleased() < 2) return; + if (!ERR_AirCheck(flight.Aircraft() == m_ERRAirframe && flight.ERR_TestCrew() >= 2, "same eight actors landed and two original groups released; original aircrew retained")) return; + vector center; + if (!ERR_AirCheck(m_ERRAirLoad.Cover(now, center) && vector.DistanceXZ(center, flight.Home()) < 1, "post-drop cover active")) return; + m_ERRAirLoad.ERR_TestCoverEnd(now); + m_ERRAirStage = 4; + return; + } + if (m_ERRAirStage == 4) + { + if (flight.Assault() == m_ERRAirLoad || flight.Phase() != ERR_ReconPhase.TAKEOFF) return; + if (!ERR_AirCheck(flight.Aircraft() == m_ERRAirframe && flight.Assault().Aboard() == 8, "same helicopter returned, serviced and departed with another native squad")) return; + if (!ERR_AirCheck(hq.Airfield().Cargo() && hq.Airfield().Cargo().AtAirport(), "separate cargo helicopter waiting at airport")) return; + ERR_AirTestStop(); + Print("ERR SMOKE COMPLETE: native airborne cycle completed"); + } + } +} +#endif diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_NativeAirLogisticsProbe.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_NativeAirLogisticsProbe.c new file mode 100644 index 0000000..375104e --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_NativeAirLogisticsProbe.c @@ -0,0 +1,174 @@ +#ifdef WORKBENCH +modded class ERR_AirfieldSupport +{ + bool ERR_TestFailed() { return m_Mission && m_Mission.Finished() && m_Mission.ERR_TestDelivered() == 0; } + int ERR_TestDelivered() { if (m_Mission) return m_Mission.ERR_TestDelivered(); + return 0; } +} +modded class ERR_AirAmmoMission +{ + int ERR_TestDelivered() { return m_Delivered; } +} +modded class ERR_HQ +{ + void ERR_TestCheckin(SCR_AIGroup group) + { + foreach (ERR_HQUnit unit : m_Units) if (unit.Group == group) unit.NextStatus = 0; + } +} +modded class ERR_GameMode +{ + protected int m_ERRLogisticsStage; + protected float m_ERRLogisticsDeadline, m_ERRLogisticsRadioDeadline, m_ERRLogisticsDiagnostic; + protected SCR_AIGroup m_ERRLogisticsRecipient; + protected ref ERR_PrisonPatrol m_ERRLogisticsRadio = new ERR_PrisonPatrol(); + protected IEntity m_ERRForensicBody; + protected bool m_ERRForensicPassed, m_ERRDeliveryPassed; + protected override void EOnInit(IEntity owner) + { + super.EOnInit(owner); + string fixture; + if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", fixture) || fixture != "air-logistics") return; + m_ERRLogisticsDeadline = GetWorld().GetWorldTime() + 720000; + GetGame().GetCallqueue().CallLater(ERR_LogisticsTestTick, 1000, true); + OnStop().Insert(ERR_LogisticsTestStop); + } + protected void ERR_LogisticsTestStop() { GetGame().GetCallqueue().Remove(ERR_LogisticsTestTick); } + protected void ERR_LogisticsFail(string reason) + { + Print("ERR SMOKE FAIL: air logistics " + reason, LogLevel.ERROR); + ERR_LogisticsTestStop(); + } + protected void ERR_LogisticsBody() + { + array locations = {}; + Locations(locations); + foreach (ERR_LocationComponent location : locations) + { + if (location.Kind() != ERR_LocationKind.RADIO) continue; + ERR_SiteRuntime site = Sites().Find(location.Id()); + if (!site || !site.Group) continue; + array agents = {}; + site.Group.GetAgents(agents); + foreach (AIAgent agent : agents) + { + IEntity actor = agent.GetControlledEntity(); + ERR_BodyEvidence record = ERR_GameMode.Get().Evidence().Find(actor); + if (!record || record.Issued.IsEmpty() || !ERR_Actors.CanFight(actor)) continue; + m_ERRForensicBody = actor; + // Test setup removes an actually issued folder. Production inspection only + // sees it missing; it cannot consult a player's inventory or discovery map. + foreach (ERR_IssuedIntel issued : record.Issued) if (issued.Folder) SCR_EntityHelper.DeleteEntityAndChildren(issued.Folder); + DamageManagerComponent damage = DamageManagerComponent.Cast(actor.FindComponent(DamageManagerComponent)); + damage.SetHealthScaled(0); + SCR_GarbageSystem garbage = SCR_GarbageSystem.GetByEntityWorld(actor); + if (!garbage || garbage.Insert(actor, 1)) { ERR_LogisticsFail("unsearched military body was not retained"); return; } + Print("ERR SMOKE PASS: air logistics native corpse retention; actual issued document now absent"); + return; + } + } + } + protected void ERR_LogisticsTestTick() + { + float now = GetWorld().GetWorldTime(); + if (now > m_ERRLogisticsDeadline) { ERR_LogisticsFail("deadline stage=" + m_ERRLogisticsStage.ToString() + "; " + ERR_HQ.Get().Airfield().Status() + "; " + ERR_HQ.Get().Recovery().Status()); return; } + if (!Active() || Phase() == ERR_Phase.PREPARING) return; + ERR_HQ hq = ERR_HQ.Get(); + if (m_ERRLogisticsStage == 0) + { + array players = {}; + ERR_Actors.Players(players, true); + if (players.IsEmpty() || !UnlockPrison(players[0])) return; + if (hq.SupplyConvoys()) hq.SupplyConvoys().Stop(); + m_ERRLogisticsStage = 1; + } + if (!m_ERRForensicBody) ERR_LogisticsBody(); + if (m_ERRForensicBody && !m_ERRForensicPassed) + { + ERR_BodyEvidence evidence = ERR_GameMode.Get().Evidence().Find(m_ERRForensicBody); + if (evidence && evidence.Searched && evidence.Reported) + { + Print("ERR SMOKE PASS: air logistics special native recovery team searched body and delivered missing-intel report"); + m_ERRForensicPassed = true; + } + } + if (now >= m_ERRLogisticsDiagnostic) + { + m_ERRLogisticsDiagnostic = now + 30000; + Print("ERR AIR LOGISTICS STATUS: " + hq.Airfield().Status() + "; " + hq.Recovery().Status()); + } + ERR_ReconFlightComponent cargo = hq.Airfield().Cargo(); + if (!cargo || !cargo.AtAirport()) { if (m_ERRLogisticsStage < 3) return; } + if (m_ERRLogisticsStage == 1) + { + ActionsManagerComponent accessManager = ActionsManagerComponent.Cast(cargo.Aircraft().FindComponent(ActionsManagerComponent)); + if (!accessManager) { ERR_LogisticsFail("no aircraft action manager"); return; } + array accessContexts = {}; + accessManager.GetContextList(accessContexts); + PrintFormat("ERR AIR CARGO ACCESS: enabled=%1 contexts=%2", accessManager.IsEnabled(), accessContexts.Count()); + foreach (UserActionContext accessContext : accessContexts) + { + if (!accessContext) { Print("ERR AIR CARGO ACCESS: null context"); continue; } + array accessActions = {}; + accessContext.GetActionsList(accessActions); + if (accessActions.Count() > 64) PrintFormat("ERR AIR CARGO ACCESS: dense context=%1 actions=%2", accessContext.GetContextName(), accessActions.Count()); + foreach (BaseUserAction accessAction : accessActions) + { + if (!SCR_OpenVehicleStorageAction.Cast(accessAction)) continue; + vector accessTransform[4]; + bool transformed = accessContext.GetTransformationWorld(accessTransform); + PrintFormat("ERR AIR CARGO ACCESS: storage context=%1 rootOwner=%2 transformed=%3 point=%4", accessContext.GetContextName(), accessAction.GetOwner() == cargo.Aircraft(), transformed, accessTransform[3]); + } + } + array initialAccess = {}; + if (!ERR_InfantryAmmoAccess.Points(cargo.Aircraft(), initialAccess)) { ERR_LogisticsFail("airport cargo-access preflight refused; see context diagnostics"); return; } + + vector position; + if (!ERR_ReconAirports.FindClearPad(cargo.Home() + "250 0 0", position)) return; + m_ERRLogisticsRecipient = SCR_AIGroup.Cast(ERR_Actors.Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", position)); + if (!m_ERRLogisticsRecipient || !ERR_PrisonPatrol.Configure(m_ERRLogisticsRecipient, FactionSelection().Choice("enemy").Group, EnemyFaction())) { ERR_LogisticsFail("recipient creation failed"); return; } + Own(m_ERRLogisticsRecipient); + m_ERRLogisticsRecipient.ActivateAI(); + m_ERRLogisticsRadioDeadline = now + 60000; + m_ERRLogisticsStage = 2; + return; + } + if (m_ERRLogisticsStage == 2) + { + if (now > m_ERRLogisticsRadioDeadline) { ERR_LogisticsFail("recipient loadout/radio timeout"); return; } + if (!m_ERRLogisticsRecipient.IsExpandComplete()) return; + if (m_ERRLogisticsRadio.Radio(this, m_ERRLogisticsRecipient, m_ERRLogisticsRadioDeadline) != ERR_PrepareResult.READY) return; + array agents = {}; + m_ERRLogisticsRecipient.GetAgents(agents); + foreach (AIAgent agent : agents) + { + agent.SetPermanentLOD(0); agent.ActivateAI(); + IEntity actor = agent.GetControlledEntity(); + InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent)); + array items = {}; ERR_Inventory.Items(inventory, items); + foreach (IEntity item : items) + { + BaseMagazineComponent magazine = BaseMagazineComponent.Cast(item.FindComponent(BaseMagazineComponent)); + if (magazine) magazine.SetAmmoCount(0); + } + } + RegisterGroup(m_ERRLogisticsRecipient); + hq.ERR_TestCheckin(m_ERRLogisticsRecipient); + Print("ERR SMOKE PASS: air logistics native infantry low-ammunition request setup; waiting for real radio/HQ dispatch"); + m_ERRLogisticsStage = 3; + } + if (m_ERRLogisticsStage == 3 && hq.Airfield().ERR_TestFailed()) { ERR_LogisticsFail("first delivery ended without a native transfer; see AIR AMMUNITION journal reason"); return; } + if (m_ERRLogisticsStage == 3 && hq.Airfield().ERR_TestDelivered() > 0) + { + Print("ERR SMOKE PASS: air logistics aircraft transported airport-loaded magazine and native inventory transfer confirmed"); + m_ERRDeliveryPassed = true; + m_ERRLogisticsStage = 4; + } + if (m_ERRDeliveryPassed && m_ERRForensicPassed) + { + Print("ERR SMOKE COMPLETE: native radio-requested air ammunition delivery and physical forensic recovery"); + ERR_LogisticsTestStop(); + } + } +} +#endif diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_NativeAirReconProbe.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_NativeAirReconProbe.c new file mode 100644 index 0000000..5e3483f --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_NativeAirReconProbe.c @@ -0,0 +1,169 @@ +#ifdef WORKBENCH +modded class ERR_AirReconSystem +{ + void ERR_TestDeparture(float due) { m_Schedule.Started = true; m_Schedule.Deadline = due; m_PadAttempts = 0; } + float ERR_TestDeadline() { return m_Schedule.Deadline; } + int ERR_TestWaves() { return m_Serial; } + bool ERR_TestAirport(vector pad) + { + foreach (vector airport : m_Airports.m_Positions) if (vector.DistanceXZ(airport, pad) < 250) return true; + return false; + } +} +modded class ERR_ReconFlightComponent +{ + int ERR_TestCrew() { return m_OriginalCrew.Count(); } + bool ERR_TestPilot() { return LivingPilot(); } + bool ERR_TestSeen(IEntity actor) { return m_Observation && m_Observation.Target == actor; } + protected override void Search(float now) + { + super.Search(now); + string fixture; + if (System.GetCLIParam("errSmokeOutcome", fixture) && fixture == "air-recon") + m_Center = m_Home + Vector(0, 0, 800); // Controlled runway-sector encounter, native perception unchanged. + } +} +modded class ERR_GameMode +{ + protected int m_ERRReconStage; + protected float m_ERRReconDeadline, m_ERRReconDue, m_ERRReconWait; + protected Vehicle m_ERRReconFirst; + protected IEntity m_ERRReconPlayer; + protected bool m_ERRReconDamage, m_ERRReconTakeoverOnly; + protected override void EOnInit(IEntity owner) + { + super.EOnInit(owner); + string fixture; + if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", fixture) || fixture != "air-recon") return; + string takeoverOnly; m_ERRReconTakeoverOnly = System.GetCLIParam("errReconTakeoverOnly", takeoverOnly); + m_ERRReconDeadline = GetWorld().GetWorldTime() + 540000; + GetGame().GetCallqueue().CallLater(ERR_ReconTestTick, 500, true); + OnStop().Insert(ERR_ReconTestStop); + } + protected void ERR_ReconTestStop() + { + GetGame().GetCallqueue().Remove(ERR_ReconTestTick); + if (m_ERRReconPlayer) + { + DamageManagerComponent damage = DamageManagerComponent.Cast(m_ERRReconPlayer.FindComponent(DamageManagerComponent)); + if (damage) damage.EnableDamageHandling(m_ERRReconDamage); + } + } + protected bool ERR_ReconCheck(bool result, string text) + { + if (result) { Print("ERR SMOKE PASS: air recon " + text); return true; } + Print("ERR SMOKE FAIL: air recon " + text, LogLevel.ERROR); ERR_ReconTestStop(); return false; + } + protected void ERR_ReconTestTick() + { + float now = GetWorld().GetWorldTime(); + if (now > m_ERRReconDeadline) { ERR_ReconCheck(false, "deadline stage=" + m_ERRReconStage.ToString() + "; " + ERR_HQ.Get().Recon().Status()); return; } + if (!Active() || Phase() == ERR_Phase.PREPARING) return; + ERR_AirReconSystem system = ERR_HQ.Get().Recon(); + if (m_ERRReconStage == 0) + { + array players = {}; ERR_Actors.Players(players, true); + if (players.IsEmpty()) return; + m_ERRReconPlayer = players[0]; + if (!ERR_ReconCheck(UnlockPrison(m_ERRReconPlayer), "normal prison escape enables departure countdown")) return; + if (ERR_HQ.Get().SupplyConvoys()) ERR_HQ.Get().SupplyConvoys().Stop(); + DamageManagerComponent damage = DamageManagerComponent.Cast(m_ERRReconPlayer.FindComponent(DamageManagerComponent)); + m_ERRReconDamage = damage.IsDamageHandlingEnabled(); damage.EnableDamageHandling(false); + ChimeraCharacter actor = ChimeraCharacter.Cast(m_ERRReconPlayer); + vector transform[4]; actor.GetWorldTransform(transform); transform[3] = ERR_Actors.Ground("4550 0 11080"); actor.Teleport(transform); + m_ERRReconDue = now + 3000; system.ERR_TestDeparture(m_ERRReconDue); + m_ERRReconStage = 1; return; + } + ERR_ReconFlightComponent flight = system.Current(); + if (m_ERRReconStage == 1) + { + if (now < m_ERRReconDue) { if (!ERR_ReconCheck(!flight && system.ERR_TestWaves() == 0, "no aircraft before scheduled deadline")) return; return; } + if (!flight || flight.Phase() == ERR_ReconPhase.CREW) return; + if (!ERR_ReconCheck(flight.Phase() == ERR_ReconPhase.TAKEOFF && flight.ERR_TestCrew() >= 2 && flight.ERR_TestPilot(), "native original pilot/gunners seated before takeoff")) return; + if (!ERR_ReconCheck(system.ERR_TestAirport(flight.Home()) && system.ERR_TestWaves() == 1 && system.ERR_TestDeadline() < 0, "one actual airfield departure disarms launch timer")) return; + m_ERRReconFirst = flight.Aircraft(); + if (m_ERRReconTakeoverOnly) { m_ERRReconStage = 8; return; } + ChimeraCharacter encounter = ChimeraCharacter.Cast(m_ERRReconPlayer); + vector encounterTransform[4]; encounter.GetWorldTransform(encounterTransform); + encounterTransform[3] = ERR_Actors.Ground(flight.Home() + Vector(0, 0, 400)); encounter.Teleport(encounterTransform); + if (!ERR_ReconCheck(ERR_Actors.CanFight(m_ERRReconPlayer), "visible encounter player is alive and conscious")) return; + m_ERRReconStage = 2; return; + } + if (m_ERRReconStage == 2) + { + if (!flight || flight.Phase() == ERR_ReconPhase.RELEASED) { ERR_ReconCheck(false, system.Status()); return; } + float distance = vector.DistanceXZ(flight.Aircraft().GetOrigin(), flight.Home()); + if (distance < 300 || flight.Phase() < ERR_ReconPhase.SEARCH || !flight.ERR_TestSeen(m_ERRReconPlayer)) return; + array contacts = {}; ERR_HQ.Get().SupportContacts(contacts, now); + bool delivered; + foreach (ERR_HQReport contact : contacts) if (contact.Source == flight.Crew() && contact.Target == m_ERRReconPlayer) delivered = true; + if (!delivered) return; + if (!ERR_ReconCheck(flight.Phase() == ERR_ReconPhase.SHADOW, "real native player sighting enters shadow orbit and reaches HQ through crew radio")) return; + if (!ERR_ReconCheck(ERR_Actors.Alive(flight.Aircraft()) && flight.ERR_TestPilot() && flight.Aircraft().GetOrigin()[1] - SCR_TerrainHelper.GetTerrainY(flight.Aircraft().GetOrigin()) > 80, "airport takeoff and more than 300 metres of terrain-cleared search flight")) return; + system.Return(); + if (!ERR_ReconCheck(flight.Phase() == ERR_ReconPhase.RETURNING, "production return-home request accepted")) return; + m_ERRReconStage = 3; return; + } + if (m_ERRReconStage == 3) + { + if (!flight || flight.Phase() != ERR_ReconPhase.PARKED) return; + if (!ERR_ReconCheck(ERR_Actors.Alive(flight.Aircraft()) && vector.DistanceXZ(flight.Aircraft().GetOrigin(), flight.Home()) < 4 && flight.ERR_TestPilot(), "same intact helicopter and crew returned and landed on airfield")) return; + m_ERRReconWait = now + 4000; m_ERRReconStage = 4; return; + } + if (m_ERRReconStage == 4) + { + if (now < m_ERRReconWait) return; + if (!ERR_ReconCheck(flight && flight.Phase() == ERR_ReconPhase.PARKED && system.ERR_TestWaves() == 1, "parking does not create a replacement")) return; + DamageManagerComponent aircraftDamage = DamageManagerComponent.Cast(m_ERRReconFirst.FindComponent(DamageManagerComponent)); + aircraftDamage.SetHealthScaled(0); + m_ERRReconStage = 5; return; + } + if (m_ERRReconStage == 5) + { + if (flight) return; + if (!ERR_ReconCheck(system.ERR_TestDeadline() - now > 899000 && system.ERR_TestWaves() == 1, "actual destruction starts full 15-minute replacement delay")) return; + system.ERR_TestDeparture(now + 3000); m_ERRReconStage = 6; return; + } + if (m_ERRReconStage == 6) + { + if (!flight || flight.Phase() != ERR_ReconPhase.TAKEOFF) return; + if (!ERR_ReconCheck(system.ERR_TestWaves() == 2 && flight.Aircraft() != m_ERRReconFirst && !ERR_Actors.Alive(m_ERRReconFirst), "delayed replacement is a new aircraft and wreck is retained")) return; + m_ERRReconStage = 8; m_ERRReconWait = now + 500; return; + } + if (m_ERRReconStage == 8) + { + if (now < m_ERRReconWait) return; + if (!ERR_ReconCheck(ERR_Actors.SetFaction(flight.Aircraft(), PlayerFaction()), "native aircraft faction transferred to escapists")) return; + m_ERRReconWait = now + 15000; + m_ERRReconStage = 7; return; + } + if (m_ERRReconStage == 7) + { + if (!flight || flight.Phase() != ERR_ReconPhase.RELEASED) + { + if (now >= m_ERRReconWait) ERR_ReconCheck(false, "native faction handover did not release flight"); + return; + } + if (!ERR_ReconCheck(SCR_FactionManager.SGetFaction(flight.Aircraft()) == PlayerFaction(), "aircraft retains transferred faction")) return; + int expectedWaves = 2; if (m_ERRReconTakeoverOnly) expectedWaves = 1; + if (!ERR_ReconCheck(system.ERR_TestWaves() == expectedWaves && system.ERR_TestDeadline() < 0 && !flight.Lost(), "faction takeover releases scripted flight without creating replacement")) return; + if (m_ERRReconTakeoverOnly) + { + m_ERRReconStage = 9; m_ERRReconWait = now + 1000; return; + } + Print("ERR SMOKE COMPLETE: air recon scheduled airport launch, native search flight, return/landing, loss delay and takeover"); + ERR_ReconTestStop(); + } + if (m_ERRReconStage == 9) + { + if (now < m_ERRReconWait) return; + SCR_EntityHelper.DeleteEntityAndChildren(flight.Aircraft()); m_ERRReconStage = 10; return; + } + if (m_ERRReconStage == 10) + { + if (!ERR_ReconCheck(!flight && system.ERR_TestWaves() == 1 && system.ERR_TestDeadline() - now >= 898000, "native entity deletion schedules full delay without duplicate aircraft")) return; + Print("ERR SMOKE COMPLETE: air recon native faction takeover and explicit deletion loss timer"); ERR_ReconTestStop(); + } + } +} +#endif diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_NativeMortarStationProbe.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_NativeMortarStationProbe.c new file mode 100644 index 0000000..8518d4a --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_NativeMortarStationProbe.c @@ -0,0 +1,103 @@ +#ifdef WORKBENCH +modded class ERR_MortarBattery +{ + ERR_MortarStation ERR_StationProbe() { return m_Station; } +} +modded class ERR_GameMode +{ + protected int m_ERRStationStage, m_ERRStationDeadline; + protected float m_ERRStationWait; + protected ERR_MortarBattery m_ERRStationBattery; + protected IEntity m_ERRStationGunner; + protected ref array m_ERRStationSupport = {}; + protected ref array m_ERRStationOrigins = {}; + protected ref SCR_AIGetOutVehicle m_ERRStationExit; + protected override void EOnInit(IEntity owner) + { + super.EOnInit(owner); + string outcome; + if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "mortar-station") return; + m_ERRStationDeadline = System.GetTickCount() + 180000; + GetGame().GetCallqueue().CallLater(ERR_StationProbeTick, 500, true); + } + protected bool ERR_StationCheck(bool condition, string detail) + { + if (condition) { Print("ERR SMOKE PASS: mortar station " + detail); return true; } + Print("ERR SMOKE FAIL: mortar station " + detail, LogLevel.ERROR); + GetGame().GetCallqueue().Remove(ERR_StationProbeTick); return false; + } + protected void ERR_StationProbeTick() + { + if (System.GetTickCount() > m_ERRStationDeadline || !PreparationError().IsEmpty()) { ERR_StationCheck(false, "deadline/preparation " + PreparationError()); return; } + if (!Active() || GetWorld().GetWorldTime() < m_ERRStationWait) return; + SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController()); + if (!controller || !controller.GetControlledEntity()) return; + float now = GetWorld().GetWorldTime(); + if (m_ERRStationStage == 0) + { + array batteries = {}; ERR_MortarSystem.Get().Batteries(batteries); + if (batteries.IsEmpty()) return; + m_ERRStationBattery = batteries[0]; + DamageManagerComponent.Cast(controller.GetControlledEntity().FindComponent(DamageManagerComponent)).EnableDamageHandling(false); + if (!ERR_StationCheck(UnlockPrison(controller.GetControlledEntity()), "normal prison release starts crew AI")) return; + m_ERRStationStage = 1; return; + } + ERR_MortarStation station = m_ERRStationBattery.ERR_StationProbe(); + if (m_ERRStationStage == 1) + { + if (!m_ERRStationBattery.StartupActivated || !station) return; + m_ERRStationGunner = station.Gunner(); + if (!m_ERRStationGunner || CompartmentAccessComponent.GetVehicleIn(m_ERRStationGunner) != m_ERRStationBattery.Gun) return; + if (!ERR_StationCheck(!m_ERRStationBattery.CommandOpen && m_ERRStationBattery.TotalShots == 0, "gunner boards original gun while idle without manufacturing fire orders")) return; + array agents = {}; m_ERRStationBattery.Group.GetAgents(agents); + foreach (AIAgent agent : agents) + { + IEntity actor = agent.GetControlledEntity(); + DamageManagerComponent.Cast(actor.FindComponent(DamageManagerComponent)).EnableDamageHandling(false); + if (actor == m_ERRStationGunner) continue; + m_ERRStationSupport.Insert(actor); m_ERRStationOrigins.Insert(actor.GetOrigin()); + SCR_ChimeraAIAgent ai = SCR_ChimeraAIAgent.Cast(agent); + ai.m_UtilityComponent.AddAction(new SCR_AIMoveIndividuallyBehavior(ai.m_UtilityComponent, null, ERR_Actors.Ground(actor.GetOrigin() + "40 0 0"), 1000)); + } + SCR_ChimeraAIAgent gunner = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(m_ERRStationGunner)); + m_ERRStationExit = new SCR_AIGetOutVehicle(gunner.m_UtilityComponent, null, m_ERRStationBattery.Gun, priority: 2000); + if (!ERR_StationCheck(m_ERRStationExit.CustomEvaluate() == 0, "even high-priority AI dismount is refused for intact gun")) return; + gunner.m_UtilityComponent.AddAction(m_ERRStationExit); + m_ERRStationBattery.Cancel("native station retention test"); + m_ERRStationWait = now + 15000; m_ERRStationStage = 2; return; + } + if (m_ERRStationStage == 2) + { + if (!ERR_StationCheck(CompartmentAccessComponent.GetVehicleIn(m_ERRStationGunner) == m_ERRStationBattery.Gun, "gunner remains seated after cancellation and competing dismount")) return; + bool stayed = m_ERRStationSupport.Count() == 3; + foreach (int i, IEntity support : m_ERRStationSupport) + if (vector.DistanceXZ(support.GetOrigin(), m_ERRStationOrigins[i]) > 1.5) stayed = false; + if (!ERR_StationCheck(stayed, "all three support crew remain on station despite competing movement")) return; + DamageManagerComponent damage = DamageManagerComponent.Cast(m_ERRStationBattery.Gun.FindComponent(DamageManagerComponent)); + vector hit[3]; hit[0] = m_ERRStationBattery.Gun.GetOrigin(); hit[1] = "0 0 1"; hit[2] = "0 0 -1"; + damage.HandleDamage(new SCR_DamageContext(EDamageType.TRUE, 100000, hit, m_ERRStationBattery.Gun, damage.GetDefaultHitZone(), Instigator.CreateInstigator(controller.GetControlledEntity()), null, -1, -1)); + m_ERRStationWait = now + 3000; m_ERRStationStage = 3; return; + } + if (m_ERRStationStage == 3) + { + if (!ERR_StationCheck(!station.GunIntact() && !station.Holds(m_ERRStationGunner) && m_ERRStationExit.CustomEvaluate() > 0, "destroying the gun removes station and dismount restrictions")) return; + foreach (IEntity survivor : m_ERRStationSupport) + { + SCR_ChimeraAIAgent ai = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(survivor)); + ai.m_UtilityComponent.AddAction(new SCR_AIMoveIndividuallyBehavior(ai.m_UtilityComponent, null, ERR_Actors.Ground(survivor.GetOrigin() + "30 0 0"), 2000)); + } + m_ERRStationWait = now + 15000; m_ERRStationStage = 4; return; + } + if (m_ERRStationStage == 4) + { + bool moved; + foreach (int i, IEntity survivor : m_ERRStationSupport) + if (vector.DistanceXZ(survivor.GetOrigin(), m_ERRStationOrigins[i]) > 3) moved = true; + if (!ERR_StationCheck(moved, "surviving support crew physically leave after destruction")) return; + GetGame().GetCallqueue().Remove(ERR_StationProbeTick); + Transition(ERR_Phase.DEFEAT); + Print("ERR SMOKE COMPLETE: permanent mortar station retention and release; scripted local fixture"); + } + } +} +#endif diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_NativeScoutProbe.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_NativeScoutProbe.c new file mode 100644 index 0000000..04dde90 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_NativeScoutProbe.c @@ -0,0 +1,54 @@ +#ifdef WORKBENCH +modded class ERR_HQ +{ + bool ERR_ProbeScoutReservation(SCR_AIGroup group) + { + foreach (ERR_HQUnit unit : m_Units) + if (unit.Group == group) return unit.ReservedForSupport && !unit.Eligible(m_Mode) && !ReserveLogistics(group, 888); + return false; + } +} +class ERR_NativeScoutProbe +{ + static bool Run() + { + ERR_HQ hq = ERR_HQ.Get(); + ERR_ScoutSystem scouts = hq.Scouts(); + if (scouts.Count() != 10) return false; + for (int index = 0; index < scouts.Count(); index++) + { + ERR_ScoutPost post = scouts.At(index); + if (!post.Ready || !ERR_Actors.CanFight(post.Actor) || post.Group.GetAgentsCount() != 1 || !hq.ERR_ProbeScoutReservation(post.Group)) return false; + if (post.Group.GetGroupUtilityComponent().GetCombatModeExternal() != EAIGroupCombatMode.RETURN_FIRE || ERR_HQRadio.Capability(post.Actor) != 2) return false; + if (SCR_FactionManager.SGetFaction(post.Actor) != ERR_GameMode.Get().EnemyFaction()) return false; + InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(post.Actor.FindComponent(InventoryStorageManagerComponent)); + if (!inventory || !post.Pack || inventory.CanInsertResourceInStorage(post.Magazine, post.Pack)) return false; + array magazines = {}; post.Pack.GetAll(magazines); + int matches; + foreach (IEntity item : magazines) + { + if (!item || !item.GetPrefabData() || item.GetPrefabData().GetPrefabName() != post.Magazine) return false; + MagazineComponent magazine = MagazineComponent.Cast(item.FindComponent(MagazineComponent)); + if (!magazine || magazine.GetAmmoCount() != magazine.GetMaxAmmoCount()) return false; + matches++; + } + if (matches < 1 || matches != post.Magazines) return false; + for (int other = 0; other < index; other++) + if (vector.DistanceXZ(post.Position, scouts.At(other).Position) < 900) return false; + PrintFormat("Escapists [scout probe]: PASS post=%1 height=%2 full magazines=%3 physical manpack, return fire, reserved", index + 1, post.Position[1], matches); + } + ERR_HQReport report = new ERR_HQReport(); report.RoadStatus = "on road"; + ERR_HQReport snapshot = report.Snapshot(); report.RoadStatus = "off road"; + if (snapshot.RoadStatus != "on road") return false; + ERR_HQReport before = new ERR_HQReport(); before.Target = scouts.At(0).Actor; before.Source = scouts.At(0).Group; + before.Position = "100 20 100"; before.ObservedAt = 1000; before.TravelMode = "on foot"; + ERR_HQReport after = before.Snapshot(); after.Position = "110 22 100"; after.ObservedAt = 11000; + ERR_ScoutMotion.Estimate(after, before); + if (!after.HasVelocity || after.Velocity != "1 0 0" || after.TravelDirection() != "E") return false; + after.ObservedAt = 42000; ERR_ScoutMotion.Estimate(after, before); + if (after.HasVelocity) return false; + Print("Escapists [scout probe]: PASS observed motion, direction, stale-sighting refusal and immutable road snapshot"); + return true; + } +} +#endif diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_ObjectiveDefence.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_ObjectiveDefence.c new file mode 100644 index 0000000..8942f27 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ObjectiveDefence.c @@ -0,0 +1,105 @@ +class ERR_ObjectiveDefenceEntry +{ + ERR_SiteRuntime Site; + SCR_AIGroup Group; + AIWaypoint Hold; + bool Activated; + bool Spawned; + bool Admitted; + float Deadline; + Faction GuardFaction; + ResourceName GuardProfile; +} + +// One permanent garrison per objective, with one finite relief crew if its +// original garrison is absent. Player proximity activates defence, not HQ intel. +class ERR_ObjectiveDefence +{ + protected ERR_GameMode m_Mode; + protected ERR_HQ m_HQ; + protected ref array m_Entries = {}; + protected float m_Next; + protected bool m_Stopped; + void ERR_ObjectiveDefence(ERR_GameMode mode, ERR_HQ hq) { m_Mode = mode; m_HQ = hq; } + static bool Objective(int kind) + { + return kind == ERR_LocationKind.RADIO || kind == ERR_LocationKind.DEPOT + || kind == ERR_LocationKind.OUTPOST || kind == ERR_LocationKind.EXTRACTION; + } + protected void Initialize() + { + for (int i = 0; i < m_Mode.Sites().Count(); i++) + { + ERR_SiteRuntime site = m_Mode.Sites().At(i); + if (!site || !site.Definition || !Objective(site.Definition.Kind)) continue; + ERR_ObjectiveDefenceEntry entry = new ERR_ObjectiveDefenceEntry(); + entry.Site = site; + entry.GuardFaction = m_Mode.EnemyFaction(); entry.GuardProfile = m_Mode.FactionSelection().Choice("enemy").Group; + if (site.Group) + { + entry.GuardFaction = site.Group.GetFaction(); + if (site.Group.GetPrefabData()) entry.GuardProfile = site.Group.GetPrefabData().GetPrefabName(); + } + m_Entries.Insert(entry); + } + } + void Tick(float now) + { + if (m_Stopped || !Replication.IsServer() || !m_Mode.Active() || now < m_Next || m_Mode.Phase() <= ERR_Phase.PRISON) return; + m_Next = now + 5000; + if (m_Entries.IsEmpty()) Initialize(); + array players = {}; ERR_Actors.Players(players); + foreach (ERR_ObjectiveDefenceEntry entry : m_Entries) + { + if (entry.Spawned && !entry.Admitted && entry.Group) + { + if (entry.Group.IsExpandComplete() && m_Mode.ForceLiving(entry.Group) == entry.Group.m_aUnitPrefabSlots.Count() && m_Mode.ForceLiving(entry.Group) > 0) + { + string source = "defence-" + entry.Site.Definition.Id; + if (entry.Group.GetFaction() == m_Mode.EnemyFaction()) + { + ERR_ForceRecord record = m_Mode.Forces().Register(source, ERR_ForceKind.GARRISON, entry.Site.Location.Position(), m_Mode.ForceLiving(entry.Group), null, entry.Group, now); + if (!record) continue; + } + m_Mode.RegisterGroup(entry.Group); m_HQ.Dedicate(entry.Group, entry.Site.Definition.Id); + entry.Hold = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.DefendWaypoint(), entry.Site.Location.Position())); + if (entry.Hold) { m_Mode.Own(entry.Hold); entry.Hold.SetCompletionRadius(40); entry.Group.AddWaypoint(entry.Hold); } + entry.Group.ActivateAI(); entry.Admitted = true; + ERR_HQ.Event("OBJECTIVE DEFENCE", source + " relief crew admitted; permanent local defence; position=" + entry.Site.Location.Position().ToString()); + } + else if (now >= entry.Deadline) { ERR_HQ.Event("OBJECTIVE DEFENCE", entry.Site.Definition.Id + " relief startup expired; no further spawn"); entry.Admitted = true; } + continue; + } + if (entry.Spawned) continue; + float nearest = 1e20; + foreach (IEntity player : players) if (player && ERR_Actors.Alive(player)) nearest = Math.Min(nearest, vector.DistanceXZ(player.GetOrigin(), entry.Site.Location.Position())); + if (nearest > 600) continue; + if (entry.Site.Group && m_Mode.ForceLiving(entry.Site.Group) > 0) + { + if (!entry.Activated) + { + entry.Activated = true; m_HQ.Dedicate(entry.Site.Group, entry.Site.Definition.Id); + ERR_HQ.Event("OBJECTIVE DEFENCE", entry.Site.Definition.Id + " existing garrison activated; permanent local defence; position=" + entry.Site.Location.Position().ToString()); + } + continue; + } + // No surprise materialisation on top of a player, nor infinite replenishment. + if (nearest < 200 || m_Mode.Forces().Count() >= 250) continue; + vector point = ERR_Actors.Ground(entry.Site.Location.Position() + "20 0 20"); + if (!ERR_HQRoadIntercept.Standable(point)) continue; + entry.Spawned = true; entry.Deadline = now + 30000; + entry.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", point)); + if (!entry.Group) continue; + m_Mode.Own(entry.Group); + // Retained ownership survives deletion of the original wiped-out group. + if (!ERR_PrisonPatrol.Configure(entry.Group, entry.GuardProfile, entry.GuardFaction)) entry.Admitted = true; + ERR_HQ.Event("OBJECTIVE DEFENCE", entry.Site.Definition.Id + " relief requested; position=" + point.ToString() + "; one relief crew maximum"); + } + } + void Stop() + { + m_Stopped = true; + foreach (ERR_ObjectiveDefenceEntry entry : m_Entries) + if (entry.Hold) { if (entry.Group) entry.Group.RemoveWaypoint(entry.Hold); ERR_Actors.DeleteSpawned(entry.Hold); entry.Hold = null; } + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_ReconAirports.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_ReconAirports.c new file mode 100644 index 0000000..c364b0f --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ReconAirports.c @@ -0,0 +1,118 @@ +// Adapted from the owner's original search-helicopter source; bounded native runway/tower discovery. +// Airport discovery is spread across updates instead of scanning the island +// in a single frame. An explicitly named ERR_ReconAirport marker takes priority. +class ERR_ReconAirports +{ + ref array m_Positions = {}; + protected ref array m_PositionSamples = {}; + protected vector m_vMin; + protected vector m_vMax; + protected vector m_vCursor; + protected bool m_bStarted; + protected bool m_bComplete; + void Update() + { + if (m_bComplete || !GetGame().GetWorldEntity()) + return; + if (!m_bStarted) + { + IEntity marker = GetGame().GetWorld().FindEntityByName("ERR_ReconAirport"); + if (marker && ERR_AirfieldPlacement.Clear(ERR_Actors.Ground(marker.GetOrigin()))) + { + m_Positions.Insert(marker.GetOrigin()); + m_PositionSamples.Insert(1); + m_bComplete = true; + return; + } + GetGame().GetWorldEntity().GetWorldBounds(m_vMin, m_vMax); + m_vCursor = m_vMin; + m_bStarted = true; + } + vector minimum = Vector(m_vCursor[0], -1000, m_vCursor[2]); + vector maximum = Vector(Math.Min(m_vCursor[0] + 2000, m_vMax[0]), 5000, Math.Min(m_vCursor[2] + 2000, m_vMax[2])); + GetGame().GetWorld().QueryEntitiesByAABB(minimum, maximum, AddAirport, IsAirportStructure, queryFlags: EQueryEntitiesFlags.ALL); + m_vCursor[0] = m_vCursor[0] + 2000; + if (m_vCursor[0] >= m_vMax[0]) + { + m_vCursor[0] = m_vMin[0]; + m_vCursor[2] = m_vCursor[2] + 2000; + } + m_bComplete = m_vCursor[2] >= m_vMax[2]; + if (m_bComplete) + { + PrintFormat("Escapists AI [air recon]: airport search finished; airports=%1", m_Positions.Count()); + for (int airportIndex = 0; airportIndex < m_Positions.Count(); airportIndex++) + PrintFormat("Escapists AI [air recon]: airport=%1 centre=%2 runway/tower samples=%3", airportIndex, m_Positions[airportIndex], m_PositionSamples[airportIndex]); + } + } + + protected bool IsAirportStructure(IEntity entity) + { + if (!entity || !entity.GetPrefabData()) + return false; + string prefabPath = entity.GetPrefabData().GetPrefabName(); + prefabPath.ToLower(); + // Airport-labelled props, bunkers and map locations can occur anywhere. + // Use physical runway infrastructure and control towers as anchors. + if (!prefabPath.Contains("prefabs/structures/")) + return false; + return prefabPath.Contains("/runway") || prefabPath.Contains("/lightrunway_") || prefabPath.Contains("/aircontroltower") || prefabPath.Contains("/airportcontroltower") || prefabPath.Contains("/controltower"); + } + + protected bool AddAirport(IEntity entity) + { + for (int index = 0; index < m_Positions.Count(); index++) + { + if (vector.DistanceXZ(m_Positions[index], entity.GetOrigin()) >= 1000) + continue; + // Use the cluster centre, rather than the first light at a runway edge. + int samples = m_PositionSamples[index]; + float sampleWeight = 1.0 / (samples + 1); + m_Positions[index] = vector.Lerp(m_Positions[index], entity.GetOrigin(), sampleWeight); + m_PositionSamples[index] = samples + 1; + return true; + } + m_Positions.Insert(entity.GetOrigin()); + m_PositionSamples.Insert(1); + PrintFormat("Escapists AI [air recon]: airport structure=%1 position=%2", entity.GetPrefabData().GetPrefabName(), entity.GetOrigin()); + return true; + } + + static bool FindClearPad(vector center, out vector position) + { + for (int attempt = 0; attempt < 12; attempt++) + { + float angleCandidate = Math.RandomFloat(0, Math.PI2); + float radiusCandidate = Math.RandomFloat(35, 220); + vector candidate = center + Vector(Math.Cos(angleCandidate) * radiusCandidate, 0, Math.Sin(angleCandidate) * radiusCandidate); + if (candidate == vector.Zero || vector.DistanceXZ(center, candidate) > 250 || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), candidate)) + continue; + candidate[1] = SCR_TerrainHelper.GetTerrainY(candidate); + bool flat = true; + for (int side = 0; side < 4; side++) + { + float angle = side * Math.PI / 2; + vector edge = candidate + Vector(Math.Cos(angle) * 12, 0, Math.Sin(angle) * 12); + if (Math.AbsFloat(SCR_TerrainHelper.GetTerrainY(edge) - candidate[1]) > 1.5) + flat = false; + } + if (!flat) + continue; + // Keep low crates, vehicles and people out of the pad footprint too; + // the overhead sweep alone starts above some of these obstacles. + if (!SCR_WorldTools.TraceCilinderUtil(candidate + Vector(0, 2.5, 0), 14, 4, TraceFlags.ENTS)) + continue; + TraceBox clearance = new TraceBox(); + clearance.Start = candidate + Vector(0, 4, 0); + clearance.End = candidate + Vector(0, 150, 0); + clearance.Mins = "-14 -2 -14"; + clearance.Maxs = "14 2 14"; + clearance.Flags = TraceFlags.ENTS | TraceFlags.WORLD; + if (GetGame().GetWorld().TraceMove(clearance, null) < 1) + continue; + position = candidate + Vector(0, 1.5, 0); + return true; + } + return false; + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_ReconCrewLocks.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_ReconCrewLocks.c new file mode 100644 index 0000000..18fbdcf --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ReconCrewLocks.c @@ -0,0 +1,19 @@ +// Retain the original pilots/gunners in flight; release on landing/loss/takeover. +modded class SCR_AIGetOutVehicle +{ + override float CustomEvaluate() + { + if (m_Utility && m_Utility.m_OwnerEntity) + { + ERR_HQ hq = ERR_HQ.Get(); + if (hq && hq.AirAssault() && hq.AirAssault().HoldsSeat(m_Utility.m_OwnerEntity)) return 0; + IEntity aircraft = CompartmentAccessComponent.GetVehicleIn(m_Utility.m_OwnerEntity); + if (aircraft) + { + ERR_ReconFlightComponent flight = ERR_ReconFlightComponent.Cast(aircraft.FindComponent(ERR_ReconFlightComponent)); + if (flight && flight.HoldsSeat(m_Utility.m_OwnerEntity)) return 0; + } + } + return super.CustomEvaluate(); + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_ReconFlightComponent.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_ReconFlightComponent.c new file mode 100644 index 0000000..a6d4fd2 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ReconFlightComponent.c @@ -0,0 +1,359 @@ +// Flight/orbit/terrain correction adapted from the owner's original recon and +// helicopter base components. Native physics remains authoritative on server. +enum ERR_ReconPhase { CREW, TAKEOFF, SEARCH, SHADOW, RETURNING, LANDING, PARKED, RELEASED } +class ERR_ReconFlightRules +{ + static vector Cruise(vector position, vector center, bool returning) + { + vector toward = center - position; toward[1] = 0; + float distance = toward.Length(); + float speed = 40; + if (returning) speed = Math.Min(speed, Math.Max(8, distance * 0.2)); + vector desired = toward.Normalized() * speed; + if (!returning && distance < 650) + desired = Vector(-toward[2], 0, toward[0]).Normalized() * 28 + toward.Normalized() * Math.Clamp((distance - 300) * 0.15, -20, 25); + return desired; + } + static vector StandOff(vector aircraft, vector observed, vector velocity) + { + vector away = aircraft - observed; away[1] = 0; + float distance = away.Length(); + if (distance >= 180) return velocity; + if (distance < 1) away = "1 0 0"; + away.Normalize(); + float inward = vector.Dot(velocity, away); + if (inward < 0) velocity -= away * inward; + return velocity + away * Math.Clamp((180 - distance) * 0.5, 0, 25); + } +} +[ComponentEditorProps(category: "Reintroduced", description: "Airport reconnaissance helicopter")] +class ERR_ReconFlightComponentClass : ScriptComponentClass {} +class ERR_ReconFlightComponent : ScriptComponent +{ + protected bool m_Logistics, m_HoldAtAirport, m_Delivery; + protected vector m_DeliveryPad, m_TakeoffBase; + bool Logistics() { return m_Logistics; } + bool AtAirport() { return m_Phase == ERR_ReconPhase.PARKED && vector.DistanceXZ(m_Aircraft.GetOrigin(), m_Home) < 10; } + vector LandingTarget() + { + if (m_Delivery) return m_DeliveryPad; + return m_Home; + } + void LaunchDelivery(vector pad) + { + if (!m_Logistics || !AtAirport() || Captured() || !LivingPilot()) return; + m_Delivery = true; m_DeliveryPad = pad; RestartFlight(); + } + void RestartFlight() + { + if (!Replication.IsServer() || m_Phase != ERR_ReconPhase.PARKED || Captured() || !LivingPilot()) return; + m_TakeoffBase = m_Aircraft.GetOrigin(); m_Started = GetGame().GetWorld().GetWorldTime(); m_Settled = 0; + m_HoldAtAirport = false; + Locks(true); + Engines(true); + m_Phase = ERR_ReconPhase.TAKEOFF; + // A helicopter waiting at the airport may have gone to physics sleep. + // Wake it here: its simulation callback cannot wake itself while asleep. + Physics physics = m_Aircraft.GetPhysics(); + if (physics) physics.SetActive(ActiveState.ACTIVE); + } + void ReloadPassengers() + { + if (!AtAirport() || m_Logistics) return; + if (m_Assault && m_Assault.Aboard() > 0) { m_Assault.ReuseRemaining(); return; } + m_Assault = ERR_HQ.Get().AirAssault().Load(this, m_Home, m_Serial); + } + protected ERR_GameMode m_Mode; + protected Vehicle m_Aircraft; + protected SCR_AIGroup m_Crew; + protected ref ERR_AirAssaultLoad m_Assault; + ERR_AirAssaultLoad Assault() { return m_Assault; } + protected ref array m_Seats = {}; + protected ref array m_OriginalCrew = {}; + protected ref ERR_HelicopterAvailability m_Availability = new ERR_HelicopterAvailability(); + protected ERR_ReconPhase m_Phase; + protected vector m_Home, m_Center, m_Velocity; + protected float m_Started, m_NextSearch, m_ObservedAt = -1, m_LandingStarted, m_Settled, m_NextLog; + protected ref ERR_HQReport m_Observation; + protected int m_Serial; + protected bool m_Lost, m_Initialized, m_CrewLocked; + protected string m_Reason = "creating crew"; + Vehicle Aircraft() { return m_Aircraft; } + SCR_AIGroup Crew() { return m_Crew; } + ERR_ReconPhase Phase() { return m_Phase; } + vector Home() { return m_Home; } + vector Destination() { return m_Center; } + bool Lost() { return m_Lost; } + string Status() { return typename.EnumToString(ERR_ReconPhase, m_Phase) + ": " + m_Reason; } + override void OnPostInit(IEntity owner) + { + super.OnPostInit(owner); + if (!SCR_Global.IsEditMode() && Replication.IsServer()) SetEventMask(owner, EntityEvent.SIMULATE); + } + protected void Seats(IEntity entity) + { + SCR_BaseCompartmentManagerComponent manager = SCR_BaseCompartmentManagerComponent.Cast(entity.FindComponent(SCR_BaseCompartmentManagerComponent)); + if (manager) + { + array slots = {}; manager.GetCompartments(slots); + foreach (BaseCompartmentSlot slot : slots) + if (slot && (slot.GetType() == ECompartmentType.PILOT || slot.GetType() == ECompartmentType.TURRET) && !m_Seats.Contains(slot)) m_Seats.Insert(slot); + } + IEntity child = entity.GetChildren(); + while (child) { Seats(child); child = child.GetSibling(); } + } + void Initialize(ERR_GameMode mode, vector pad, int serial, bool logistics = false) + { + if (m_Initialized || !Replication.IsServer()) return; + m_Initialized = true; m_Mode = mode; m_Aircraft = Vehicle.Cast(GetOwner()); m_Home = pad; m_Center = pad; m_Serial = serial; + m_Logistics = logistics; m_HoldAtAirport = logistics; m_TakeoffBase = pad; + m_Started = GetGame().GetWorld().GetWorldTime(); + if (!m_Aircraft || !ERR_Actors.SetFaction(m_Aircraft, mode.EnemyFaction())) { Fail("aircraft faction unavailable"); return; } + m_Crew = SCR_AIGroup.Cast(ERR_Actors.Spawn("{C31F210ABE078195}Prefabs/ERR/ReconCrew.et", pad)); + if (!m_Crew) { Fail("crew group unavailable"); return; } + mode.Own(m_Crew); m_Crew.SetFaction(mode.EnemyFaction()); m_Crew.SetPermanentLOD(0); + m_Crew.GetGroupUtilityComponent().SetCombatMode(EAIGroupCombatMode.FIRE_AT_WILL); + Seats(m_Aircraft); + if (m_Seats.Count() < 2 || m_Seats.Count() > 8) { Fail("unsupported pilot/gunner seat layout"); return; } + ResourceName pilot = "{C31F210ABE078197}Prefabs/ERR/ReconPilotEast.et"; + string faction = mode.EnemyFaction().GetFactionKey(); + if (faction == "US" || faction == "RHS_USAF") pilot = "{C31F210ABE078199}Prefabs/ERR/ReconPilotWest.et"; + foreach (BaseCompartmentSlot seat : m_Seats) + { + if (seat.IsOccupied() || !seat.IsCompartmentAccessible()) { Fail("crew seat obstructed"); return; } + AIGroup nativeCrew = m_Crew; + IEntity initialCrew = seat.SpawnCharacterInCompartment(pilot, nativeCrew, "{C31F210ABE078195}Prefabs/ERR/ReconCrew.et"); + if (initialCrew) m_OriginalCrew.Insert(initialCrew); + if (nativeCrew != m_Crew) { Fail("native boarding changed crew identity"); return; } + } + m_Crew.ActivateAI(); Locks(true); + if (!logistics) m_Assault = ERR_HQ.Get().AirAssault().Load(this, pad, serial); + } + protected bool Captured() + { + if (!m_Crew || m_Crew.GetPlayerCount(true) > 0 || m_Crew.GetFaction() != m_Mode.EnemyFaction() || SCR_FactionManager.SGetFaction(m_Aircraft) != m_Mode.EnemyFaction()) return true; + array players = {}; GetGame().GetPlayerManager().GetPlayers(players); + foreach (int id : players) + { + IEntity actor = GetGame().GetPlayerManager().GetPlayerControlledEntity(id); + if (actor && CompartmentAccessComponent.GetVehicleIn(actor) == m_Aircraft) return true; + } + return false; + } + protected bool LivingPilot() + { + foreach (BaseCompartmentSlot seat : m_Seats) + if (seat && seat.GetType() == ECompartmentType.PILOT && m_OriginalCrew.Contains(seat.GetOccupant()) && ERR_Actors.CanFight(seat.GetOccupant()) && !ERR_Actors.Player(seat.GetOccupant())) return true; + return false; + } + protected void Engines(bool on) + { + if (!m_Aircraft) return; + VehicleHelicopterSimulation simulation = VehicleHelicopterSimulation.Cast(m_Aircraft.FindComponent(VehicleHelicopterSimulation)); + if (!simulation) return; + if (on) { simulation.EngineStart(); simulation.SetThrottle(1); simulation.RotorSetForceScaleState(0, 2); simulation.RotorSetForceScaleState(1, 1); } + else { simulation.EngineStop(); simulation.SetThrottle(0); simulation.RotorSetForceScaleState(0, 0); simulation.RotorSetForceScaleState(1, 0); } + } + protected void Locks(bool locked) { m_CrewLocked = locked; } + bool HoldsSeat(IEntity actor) + { + return Replication.IsServer() && m_CrewLocked && m_Mode && m_Mode.Active() && m_Phase >= ERR_ReconPhase.CREW && m_Phase <= ERR_ReconPhase.PARKED + && m_OriginalCrew.Contains(actor) && !ERR_Actors.Player(actor) && ERR_Actors.Group(actor) == m_Crew && ERR_Actors.Alive(m_Aircraft) + && CompartmentAccessComponent.GetVehicleIn(actor) == m_Aircraft; + } + protected void Fail(string reason) { m_Lost = true; m_Reason = reason; Release(false); ERR_HQ.Event("HELICOPTER FAILURE", reason); } + void Release(bool captured) + { + if (m_Phase == ERR_ReconPhase.RELEASED) return; + m_Phase = ERR_ReconPhase.RELEASED; + ClearEventMask(GetOwner(), EntityEvent.SIMULATE); + if (!captured && m_Aircraft && !Captured()) Engines(false); + Locks(false); + } + protected bool FinishCrew(float now) + { + m_Crew.ActivateAI(); + array boardingAgents = {}; m_Crew.GetAgents(boardingAgents); + foreach (AIAgent boardingAgent : boardingAgents) { boardingAgent.SetPermanentLOD(0); boardingAgent.ActivateAI(); } + if (m_Assault && m_Assault.Preparing()) return false; + if (now - m_Started > 45000) { Fail("native crew boarding timed out; partial spawn retained"); return false; } + foreach (BaseCompartmentSlot seat : m_Seats) + if (!seat || !seat.GetOccupant() || !ERR_Actors.CanFight(seat.GetOccupant()) || ERR_Actors.Group(seat.GetOccupant()) != m_Crew) return false; + // Native loadouts can complete after seating; keep the original boarding deadline. + if (ERR_HQRadio.Capability(m_Seats[0].GetOccupant()) != 2) return false; + foreach (BaseCompartmentSlot occupied : m_Seats) + { + IEntity actor = occupied.GetOccupant(); + if (!ERR_Actors.SetFaction(actor, m_Mode.EnemyFaction())) { Fail("crew faction unavailable"); return false; } + if (!m_OriginalCrew.Contains(actor)) m_OriginalCrew.Insert(actor); + } + ERR_ForceRecord record = m_Mode.Forces().Register("air-recon-" + m_Serial.ToString(), ERR_ForceKind.PATROL, m_Home, m_OriginalCrew.Count(), null, m_Crew, now); + if (!record) { Fail("finite force ledger refused crew"); return false; } + record.Pinned = true; m_Mode.RegisterGroup(m_Crew); m_Crew.ActivateAI(); + array agents = {}; m_Crew.GetAgents(agents); + foreach (AIAgent agent : agents) { agent.SetPermanentLOD(0); agent.ActivateAI(); } + Locks(true); + m_Aircraft.GetPhysics().SetActive(ActiveState.ACTIVE); + if (m_HoldAtAirport) { m_Phase = ERR_ReconPhase.PARKED; m_Reason = "ammunition helicopter awaiting HQ order"; } + else { Engines(true); m_Phase = ERR_ReconPhase.TAKEOFF; m_Reason = "vertical airport departure"; } + return true; + } + void ReturnHome() + { + if (!Replication.IsServer() || m_Lost || Captured() || m_Phase == ERR_ReconPhase.RELEASED) return; + if (m_Logistics && m_Delivery) + { + m_Delivery = false; + if (m_Phase == ERR_ReconPhase.PARKED) { RestartFlight(); return; } + m_Center = m_Home; + if (m_Phase == ERR_ReconPhase.TAKEOFF) return; + m_Phase = ERR_ReconPhase.RETURNING; + m_Reason = "cargo mission finished; returning to airport"; + return; + } + if (m_Phase < ERR_ReconPhase.SEARCH || m_Phase > ERR_ReconPhase.SHADOW) return; + if (m_Assault) m_Assault.Abort(); + m_Phase = ERR_ReconPhase.RETURNING; + m_Center = m_Home; + m_Reason = "returning to departure airfield"; + m_LandingStarted = GetGame().GetWorld().GetWorldTime(); + ERR_HQ.Event("HELICOPTER RETURN", m_Reason); + } + protected void Search(float now) + { + if (m_Logistics) { m_Phase = ERR_ReconPhase.RETURNING; m_Center = LandingTarget(); m_Reason = "HQ ammunition transport"; return; } + m_Phase = ERR_ReconPhase.SEARCH; m_NextSearch = now + 90000; + // Only delivered HQ snapshots are available as off-aircraft search hints. + array delivered = {}; ERR_HQ.Get().SupportContacts(delivered, now); + if (!delivered.IsEmpty()) { m_Center = delivered[delivered.Count() - 1].Position; m_Reason = "searching delivered HQ sighting"; return; } + vector low, high; GetGame().GetWorldEntity().GetWorldBounds(low, high); + for (int attempt = 0; attempt < 16; attempt++) + { + vector point = ERR_Actors.Ground(Vector(Math.RandomFloat(low[0], high[0]), 0, Math.RandomFloat(low[2], high[2]))); + if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point)) continue; + m_Center = point; m_Reason = "searching island sector"; return; + } + m_Center = m_Home; + } + protected bool IsEscapistTarget(IEntity target) + { + array players = {}; ERR_Actors.Players(players, true); + foreach (IEntity player : players) + if (target == player || CompartmentAccessComponent.GetVehicleIn(player) == target) return true; + return false; + } + protected void Observe(float now) + { + // The same real native-perception adapter used by ground scouts. HQ's own + // registered-crew observation tick sends these contacts through actual radios. + array visible = {}; ERR_HQObservation.Gather(m_Crew.GetGroupUtilityComponent(), now, visible); + ERR_HQVisualContact selected; + foreach (ERR_HQVisualContact contact : visible) + if (IsEscapistTarget(contact.Entity) && vector.DistanceXZ(m_Aircraft.GetOrigin(), contact.Position) <= 1200 + && (!selected || (m_Observation && contact.Entity == m_Observation.Target))) selected = contact; + if (!selected || (m_Observation && selected.Entity == m_Observation.Target && selected.ObservedAt - m_ObservedAt < 2000)) return; + ERR_HQReport sighting = new ERR_HQReport(); sighting.Target = selected.Entity; sighting.Source = m_Crew; + sighting.Position = selected.Position; sighting.ObservedAt = selected.ObservedAt; sighting.TravelMode = selected.Travel; + ERR_ScoutMotion.Estimate(sighting, m_Observation); m_Observation = sighting; m_ObservedAt = sighting.ObservedAt; + } + void Update(float now) + { + if (!m_Initialized || m_Lost || !m_Mode || !m_Mode.Active()) return; + if (!m_Aircraft || !ERR_Actors.Alive(m_Aircraft)) { Fail("aircraft destroyed or removed"); return; } + if (m_Phase == ERR_ReconPhase.CREW) { FinishCrew(now); return; } + SCR_VehicleDamageManagerComponent damage = SCR_VehicleDamageManagerComponent.Cast(m_Aircraft.FindComponent(SCR_VehicleDamageManagerComponent)); + bool captured = Captured(); + bool disabled = !damage || !damage.GetEngineFunctional() || damage.GetMovementDamage() >= 1 || damage.IsOnFire(); + Physics physics = m_Aircraft.GetPhysics(); + if (!physics) { Fail("aircraft physics unavailable"); return; } + if (m_Phase == ERR_ReconPhase.TAKEOFF) physics.SetActive(ActiveState.ACTIVE); + string loss = m_Availability.Check(now, m_Started, false, disabled, LivingPilot(), captured, m_Phase == ERR_ReconPhase.PARKED, + m_Aircraft.GetOrigin()[1] - SCR_TerrainHelper.GetTerrainY(m_Aircraft.GetOrigin()), physics.GetVelocity().Length()); + if (!loss.IsEmpty()) { Fail(loss); return; } + if (captured) { m_Reason = "player takeover; flight controls released"; Release(true); return; } + if (m_Phase == ERR_ReconPhase.RELEASED || m_Phase == ERR_ReconPhase.PARKED) return; + if (now >= m_NextLog) { m_NextLog = now + 15000; ERR_HQ.Event("HELICOPTER STATUS", Status() + "; position=" + m_Aircraft.GetOrigin().ToString()); } + if (m_Phase == ERR_ReconPhase.RETURNING && vector.DistanceXZ(m_Aircraft.GetOrigin(), LandingTarget()) < 100) + { m_Phase = ERR_ReconPhase.LANDING; m_LandingStarted = now; m_Reason = "landing at airfield"; } + if (m_Phase == ERR_ReconPhase.LANDING && now - m_LandingStarted > 180000) { m_Phase = ERR_ReconPhase.RETURNING; m_Reason = "landing retry"; return; } + if (m_Phase != ERR_ReconPhase.SEARCH && m_Phase != ERR_ReconPhase.SHADOW) return; + vector cover; if (m_Assault && m_Assault.Cover(now, cover)) { Observe(now); m_Center = cover; m_Reason = "covering landed airborne squad"; return; } + FuelManagerComponent fuel = FuelManagerComponent.Cast(m_Aircraft.FindComponent(FuelManagerComponent)); + if (fuel && fuel.GetTotalFuel() < 50) { ReturnHome(); return; } + Observe(now); + if (m_Observation && now - m_ObservedAt <= 15000) + { + m_Phase = ERR_ReconPhase.SHADOW; + m_Center = ERR_HQReport.Predict(m_Observation.Position, m_Observation.Velocity, Math.Min(8, (now - m_ObservedAt) / 1000)); + m_Reason = "orbiting last visible contact"; + } + else if (m_Observation && now - m_ObservedAt <= 120000) + { + m_Phase = ERR_ReconPhase.SEARCH; + if (now >= m_NextSearch) + { + float angle = Math.RandomFloat(0, Math.PI2); float radius = Math.Min(600, 150 + (now - m_ObservedAt) / 200); + m_Center = m_Observation.Position + Vector(Math.Cos(angle) * radius, 0, Math.Sin(angle) * radius); + m_NextSearch = now + 30000; m_Reason = "searching around lost sighting"; + } + } + else if (now >= m_NextSearch) Search(now); + } + override void EOnSimulate(IEntity owner, float timeSlice) + { + if (!Replication.IsServer() || !m_Initialized || m_Phase == ERR_ReconPhase.CREW || m_Phase >= ERR_ReconPhase.PARKED) return; + if (!m_Mode || !m_Mode.Active() || Captured()) { Release(true); return; } + if (!LivingPilot() || !ERR_Actors.Alive(m_Aircraft)) { Fail("pilot or airframe lost"); return; } + Physics physics = owner.GetPhysics(); if (!physics) { Fail("physics lost"); return; } + vector position = owner.GetOrigin(); float now = GetGame().GetWorld().GetWorldTime(); + if (m_Phase == ERR_ReconPhase.TAKEOFF) + { + float climb; if (now - m_Started >= 10000) climb = 5; + physics.SetVelocity(Vector(0, climb, 0)); physics.SetAngularVelocity(vector.Zero); + if (position[1] - m_TakeoffBase[1] >= 120) Search(now); + return; + } + if (m_Phase == ERR_ReconPhase.LANDING) + { + vector toward = LandingTarget() - position; float vertical = toward[1]; toward[1] = 0; + vector angles = owner.GetYawPitchRoll(); owner.SetYawPitchRoll(Vector(angles[0], Math.Lerp(angles[1], 0, Math.Clamp(timeSlice * 2, 0, 1)), Math.Lerp(angles[2], 0, Math.Clamp(timeSlice * 2, 0, 1)))); + vector landing = toward.Normalized() * Math.Min(8, toward.Length() * 0.5); + landing[1] = Math.Clamp(vertical * 0.3, -3, 3); if (toward.Length() > 15) landing[1] = Math.Max(0, landing[1]); + physics.SetVelocity(landing); physics.SetAngularVelocity(vector.Zero); + if (toward.Length() < 3 && Math.AbsFloat(vertical) < 1 && physics.GetVelocity().Length() < 1) m_Settled += timeSlice; else m_Settled = 0; + if (m_Settled >= 2) { m_Phase = ERR_ReconPhase.PARKED; m_Reason = "landed; original helicopter retained"; Engines(false); physics.SetVelocity(vector.Zero); } + return; + } + vector destination = m_Center; bool dropRun; + vector dropDestination; + if (m_Assault && m_Assault.Approach(dropDestination)) { dropRun = true; destination = dropDestination; } + vector desired = ERR_ReconFlightRules.Cruise(position, destination, dropRun || m_Phase == ERR_ReconPhase.RETURNING); + if (dropRun && desired.Length() > 24) desired = desired.Normalized() * 24; + m_Velocity = vector.Lerp(m_Velocity, desired, Math.Clamp(timeSlice * 0.6, 0, 1)); m_Velocity[1] = 0; + if (!dropRun && m_Phase != ERR_ReconPhase.RETURNING && m_Observation && now - m_ObservedAt <= 120000) m_Velocity = ERR_ReconFlightRules.StandOff(position, m_Observation.Position, m_Velocity); + vector ahead = position + m_Velocity.Normalized() * 100; + float terrain = Math.Max(SCR_TerrainHelper.GetTerrainY(ahead, GetGame().GetWorld(), noUnderwater: true), SCR_TerrainHelper.GetTerrainY(position, GetGame().GetWorld(), noUnderwater: true)); + physics.SetVelocity(Vector(m_Velocity[0], Math.Clamp((terrain + 120 - position[1]) * 2, -2, 24), m_Velocity[2])); + if (m_Velocity.Length() > 1) NoseDown(); + } + protected void NoseDown() + { + vector direction[4]; Math3D.DirectionAndUpMatrix(m_Velocity.Normalized(), vector.Up, direction); + float pitch[4], base[4], rotation[4]; + SCR_Math3D.QuatAngleAxis(Math.Lerp(0, 20, Math.Clamp(m_Velocity.Length() / 70, 0, 1)) * Math.DEG2RAD, direction[0], pitch); + Math3D.MatrixToQuat(direction, base); Math3D.QuatMultiply(rotation, pitch, base); + vector axes[3]; Math3D.QuatToMatrix(rotation, axes); + vector transform[4]; transform[0] = axes[0]; transform[1] = axes[1]; transform[2] = axes[2]; transform[3] = m_Aircraft.GetOrigin(); + m_Aircraft.SetTransform(transform); + } + override void OnDelete(IEntity owner) + { + if (Replication.IsServer()) + { + Release(false); + ERR_HQ hq = ERR_HQ.Get(); + if (m_Mode && m_Mode.Active() && hq && hq.Recon()) hq.Recon().Removed(this); + } + super.OnDelete(owner); + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_ScoutSystem.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_ScoutSystem.c new file mode 100644 index 0000000..19c7ac4 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ScoutSystem.c @@ -0,0 +1,305 @@ +// Ten finite observation posts on Everon. Survey and stock finish before player +// admission. No replacements, periodic ammunition grants or target-position polling. +class ERR_ScoutPost +{ + vector Position; + float Heading; + SCR_AIGroup Group; + IEntity Actor; + BaseInventoryStorageComponent Pack; + ResourceName Magazine; + int Magazines; + bool Ready; + ref ERR_EnemyEquipmentCallback Operation; + ref array MotionSamples = {}; +} + +class ERR_ScoutTerrain +{ + // Reject low/flat terrain; prefer local crests with several downhill views. + static bool Hill(float height, float islandHigh, float meanDrop, int lowerSamples) + { + return height >= Math.Max(35, islandHigh * 0.18) && meanDrop >= 6 && lowerSamples >= 4; + } + static bool Separated(vector point, array posts, ERR_ScenarioDefinition definition) + { + foreach (ERR_ScoutPost post : posts) if (vector.DistanceXZ(point, post.Position) < 900) return false; + foreach (ERR_SiteDefinition site : definition.Sites) + { + float minimum = 150; + if (site.Kind == ERR_LocationKind.PRISON) minimum = 600; + if (vector.DistanceXZ(point, site.Position) < minimum) return false; + } + return true; + } + static bool Survey(vector point, float islandHigh, out float heading) + { + float drop; int lower; + for (int sample = 0; sample < 8; sample++) + { + float angle = sample * Math.PI / 4; + vector edge = ERR_Actors.Ground(point + Vector(Math.Sin(angle) * 150, 0, Math.Cos(angle) * 150)); + float delta = point[1] - edge[1]; drop += delta; + if (delta >= 5) lower++; + } + if (!Hill(point[1], islandHigh, drop / 8, lower) || !ERR_HQRoadIntercept.Standable(point)) return false; + int views; float best = -1; + for (int ray = 0; ray < 8; ray++) + { + float bearing = ray * Math.PI / 4; + vector end = ERR_Actors.Ground(point + Vector(Math.Sin(bearing) * 350, 0, Math.Cos(bearing) * 350)); + TraceParam sight = new TraceParam(); + sight.Start = point + "0 1.7 0"; sight.End = end + "0 1.7 0"; + sight.Flags = TraceFlags.ENTS | TraceFlags.WORLD; + float fraction = GetGame().GetWorld().TraceMove(sight, null); + if (fraction >= 0.95) views++; + if (fraction > best) { best = fraction; heading = ray * 45; } + } + return views >= 2; + } +} + +class ERR_ScoutSystem +{ + protected ERR_GameMode m_Mode; + protected ref array m_Posts = {}; + protected ERR_ScenarioDefinition m_Definition; + protected vector m_Low, m_High; + protected float m_IslandHigh; + protected int m_Started, m_HeightSamples, m_Attempts; + protected float m_Deadline; + protected bool m_Begun, m_Surveyed, m_Ready, m_Stopped; + protected string m_Error; + void ERR_ScoutSystem(ERR_GameMode mode) { m_Mode = mode; } + string Error() { return m_Error; } + int Count() { return m_Posts.Count(); } + ERR_ScoutPost At(int index) { return m_Posts[index]; } + bool Contains(SCR_AIGroup group) + { + foreach (ERR_ScoutPost post : m_Posts) if (post.Group == group) return true; + return false; + } + // The source estimates motion from two clear sightings of the same identity. + // HQ receives this frozen estimate through the normal physical radio route. + void DescribeMotion(ERR_HQReport report) + { + if (!report || !report.Target || report.Distress || report.DetailsObservedAt < 0 || m_Stopped) return; + foreach (ERR_ScoutPost post : m_Posts) + { + if (!post.Ready || post.Group != report.Source) continue; + for (int index = post.MotionSamples.Count() - 1; index >= 0; index--) + { + ERR_HQReport previous = post.MotionSamples[index]; + if (!previous.Target || report.ObservedAt - previous.ObservedAt > 30000) { post.MotionSamples.Remove(index); continue; } + if (previous.Target != report.Target) continue; + if (report.ObservedAt <= previous.ObservedAt) return; + ERR_ScoutMotion.Estimate(report, previous); + post.MotionSamples[index] = report.Snapshot(); + return; + } + if (post.MotionSamples.Count() >= 64) post.MotionSamples.Remove(0); + post.MotionSamples.Insert(report.Snapshot()); + return; + } + } + protected ERR_PrepareResult Fail(string reason) + { + m_Error = "Hill scout preparation: " + reason; + Print(m_Error, LogLevel.ERROR); + Stop(); + return ERR_PrepareResult.FAILED; + } + protected vector RandomPoint() + { + return ERR_Actors.Ground(Vector(Math.RandomFloat(m_Low[0] + 50, m_High[0] - 50), 0, Math.RandomFloat(m_Low[2] + 50, m_High[2] - 50))); + } + protected ERR_PrepareResult Survey() + { + int tick = System.GetTickCount(); + while (m_HeightSamples < 500) + { + vector heightSample = RandomPoint(); m_HeightSamples++; + m_IslandHigh = Math.Max(m_IslandHigh, heightSample[1]); + if (System.GetTickCount() - tick >= 6) return ERR_PrepareResult.PENDING; + } + while (m_Posts.Count() < m_Definition.ScoutCount) + { + if (++m_Attempts > 100000 || System.GetTickCount() - m_Started > 45000) + return Fail(string.Format("only %1/%2 safe, separated hilltops found; adjust ScoutCount or terrain criteria", m_Posts.Count(), m_Definition.ScoutCount)); + vector point = RandomPoint(); float heading; + if (ERR_ScoutTerrain.Separated(point, m_Posts, m_Definition) && ERR_ScoutTerrain.Survey(point, m_IslandHigh, heading)) + { + ERR_ScoutPost post = new ERR_ScoutPost(); post.Position = point; post.Heading = heading; m_Posts.Insert(post); + PrintFormat("Escapists [scout survey]: post=%1 position=%2 heading=%3", m_Posts.Count(), point, heading); + } + if (System.GetTickCount() - tick >= 6) return ERR_PrepareResult.PENDING; + } + m_Surveyed = true; + m_Deadline = GetGame().GetWorld().GetWorldTime() + 60000; + return ERR_PrepareResult.READY; + } + protected bool Spawn(ERR_ScoutPost post) + { + post.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn("{DF862ED195094E21}Prefabs/ERR/ScoutGroup.et", post.Position, Vector(post.Heading, 0, 0))); + if (!post.Group) return false; + m_Mode.Own(post.Group); + post.Group.SetFaction(m_Mode.EnemyFaction()); + ResourceName soldier = "{CF862ED195094E28}Prefabs/ERR/ScoutEast.et"; + post.Magazine = "{9CCB46C6EE632C1A}Prefabs/Weapons/Magazines/Magazine_762x54_SVD_10rnd_Sniper.et"; + string faction = m_Mode.EnemyFaction().GetFactionKey(); + if (faction == "US" || faction == "RHS_USAF") + { + soldier = "{CF862ED195094E2A}Prefabs/ERR/ScoutWest.et"; + post.Magazine = "{627255315038152A}Prefabs/Weapons/Magazines/Magazine_762x51_M14_20rnd_SpecialBall.et"; + } + post.Group.m_aUnitPrefabSlots = {soldier}; + SCR_AIGroupUtilityComponent utility = post.Group.GetGroupUtilityComponent(); + if (!utility) return false; + utility.SetCombatMode(EAIGroupCombatMode.RETURN_FIRE); + AIWaypoint hold = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.DefendWaypoint(), post.Position)); + if (!hold) return false; + m_Mode.Own(hold); hold.SetCompletionRadius(8); post.Group.AddWaypoint(hold); + post.Group.SetSpawnImmediately(true); post.Group.RequestSpawn(1); + return true; + } + protected ERR_PrepareResult Stock(ERR_ScoutPost post) + { + if (!post.Group.IsExpandComplete()) return ERR_PrepareResult.PENDING; + array agents = {}; post.Group.GetAgents(agents); + if (agents.Count() != 1 || !agents[0]) return ERR_PrepareResult.PENDING; + if (!post.Actor) + { + post.Actor = agents[0].GetControlledEntity(); + if (!post.Actor) return ERR_PrepareResult.PENDING; + if (!ERR_Actors.SetFaction(post.Actor, m_Mode.EnemyFaction())) return Fail("scout faction affiliation unavailable"); + // Native group spawn may choose an offset. Place the original body on the + // surveyed standing point, facing its longest checked view, before release. + ChimeraCharacter actor = ChimeraCharacter.Cast(post.Actor); + if (!actor) return Fail("scout character unavailable"); + vector transform[4]; Math3D.AnglesToMatrix(Vector(post.Heading, 0, 0), transform); transform[3] = post.Position; actor.Teleport(transform); + SCR_ChimeraAIAgent nativeAgent = SCR_ChimeraAIAgent.Cast(agents[0]); + if (nativeAgent) nativeAgent.ERR_HoldForPrison(); + } + if (!ERR_Actors.CanFight(post.Actor)) return Fail("scout died before admission; no replacement"); + InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(post.Actor.FindComponent(InventoryStorageManagerComponent)); + if (!inventory) return ERR_PrepareResult.PENDING; + if (!post.Pack) + { + array storages = {}; inventory.GetStorages(storages); + foreach (BaseInventoryStorageComponent storage : storages) + if (storage && storage.GetOwner().GetPrefabData() && storage.GetOwner().GetPrefabData().GetPrefabName().Contains("/ScoutRadioPack.et")) { post.Pack = storage; break; } + if (!post.Pack) return ERR_PrepareResult.PENDING; + } + if (post.Operation) + { + if (!post.Operation.Finished) return ERR_PrepareResult.PENDING; + if (!post.Operation.Succeeded) return Fail("native magazine insertion failed"); + post.Magazines++; post.Operation = null; + } + if (inventory.CanInsertResourceInStorage(post.Magazine, post.Pack)) + { + if (post.Magazines >= 128) return Fail("backpack capacity exceeds bounded magazine allowance"); + post.Operation = new ERR_EnemyEquipmentCallback(); + if (!inventory.TrySpawnPrefabToStorage(post.Magazine, post.Pack, -1, EStoragePurpose.PURPOSE_ANY, post.Operation)) return Fail("native magazine insertion refused"); + return ERR_PrepareResult.PENDING; + } + if (post.Magazines < 1 || ERR_HQRadio.Capability(post.Actor) != 2) return Fail("full backpack or physical long-range radio missing"); + array items = {}; inventory.GetItems(items); + bool binoculars, rifle; + foreach (IEntity item : items) + { + if (item.FindComponent(SCR_BinocularsComponent)) binoculars = true; + BaseWeaponComponent weapon = BaseWeaponComponent.Cast(item.FindComponent(BaseWeaponComponent)); + if (weapon && weapon.GetWeaponType() == EWeaponType.WT_SNIPERRIFLE) rifle = true; + } + if (!binoculars || !rifle) return Fail("binoculars or scoped sniper rifle missing"); + ERR_ForceRecord record = m_Mode.Forces().Register("hill-scout-" + (m_Posts.Find(post) + 1).ToString(), ERR_ForceKind.GARRISON, post.Position, 1, null, post.Group, GetGame().GetWorld().GetWorldTime()); + if (!record) return Fail("finite force ledger admission refused"); + record.Pinned = true; + m_Mode.RegisterGroup(post.Group); + post.Ready = true; + PrintFormat("Escapists [scout ready]: position=%1 backpack magazines=%2", post.Position, post.Magazines); + return ERR_PrepareResult.READY; + } + ERR_PrepareResult Prepare(ERR_ScenarioDefinition definition) + { + if (m_Stopped || !Replication.IsServer() || !definition) return ERR_PrepareResult.FAILED; + if (m_Ready) return ERR_PrepareResult.READY; + if (!m_Begun) + { + m_Begun = true; m_Definition = definition; m_Started = System.GetTickCount(); + GetGame().GetWorldEntity().GetWorldBounds(m_Low, m_High); + } + if (!m_Surveyed) + { + ERR_PrepareResult survey = Survey(); + if (survey != ERR_PrepareResult.READY) return survey; + } + if (GetGame().GetWorld().GetWorldTime() > m_Deadline) return Fail("native scout spawn/equipment deadline exceeded"); + bool pending; + foreach (ERR_ScoutPost post : m_Posts) + { + if (post.Ready) continue; + if (!post.Group) + { + if (!Spawn(post)) return Fail("scout group spawn failed"); + return ERR_PrepareResult.PENDING; // One group per preparation frame. + } + ERR_PrepareResult stock = Stock(post); + if (stock == ERR_PrepareResult.FAILED) return stock; + if (stock == ERR_PrepareResult.PENDING) pending = true; + } + if (pending) return ERR_PrepareResult.PENDING; + m_Ready = true; + return ERR_PrepareResult.READY; + } + void Stop() + { + if (m_Stopped) return; + m_Stopped = true; + // GameMode owns bodies, groups, equipment and waypoints. No new work is queued. + } +} + +// Classify only an already visible, captured coordinate against real road +// geometry. Vehicles in the air and nearby verges are not reported as on-road. +class ERR_ScoutRoad +{ + static bool OnSegment(vector point, vector start, vector end, float width) + { + vector delta = end - start; delta[1] = 0; + float lengthSquared = vector.Dot(delta, delta); + if (lengthSquared < 0.01 || width <= 0) return false; + vector offset = point - start; offset[1] = 0; + float fraction = Math.Clamp(vector.Dot(offset, delta) / lengthSquared, 0, 1); + vector nearest = vector.Lerp(start, end, fraction); + return vector.DistanceXZ(point, nearest) <= width * 0.5 && Math.AbsFloat(point[1] - nearest[1]) <= 3; + } + static string Describe(vector observed) + { + SCR_AIWorld world = SCR_AIWorld.Cast(GetGame().GetAIWorld()); + if (!world || !world.GetRoadNetworkManager()) return "road unknown"; + BaseRoad road; float distance; + world.GetRoadNetworkManager().GetClosestRoad(observed, road, distance, skipNavlinks: true); + if (!road) return "off road"; + array points = {}; road.GetPoints(points); + if (points.Count() < 2 || points.Count() > 1024) return "road unknown"; + for (int i = 1; i < points.Count(); i++) if (OnSegment(observed, points[i - 1], points[i], road.GetWidth())) return "on road"; + return "off road"; + } +} + +// Average observed ground motion, never a read of the target's current physics. +class ERR_ScoutMotion +{ + static void Estimate(ERR_HQReport report, ERR_HQReport previous) + { + report.HasVelocity = false; report.Velocity = vector.Zero; + if (!previous || !report.Target || report.Target != previous.Target || report.Source != previous.Source || report.Distress || previous.Distress) return; + float seconds = (report.ObservedAt - previous.ObservedAt) / 1000; + if (seconds > 30 || !ERR_HQMemoryRules.MotionCredible(seconds, vector.DistanceXZ(previous.Position, report.Position), previous.TravelMode, report.TravelMode)) return; + report.Velocity = (report.Position - previous.Position) / seconds; + report.Velocity[1] = 0; + report.HasVelocity = true; + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c new file mode 100644 index 0000000..7da7eb5 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c @@ -0,0 +1,227 @@ +// Full supply convoys reuse finite vehicle creation and cargo ownership. The +// legacy finite-route service remains available for isolated migration fixtures. +class ERR_SupplyConvoyService : ERR_ConvoyService +{ + void ERR_SupplyConvoyService(ERR_GameMode mode, ERR_ConvoyPool pool) {} + protected ref ERR_SupplyConvoyTask m_Creating; + protected ref array m_Bases = {}; + void SetBases(array bases) { m_Bases.Copy(bases); } + protected override ERR_ConvoyTask NewTask() + { + m_Creating = new ERR_SupplyConvoyTask(); m_Creating.Bases.Copy(m_Bases); + return m_Creating; + } + protected override ERR_ConvoyVehicle NewVehicle() { return new ERR_SupplyConvoyVehicle(); } + protected override bool FillCrew(ERR_ConvoyVehicle unit) + { + if (m_Creating) m_Creating.Starts.Insert(unit.Entity.GetOrigin()); + if (unit.Index != 2) return super.FillCrew(unit); + array all = {}, drivers = {}, cargo = {}; + if (!m_Creating || !ERR_ConvoyRules.CollectSeats(unit.Entity, all)) return false; + foreach (BaseCompartmentSlot seat : all) + { + if (!seat || seat.IsOccupied() || !seat.IsCompartmentAccessible()) continue; + if (seat.GetType() == ECompartmentType.PILOT) drivers.Insert(seat); + if (seat.GetType() == ECompartmentType.CARGO) cargo.Insert(seat); + } + if (drivers.Count() != 1 || cargo.Count() != 14) return false; + AIGroup nativeGroup = unit.Group; + IEntity driver = drivers[0].SpawnCharacterInCompartment(ERR_ConvoyResources.Soldier(), nativeGroup, ERR_ConvoyResources.GroupPrefab()); + if (driver) { m_Mode.Own(driver); unit.Members.Insert(driver); unit.Seats.Insert(drivers[0]); unit.Driver = driver; } + if (!driver || nativeGroup != unit.Group) return false; + for (int index = 0; index < 2; index++) + { + ERR_SupplyTeam team = new ERR_SupplyTeam(); m_Creating.Teams.Insert(team); + team.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.GroupPrefab(), unit.Entity.GetOrigin())); + if (team.Group) m_Mode.Own(team.Group); + if (!ERR_ConvoyRules.EmptyCrewTemplate(team.Group)) return false; + AIFormationComponent formation = AIFormationComponent.Cast(team.Group.FindComponent(AIFormationComponent)); + if (formation) formation.SetFormation("Line"); + nativeGroup = team.Group; + for (int slot = index; slot < 14; slot += 2) + { + IEntity actor = cargo[slot].SpawnCharacterInCompartment(ERR_ConvoyResources.Soldier(), nativeGroup, ERR_ConvoyResources.GroupPrefab()); + if (actor) { m_Mode.Own(actor); team.Members.Insert(actor); team.Seats.Insert(cargo[slot]); } + if (!actor || nativeGroup != team.Group) return false; + } + } + return true; + } + protected void Wake(SCR_AIGroup group) + { + if (!group) return; + group.SetPermanentLOD(0); group.ActivateAI(); + array agents = {}; group.GetAgents(agents); + foreach (AIAgent agent : agents) if (agent) { agent.SetPermanentLOD(0); agent.ActivateAI(); } + } + protected override bool Admit(ERR_ConvoyTask task, float now) + { + ERR_SupplyConvoyTask supply = ERR_SupplyConvoyTask.Cast(task); + if (!supply || supply.Teams.Count() != 2) return false; + // Distant native agents must wake before compartment membership can settle. + foreach (ERR_ConvoyVehicle vehicle : supply.Vehicles) + { + Wake(vehicle.Group); + foreach (IEntity member : vehicle.Members) ERR_ConvoyMountedCrew.Hold(member, vehicle.Entity); + } + foreach (ERR_SupplyTeam infantry : supply.Teams) Wake(infantry.Group); + if (!super.Admit(supply, now)) return false; + SCR_AIVehicleUsageComponent truck = SCR_AIVehicleUsageComponent.Cast(supply.Vehicles[2].Entity.FindComponent(SCR_AIVehicleUsageComponent)); + for (int index = 0; index < supply.Teams.Count(); index++) + { + ERR_SupplyTeam team = supply.Teams[index]; + if (team.Registered) continue; + if (!team.Group || team.Group.GetFaction() != m_Mode.EnemyFaction() || team.Members.Count() != 7) return false; + array agents = {}; team.Group.GetAgents(agents); + if (agents.Count() != 7) return false; + foreach (AIAgent agent : agents) + if (!agent || !team.Members.Contains(agent.GetControlledEntity()) || !ERR_Actors.CanFight(agent.GetControlledEntity()) || ERR_Actors.Player(agent.GetControlledEntity())) return false; + for (int slot = 0; slot < 7; slot++) + if (team.Seats[slot].GetOccupant() != team.Members[slot] || ERR_Actors.Group(team.Members[slot]) != team.Group) return false; + SCR_AIGroupUtilityComponent utility = team.Group.GetGroupUtilityComponent(); + if (!utility || !truck) return false; + utility.AddUsableVehicle(truck); + ERR_ForceRecord record = m_Mode.Forces().Register(supply.Receipt.SourceId + "-vehicle-" + (index + 6).ToString(), ERR_ForceKind.CONVOY, team.Group.GetOrigin(), 7, null, team.Group, now); + if (!record || !m_Pool.BindRecord(supply.Receipt, index + 6, record)) { supply.Stop(m_Mode, "infantry ledger admission failed; no replacement"); return false; } + record.Pinned = true; team.Registered = true; + m_Mode.RegisterGroup(team.Group); + ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command()); + if (hq) hq.ReserveGroup(team.Group); + team.Group.SetPermanentLOD(0); team.Group.ActivateAI(); + foreach (AIAgent active : agents) { active.SetPermanentLOD(0); active.ActivateAI(); } + } + supply.Tactics = new ERR_SupplyConvoyTactics(m_Mode, supply); + supply.TravelDeadline = now + 600000; + return true; + } + protected override void IssueCargo(ERR_ConvoyTask task) + { + ERR_SupplyConvoyTask supply = ERR_SupplyConvoyTask.Cast(task); + if (!supply || supply.CargoIssued || supply.Stopped) return; + supply.CargoIssued = true; + InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(supply.Vehicles[1].Entity.FindComponent(InventoryStorageManagerComponent)); + if (!inventory) { supply.MagazinesIssued = true; return; } + ERR_Inventory.Items(inventory, supply.CargoBefore); + SCR_EntityCatalogManagerComponent catalog = SCR_EntityCatalogManagerComponent.GetInstance(); + array weapons = {}; + if (catalog) catalog.GetFactionArsenalItems(weapons, SCR_Faction.Cast(m_Mode.EnemyFaction()), -1, SCR_EArsenalItemMode.WEAPON); + for (int i = 0; i < 8; i++) + { + ResourceName prefab = ERR_ConvoyResources.Rifle(); + if (!weapons.IsEmpty()) + { + SCR_ArsenalItem choice = weapons[Math.RandomInt(0, weapons.Count())]; + if (choice) prefab = choice.GetItemResourceName(); + } + ERR_ConvoyCargoCallback operation = new ERR_ConvoyCargoCallback(); operation.Prefab = prefab; supply.Cargo.Insert(operation); + if (!inventory.TrySpawnPrefabToStorage(prefab, null, -1, EStoragePurpose.PURPOSE_DEPOSIT, operation)) operation.Finished = true; + } + } + protected void IssueMagazines(ERR_SupplyConvoyTask task, float now) + { + if (task.MagazinesIssued || !task.CargoIssued || task.Stopped) return; + foreach (ERR_ConvoyCargoCallback operation : task.Cargo) + if (!operation.Finished && now < task.Deadline + 30000) return; + task.MagazinesIssued = true; + InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(task.Vehicles[1].Entity.FindComponent(InventoryStorageManagerComponent)); + if (!inventory) return; + array items = {}; ERR_Inventory.Items(inventory, items); + array expected = {}; + foreach (ERR_ConvoyCargoCallback weaponOperation : task.Cargo) + if (weaponOperation.Finished && weaponOperation.Succeeded) expected.Insert(weaponOperation.Prefab); + int issued; + foreach (IEntity item : items) + { + if (!item || task.CargoBefore.Contains(item) || !item.GetPrefabData()) continue; + int match = expected.Find(item.GetPrefabData().GetPrefabName()); + if (match < 0) continue; + expected.Remove(match); + BaseWeaponComponent weapon = BaseWeaponComponent.Cast(item.FindComponent(BaseWeaponComponent)); + if (!weapon) continue; + array muzzles = {}; weapon.GetMuzzlesList(muzzles); + foreach (BaseMuzzleComponent muzzle : muzzles) + { + ResourceName magazine = muzzle.GetDefaultMagazineOrProjectileName(); + if (magazine.IsEmpty()) continue; + for (int mag = 0; mag < 6 && issued < 96; mag++) + { + ERR_ConvoyCargoCallback load = new ERR_ConvoyCargoCallback(); load.Prefab = magazine; task.Cargo.Insert(load); issued++; + if (!inventory.TrySpawnPrefabToStorage(magazine, null, -1, EStoragePurpose.PURPOSE_DEPOSIT, load)) load.Finished = true; + } + } + } + task.CargoBefore.Clear(); + ERR_HQ.Event("CONVOY CARGO", task.Receipt.SourceId + ": eight weapon requests and matching finite ammunition batch; no refill"); + } + protected bool Captured(ERR_SupplyConvoyTask task) + { + foreach (ERR_ConvoyVehicle vehicle : task.Vehicles) + if (ERR_ConvoyRules.Captured(vehicle.Entity, vehicle.Group)) return true; + foreach (ERR_SupplyTeam team : task.Teams) + { + if (team.Group && team.Group.GetPlayerCount(true) > 0) return true; + foreach (IEntity member : team.Members) if (ERR_Actors.Player(member)) return true; + } + return false; + } + protected void Circuit(ERR_SupplyConvoyTask task, float now) + { + bool surviving, pending; + foreach (ERR_ConvoyVehicle vehicle : task.Vehicles) + { + if (!vehicle.Released && now >= task.TravelDeadline && task.TravelDeadline > 0) { vehicle.ClearOrder(); vehicle.Arrived = true; } + if (!vehicle.Released && task.Legs > 0 && vector.Distance(vehicle.Entity.GetOrigin(), task.Bases[task.LastBase]) <= 250) + { vehicle.ClearOrder(); vehicle.Arrived = true; } + vehicle.Tick(m_Mode, task.Route, now); + if (!vehicle.Released) { surviving = true; if (!vehicle.Arrived) pending = true; } + } + if (!surviving) { task.Stop(m_Mode, "no surviving drivable convoy vehicles"); return; } + if (pending) return; + if (!task.Waiting) { task.Waiting = true; task.Departure = now + Math.RandomFloat(45000, 90000); return; } + if (now < task.Departure || task.Bases.Count() < 2) return; + int next = ERR_SupplyConvoyRules.NextBase(task.Bases.Count(), task.LastBase, Math.RandomInt(0, task.Bases.Count() - 1)); + if (task.LastBase < 0) next = Math.RandomInt(0, task.Bases.Count()); + if (next < 0) return; + ERR_ForceRoute leg = new ERR_ForceRoute(); leg.Add(task.Bases[next]); leg.Seal(); + task.Route = leg; task.LastBase = next; task.Legs++; task.Waiting = false; task.TravelDeadline = now + 600000; + foreach (ERR_ConvoyVehicle departure : task.Vehicles) + { + if (departure.Released) continue; + departure.ClearOrder(); departure.RouteIndex = 0; departure.Arrived = false; + } + ERR_HQ.Event("CONVOY DEPARTURE", string.Format("%1 leg=%2 base=%3; native road navigation", task.Receipt.SourceId, task.Legs, task.Bases[next])); + } + override void Tick(float now) + { + if (!Current() || !ERR_ForceRules.Finite(now)) return; + foreach (ERR_ConvoyTask baseTask : m_Tasks) + { + ERR_SupplyConvoyTask task = ERR_SupplyConvoyTask.Cast(baseTask); + if (!task || task.Stopped || now < task.NextCheck) continue; + task.NextCheck = now + 1000; + if (Captured(task)) { task.Stop(m_Mode, "player captured convoy vehicle or crew"); continue; } + if (!task.Ready) + { + if (now >= task.Deadline) { task.Stop(m_Mode, "native crew readiness expired; partial convoy retained"); continue; } + if (!Admit(task, now)) continue; + task.Ready = true; IssueCargo(task); + } + IssueMagazines(task, now); + foreach (ERR_ConvoyVehicle checked : task.Vehicles) + if (!checked.Released && !checked.CrewValid(m_Mode)) checked.Release(m_Mode); + if (task.Tactics && task.Tactics.Tick(now)) continue; + Circuit(task, now); + } + } + override bool ApplyCombatOrder(ERR_ConvoyTask task, ERR_ConvoyCombatOrder order, float now) + { + ERR_SupplyConvoyTask supply = ERR_SupplyConvoyTask.Cast(task); + if (!supply || !supply.Tactics || !order) return false; + // Fresh delivered sightings may extend combat or interrupt reboarding. Only + // this controller may resume while the two infantry teams are still out. + if (!order.Hold && supply.Tactics.State() != 0) return false; + bool accepted = super.ApplyCombatOrder(task, order, now); + if (accepted && order.Hold) supply.Tactics.Report(order.Position, order.ObservedAt, now); + return accepted; + } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoySystem.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoySystem.c new file mode 100644 index 0000000..dd0fbe8 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoySystem.c @@ -0,0 +1,133 @@ +class ERR_SupplyConvoyRules +{ + static int NextBase(int count, int last, int roll) + { + if (count < 2 || last < -1 || last >= count || roll < 0 || roll >= count - 1) return -1; + if (last >= 0 && roll >= last) return roll + 1; + return roll; + } + static bool Separated(vector position, array starts, float distance) + { + foreach (vector start : starts) if (vector.DistanceXZ(start, position) < distance) return false; + return true; + } +} + +// Original finite startup target, eight bounded road candidates per attempt, +// separated initial positions and base-to-base circulation; never replacement spawns. +class ERR_SupplyConvoySystem +{ + protected ERR_GameMode m_Mode; + protected ERR_ConvoyPool m_Pool; + protected ERR_SupplyConvoyService m_Service; + protected ref array m_Bases = {}; + protected ref array m_Starts = {}; + protected int m_Target; + protected int m_Attempts; + protected bool m_Automatic; + protected bool m_Stopped; + protected float m_Began = -1; + protected float m_Next; + protected string m_Reason; + void ERR_SupplyConvoySystem(ERR_GameMode mode, ERR_ConvoyPool pool, ERR_SupplyConvoyService service, int target, bool automatic) + { + m_Mode = mode; m_Pool = pool; m_Service = service; + m_Target = Math.Clamp(target, 0, 20); m_Automatic = automatic; + } + int Target() { return m_Target; } + static bool Approach(vector center, out vector position, float radius = 500) + { + vector angles; + return ApproachDirected(center, position, angles, radius); + } + static bool ApproachDirected(vector center, out vector position, out vector angles, float radius = 500) + { + SCR_AIWorld world = SCR_AIWorld.Cast(GetGame().GetAIWorld()); + if (!world || !world.GetRoadNetworkManager()) return false; + array roads = {}; + world.GetRoadNetworkManager().GetRoadsInAABB(center - Vector(radius, 1000, radius), center + Vector(radius, 1000, radius), roads); + if (roads.Count() > 128) return false; + float nearest = radius; bool found; + foreach (BaseRoad road : roads) + { + if (!road || road.GetWidth() < 4) continue; + array points = {}; road.GetPoints(points); + if (points.Count() > 1024) continue; + for (int i = 1; i < points.Count(); i++) + { + vector delta = points[i] - points[i - 1]; delta[1] = 0; + float length = vector.Dot(delta, delta); + if (length < 0.01) continue; + vector offset = center - points[i - 1]; offset[1] = 0; + float fraction = Math.Clamp(vector.Dot(offset, delta) / length, 0, 1); + vector candidate = ERR_Actors.Ground(vector.Lerp(points[i - 1], points[i], fraction)); + float distance = vector.DistanceXZ(center, candidate); + if (distance >= nearest || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), candidate)) continue; + nearest = distance; position = candidate; angles = Vector(Math.Atan2(delta[0], delta[2]) * Math.RAD2DEG, 0, 0); found = true; + } + } + return found; + } + protected void FindBases() + { + m_Bases.Clear(); + if (!m_Mode.Sites()) return; + for (int i = 0; i < m_Mode.Sites().Count(); i++) + { + ERR_SiteRuntime site = m_Mode.Sites().At(i); + if (!site || !site.Definition) continue; + ERR_LocationKind kind = site.Definition.Kind; + if (kind != ERR_LocationKind.OUTPOST && kind != ERR_LocationKind.DEPOT && kind != ERR_LocationKind.RADIO) continue; + if (site.Group && site.Group.GetFaction() != m_Mode.EnemyFaction()) continue; + vector approach; + if (Approach(site.Definition.Position, approach) && ERR_SupplyConvoyRules.Separated(approach, m_Bases, 300)) m_Bases.Insert(approach); + } + m_Service.SetBases(m_Bases); + } + bool StartOne() + { + if (m_Stopped || !Replication.IsServer() || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active() || m_Service.Count() >= m_Target) return false; + if (m_Bases.Count() < 2) FindBases(); + if (m_Bases.Count() < 2) { m_Reason = "waiting for two friendly military road approaches"; return false; } + if (!GetGame().GetWorldEntity()) return false; + vector low, high; GetGame().GetWorldEntity().GetWorldBounds(low, high); + float separation = Math.Min(1000, Math.Max(high[0] - low[0], high[2] - low[2]) * 0.1); + for (int attempt = 0; attempt < 8; attempt++) + { + vector seed = Vector(Math.RandomFloat(low[0], high[0]), 0, Math.RandomFloat(low[2], high[2])); + // A nearby base is the first candidate; remaining attempts cover the island. + if (attempt == 0) seed = m_Bases[(m_Attempts + m_Service.Count()) % m_Bases.Count()]; + seed = ERR_Actors.Ground(seed); + array positions, angles; ERR_ForceRoute route; + if (!ERR_ConvoyRoadPlan.Build(seed, positions, angles, route) || !ERR_SupplyConvoyRules.Separated(positions[0], m_Starts, separation)) continue; + string source = "supply-convoy-" + (m_Service.Count() + 1).ToString(); + ERR_ConvoyReceipt receipt = m_Pool.Reserve(source); + if (!receipt || receipt.Consumed) { m_Reason = "configured convoy capacity or shared force budget unavailable"; return false; } + ERR_ConvoyTask task = m_Service.Dispatch(receipt, positions, angles, route, GetGame().GetWorld().GetWorldTime()); + if (!task) { m_Reason = "road preflight changed; no entities created"; return false; } + m_Starts.Insert(positions[0]); + m_Reason = "finite convoy slot deployed"; + if (task.Stopped) m_Reason = "partial startup retained; this slot cannot respawn"; + ERR_HQ.Event("CONVOY STARTUP", string.Format("%1/%2 at %3: %4", m_Service.Count(), m_Target, positions[0], m_Reason)); + return !task.Stopped; + } + m_Reason = "no clear separated road formation in this bounded survey"; + return false; + } + void Tick(float now, bool admitted) + { + if (m_Stopped || !m_Automatic || m_Target == 0 || m_Attempts >= 120 || m_Service.Count() >= m_Target || !ERR_ForceRules.Finite(now) || now < 0) return; + if (m_Began < 0) { m_Began = now; m_Next = now + 30000; } + if (now < m_Next) return; + m_Next = now + 5000; + if (!admitted) { m_Reason = "waiting for existing force admission"; return; } + m_Attempts++; + StartOne(); + if (m_Attempts == 120 && m_Service.Count() < m_Target) ERR_HQ.Event("CONVOY STARTUP", "bounded survey exhausted; deployed slots retained without replacement"); + } + string Status() + { + return string.Format("This round: %1/%2 convoy slots; automatic=%3, startup attempts=%4/120; %5", m_Service.Count(), m_Target, m_Automatic, m_Attempts, m_Reason); + } + void Stop() { m_Stopped = true; } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c new file mode 100644 index 0000000..956639e --- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c @@ -0,0 +1,404 @@ +// Original convoy tactics adapted to native AI waypoints. No old framework hooks. +class ERR_SupplyTeam +{ + SCR_AIGroup Group; + ref array Members = {}; + ref array Seats = {}; + ref array Orders = {}; + bool Registered; + bool Released; + vector OrderedContact; + void Clear() + { + foreach (AIWaypoint waypoint : Orders) + { + if (Group && waypoint) Group.RemoveWaypoint(waypoint); + if (waypoint) SCR_EntityHelper.DeleteEntityAndChildren(waypoint); + } + Orders.Clear(); + } + void Add(AIWaypoint waypoint) + { + if (!waypoint) return; + if (!Group || Released) { SCR_EntityHelper.DeleteEntityAndChildren(waypoint); return; } + Orders.Insert(waypoint); Group.AddWaypoint(waypoint); + } + void Release(ERR_GameMode mode) + { + if (Released) return; + Released = true; Clear(); + if (!Group) return; + if (mode && mode.Command()) mode.Command().UnregisterGroup(Group); + ERR_ForceRecord record; + if (mode && mode.Forces()) record = mode.Forces().FindGroup(Group); + if (record) record.Pinned = false; + Group.SetPermanentLOD(-1); + array agents = {}; Group.GetAgents(agents); + foreach (AIAgent agent : agents) if (agent) agent.SetPermanentLOD(-1); + } + bool Living() + { + foreach (IEntity member : Members) + if (ERR_Actors.CanFight(member) && !ERR_Actors.Player(member) && ERR_Actors.Group(member) == Group) return true; + return false; + } + bool Aboard(Vehicle truck, bool required) + { + foreach (IEntity member : Members) + { + if (!ERR_Actors.CanFight(member) || ERR_Actors.Group(member) != Group) continue; + if ((CompartmentAccessComponent.GetVehicleIn(member) == truck) != required) return false; + } + return true; + } +} + +// Only the exact original vehicle crews are retained. Infantry never enters this map. +class ERR_ConvoyMountedCrew +{ + protected static ref map s_Crews = new map(); + static void Hold(IEntity actor, Vehicle vehicle) { if (actor && vehicle) s_Crews.Set(actor, vehicle); } + static void Release(IEntity actor) { if (actor) s_Crews.Remove(actor); } + static bool Retain(IEntity actor, IEntity vehicle) + { + if (!actor || !vehicle || !s_Crews.Contains(actor) || s_Crews.Get(actor) != vehicle || ERR_Actors.Player(actor) || !ERR_Actors.CanFight(actor)) return false; + if (CompartmentAccessComponent.GetVehicleIn(actor) != vehicle) return false; + SCR_VehicleDamageManagerComponent damage = SCR_VehicleDamageManagerComponent.Cast(vehicle.FindComponent(SCR_VehicleDamageManagerComponent)); + // Let native emergency abandonment take over on critical damage or drivetrain failure. + return damage && damage.GetState() != EDamageState.DESTROYED && damage.GetHealthScaled() > 0.25 && damage.GetEngineFunctional() && damage.GetGearboxFunctional(); + } + static bool RetainGroup(SCR_AIGroup group, IEntity vehicle) + { + if (!group || group.GetPlayerCount(true) > 0) return false; + IEntity leader = group.GetLeaderEntity(); + if (!leader) return false; + if (!vehicle) vehicle = CompartmentAccessComponent.GetVehicleIn(leader); + return Retain(leader, vehicle); + } + +} +modded class SCR_AIGetOutVehicle +{ + override float CustomEvaluate() + { + if (m_Utility && ERR_ConvoyMountedCrew.Retain(m_Utility.m_OwnerEntity, m_Vehicle.m_Value)) return 0; + return super.CustomEvaluate(); + } + override float EvaluatePriorityLevel() + { + if (m_Utility && ERR_ConvoyMountedCrew.Retain(m_Utility.m_OwnerEntity, m_Vehicle.m_Value)) return 0; + return super.EvaluatePriorityLevel(); + } +} +modded class SCR_AIGetOutActivity +{ + override float CustomEvaluate() + { + if (m_Utility && ERR_ConvoyMountedCrew.RetainGroup(m_Utility.m_Owner, m_Vehicle.m_Value)) return 0; + return super.CustomEvaluate(); + } + override float EvaluatePriorityLevel() + { + if (m_Utility && ERR_ConvoyMountedCrew.RetainGroup(m_Utility.m_Owner, m_Vehicle.m_Value)) return 0; + return super.EvaluatePriorityLevel(); + } +} + +class ERR_SupplyConvoyVehicle : ERR_ConvoyVehicle +{ + override bool CrewValid(ERR_GameMode mode) + { + if (!Group || !Entity || !ERR_Actors.Alive(Entity) || Group.GetFaction() != mode.EnemyFaction() || !ERR_Actors.CanFight(Driver)) return false; + if (ERR_Actors.Player(Driver) || ERR_Actors.Group(Driver) != Group || !ERR_ConvoyRules.PartOf(CompartmentAccessComponent.GetVehicleIn(Driver), Entity)) return false; + foreach (IEntity member : Members) + if (ERR_Actors.Alive(member) && (ERR_Actors.Player(member) || ERR_Actors.Group(member) != Group)) return false; + return true; + } + override void Release(ERR_GameMode mode) + { + foreach (IEntity member : Members) ERR_ConvoyMountedCrew.Release(member); + super.Release(mode); + } +} + +class ERR_SupplyConvoyTask : ERR_ConvoyTask +{ + ref array Teams = {}; + ref ERR_SupplyConvoyTactics Tactics; + ref array Bases = {}; + ref array Starts = {}; + int LastBase = -1; + int Legs; + float Departure; + float TravelDeadline; + bool Waiting; + bool MagazinesIssued; + ref array CargoBefore = {}; + override void Stop(ERR_GameMode mode, string reason) + { + if (Stopped) return; + if (Tactics) Tactics.Stop(); + foreach (ERR_SupplyTeam team : Teams) team.Release(mode); + super.Stop(mode, reason); + } +} + +class ERR_SupplyConvoyTactics +{ + protected ERR_GameMode m_Mode; + protected ERR_SupplyConvoyTask m_Task; + protected ref array m_Support = {}; + protected int m_State; // mounted, fighting, reboarding + protected vector m_Contact; + protected vector m_Forward; + protected vector m_Left; + protected float m_LastContact = -1; + protected float m_NextOrders; + protected float m_BoardDeadline; + protected bool m_TruckWithdrawn; + protected float m_NextDismount; + protected int m_DismountAttempts; + protected bool m_Stopped; + void ERR_SupplyConvoyTactics(ERR_GameMode mode, ERR_SupplyConvoyTask task) + { + m_Mode = mode; m_Task = task; + foreach (ERR_ConvoyVehicle vehicle : task.Vehicles) + { + ERR_SupplyTeam order = new ERR_SupplyTeam(); order.Group = vehicle.Group; + m_Support.Insert(order); + foreach (IEntity member : vehicle.Members) ERR_ConvoyMountedCrew.Hold(member, vehicle.Entity); + } + } + int State() { return m_State; } + static AIWaypoint Move(vector position, float radius = 20) + { + AIWaypoint waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), position)); + if (waypoint) waypoint.SetCompletionRadius(radius); + return waypoint; + } + static AIWaypoint Board(Vehicle truck, bool enter) + { + if (!truck) return null; + ResourceName prefab = "{C40316EE26846CAB}Prefabs/AI/Waypoints/AIWaypoint_GetOut.et"; + if (enter) prefab = "{712F4795CF8B91C7}Prefabs/AI/Waypoints/AIWaypoint_GetIn.et"; + SCR_BoardingWaypoint waypoint = SCR_BoardingWaypoint.Cast(ERR_Actors.Spawn(prefab, truck.GetOrigin())); + if (!waypoint) return null; + waypoint.SetAllowance(false, false, true); + // Native defaults may complete when only the leader has left/boarded. + waypoint.SetCompletionType(EAIWaypointCompletionType.All); + // The waypoint approach applies to the whole team; leave space around the truck. + waypoint.SetCompletionRadius(100); + SCR_BoardingEntityWaypoint specific = SCR_BoardingEntityWaypoint.Cast(waypoint); + if (specific) specific.SetEntity(truck); + return waypoint; + } + protected bool Usable(int index) + { + return index >= 0 && index < m_Task.Vehicles.Count() && !m_Task.Vehicles[index].Released && m_Task.Vehicles[index].CrewValid(m_Mode); + } + protected bool InfantryAboard(bool required) + { + foreach (ERR_SupplyTeam team : m_Task.Teams) + if (!team.Released && !team.Aboard(m_Task.Vehicles[2].Entity, required)) return false; + return true; + } + protected float DistanceToConvoy(vector position) + { + float distance = 1e20; + foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles) + if (!vehicle.Released && vehicle.Entity) distance = Math.Min(distance, vector.DistanceXZ(vehicle.Entity.GetOrigin(), position)); + return distance; + } + protected bool Observe(SCR_AIGroup group, out vector position, inout float nearest) + { + if (!group || !group.GetGroupUtilityComponent()) return false; + SCR_AIGroupPerception perception = group.GetGroupUtilityComponent().m_Perception; + PerceptionManager manager = GetGame().GetPerceptionManager(); + if (!perception || !manager) return false; + bool found; + foreach (SCR_AITargetInfo target : perception.m_aTargets) + { + if (!target || !target.m_Entity || !target.m_Faction || !group.GetFaction().IsFactionEnemy(target.m_Faction)) continue; + float age = manager.GetTime() - target.m_fTimestamp; + if (target.m_eCategory != EAITargetInfoCategory.IDENTIFIED || age < 0 || age > 5) continue; + float distance = DistanceToConvoy(target.m_vWorldPos); + if (distance > nearest || !group.GetGroupUtilityComponent().IsPositionAllowed(target.m_vWorldPos)) continue; + position = target.m_vWorldPos; nearest = distance; found = true; + } + return found; + } + // Only production native observations or a delivered HQ snapshot may call this. + void Report(vector position, float observed, float now) + { + if (m_Stopped || !ERR_ForceRules.Finite(now) || !ERR_ForceRules.Finite(observed) || !ERR_ForceRules.FiniteVector(position) || observed < 0 || observed > now || now - observed > 60000 || observed < m_LastContact) return; + m_LastContact = observed; + if (m_State != 1) Start(position, now); + else if (vector.DistanceXZ(position, m_Contact) > 40 && now >= m_NextOrders) { m_Contact = position; Orders(false, now); } + } + protected bool FootPosition(ERR_SupplyTeam team, vector desired, out vector position) + { + if (!team.Group || !team.Group.GetGroupUtilityComponent()) return false; + for (int i = 0; i < 8; i++) + { + vector point = desired; + if (i > 0) point += Vector(Math.RandomFloat(-12, 12), 0, Math.RandomFloat(-12, 12)); + point = ERR_Actors.Ground(point); + if (!ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point) && team.Group.GetGroupUtilityComponent().IsPositionAllowed(point) && SCR_WorldTools.TraceCilinderUtil(point + "0 1 0", 1, 2)) { position = point; return true; } + } + return false; + } + protected void Flank(int index, bool dismount) + { + ERR_SupplyTeam team = m_Task.Teams[index]; + if (team.Released || !team.Living()) return; + // Preserve an in-progress native all-member dismount across contact refreshes. + if (!team.Aboard(m_Task.Vehicles[2].Entity, false) && SCR_BoardingWaypoint.Cast(team.Group.GetCurrentWaypoint())) return; + team.Clear(); team.OrderedContact = m_Contact; + if (dismount && !team.Aboard(m_Task.Vehicles[2].Entity, false)) team.Add(Board(m_Task.Vehicles[2].Entity, false)); + float side = 1; if (index == 1) side = -1; + vector approach, sector; + if (!FootPosition(team, m_Contact - m_Forward * 80 + m_Left * (110 * side), approach) || !FootPosition(team, m_Contact + m_Left * (70 * side), sector)) return; + team.Add(Move(approach, 15)); + AIWaypoint search = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.SearchWaypoint(), sector)); + if (search) search.SetCompletionRadius(30); + team.Add(search); + } + protected bool SupportPosition(int index, vector desired, out vector position) + { + ERR_ConvoyVehicle vehicle = m_Task.Vehicles[index]; + SCR_AIGroupUtilityComponent utility = vehicle.Group.GetGroupUtilityComponent(); + if (!utility) return false; + vector road; + if (ERR_SupplyConvoySystem.Approach(desired, road, 70) && vector.Dot(road - m_Contact, m_Forward) <= -100 && utility.IsPositionAllowed(road)) { position = road; return true; } + if (index == 1 || index == 2) return false; + // Original armed escorts can seek a nearby open firing position off the road. + ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement(); + for (int attempt = 0; attempt < 8; attempt++) + { + vector candidate = desired; + if (attempt > 0) candidate += Vector(Math.RandomFloat(-15, 15), 0, Math.RandomFloat(-15, 15)); + candidate = ERR_Actors.Ground(candidate); + if (vector.Dot(candidate - m_Contact, m_Forward) > -100 || !utility.IsPositionAllowed(candidate)) continue; + if (placement.Clear(candidate, ERR_ForceRules.Heading(candidate, m_Contact, 0))) { position = candidate; return true; } + } + return false; + } + protected void Support(int index, bool holdTruck) + { + ERR_SupplyTeam orders = m_Support[index]; orders.Clear(); + if (!Usable(index)) return; + ERR_ConvoyVehicle vehicle = m_Task.Vehicles[index]; + // An idle mounted driver stops without a Defend order asking the crew to exit. + if (index == 2 && holdTruck) return; + float rear = 180, side; + if (index == 0) side = 150; + if (index == 3) side = -150; + if (index == 5) rear = 250; + if (index == 1) { rear = 350; side = 40; } + if (index == 2) { rear = 310; side = -40; } + vector support = vehicle.Entity.GetOrigin(); + vector desired = m_Contact - m_Forward * rear + m_Left * side; + vector chosen; + if (SupportPosition(index, desired, chosen)) support = chosen; + if (vector.DistanceXZ(vehicle.Entity.GetOrigin(), support) > 20) orders.Add(Move(support)); + if (index == 1 || index == 2) return; + AIWaypoint defend = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.DefendWaypoint(), support)); + if (defend) defend.SetCompletionRadius(20); + orders.Add(defend); + } + protected void Clear() + { + foreach (ERR_SupplyTeam orders : m_Support) orders.Clear(); + foreach (ERR_SupplyTeam team : m_Task.Teams) team.Clear(); + } + protected void Orders(bool dismount, float now) + { + for (int i = 0; i < m_Task.Vehicles.Count(); i++) Support(i, !m_TruckWithdrawn); + for (int i = 0; i < m_Task.Teams.Count(); i++) Flank(i, dismount); + m_NextOrders = now + 30000; + } + protected void Start(vector contact, float now) + { + Clear(); m_State = 1; m_Contact = contact; m_TruckWithdrawn = false; + vector origin; + foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles) + { + vehicle.ClearOrder(); + if (!vehicle.Released) vehicle.Combat.Held = true; + if (!vehicle.Released) origin = vehicle.Entity.GetOrigin(); + } + m_Forward = contact - origin; m_Forward[1] = 0; + if (m_Forward.Length() < 1) m_Forward = "0 0 1"; + m_Forward.Normalize(); m_Left = Vector(-m_Forward[2], 0, m_Forward[0]); + Orders(true, now); + m_DismountAttempts = 0; m_NextDismount = now + 10000; + ERR_HQ.Event("CONVOY COMBAT", m_Task.Receipt.SourceId + ": infantry flank left/right; escorts cover; troop truck holds for dismount"); + } + protected void Finish(float now) + { + Clear(); m_State = 0; + foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles) + { + vehicle.Combat.Held = false; + // Combat time does not consume the driving deadline. + vehicle.LegDeadline = now + 600000; + } + m_Task.TravelDeadline = now + 600000; + ERR_HQ.Event("CONVOY REGROUP", m_Task.Receipt.SourceId + ": surviving convoy resumes; no replacements"); + } + bool Tick(float now) + { + if (m_Stopped) return false; + for (int released = 0; released < m_Task.Vehicles.Count(); released++) + if (m_Task.Vehicles[released].Released) m_Support[released].Clear(); + foreach (ERR_SupplyTeam infantryTeam : m_Task.Teams) + { + if (infantryTeam.Released) continue; + bool foreign = !infantryTeam.Group || infantryTeam.Group.GetFaction() != m_Mode.EnemyFaction(); + foreach (IEntity member : infantryTeam.Members) + if (ERR_Actors.Alive(member) && ERR_Actors.Group(member) != infantryTeam.Group) foreign = true; + if (foreign || !infantryTeam.Living()) infantryTeam.Release(m_Mode); + } + vector observed; float nearest = 400; bool found; + foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles) + if (!vehicle.Released && Observe(vehicle.Group, observed, nearest)) found = true; + foreach (ERR_SupplyTeam team : m_Task.Teams) + if (!team.Released && Observe(team.Group, observed, nearest)) found = true; + if (found) Report(observed, now, now); + if (m_State == 1) + { + if (!m_TruckWithdrawn && InfantryAboard(false)) { m_TruckWithdrawn = true; Support(2, false); } + // Native exit attempts can fail while the truck brakes or a door is busy. + // Retry only still-mounted teams, never teleport actors or reset quiet time. + if (!m_TruckWithdrawn && Usable(2) && now >= m_NextDismount && m_DismountAttempts < 6) + { + m_DismountAttempts++; m_NextDismount = now + 10000; + for (int retry = 0; retry < m_Task.Teams.Count(); retry++) + { + ERR_SupplyTeam delayed = m_Task.Teams[retry]; + if (delayed.Released || delayed.Aboard(m_Task.Vehicles[2].Entity, false)) continue; + delayed.Clear(); Flank(retry, true); + } + } + if (found && now >= m_NextOrders) Orders(!InfantryAboard(false), now); + if (now - m_LastContact >= 60000) + { + Clear(); + if (!Usable(2)) { foreach (ERR_SupplyTeam lost : m_Task.Teams) lost.Release(m_Mode); Finish(now); return false; } + for (int i = 0; i < m_Task.Vehicles.Count(); i++) Support(i, true); + foreach (ERR_SupplyTeam boarding : m_Task.Teams) + if (!boarding.Released && boarding.Living()) boarding.Add(Board(m_Task.Vehicles[2].Entity, true)); + m_State = 2; m_BoardDeadline = now + 120000; + ERR_HQ.Event("CONVOY BOARDING", m_Task.Receipt.SourceId + ": both teams return under escort cover"); + } + } + if (m_State == 2 && (!Usable(2) || InfantryAboard(true) || now >= m_BoardDeadline)) + { + foreach (ERR_SupplyTeam stranded : m_Task.Teams) + if (!stranded.Aboard(m_Task.Vehicles[2].Entity, true)) stranded.Release(m_Mode); + Finish(now); + } + return m_State != 0; + } + void Stop() { if (m_Stopped) return; m_Stopped = true; Clear(); } +} diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupportScenario.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupportScenario.c index 1cb5412..8105d3a 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupportScenario.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupportScenario.c @@ -1,5 +1,11 @@ modded class ERR_ScenarioComponent { + protected override ERR_PrepareResult PrepareAdditionalPopulation() + { + ERR_HQ hq = ERR_HQ.Get(); + if (!hq) return ERR_PrepareResult.FAILED; + return hq.Scouts().Prepare(m_RuntimeDefinition); + } protected override bool PrepareAdditionalForces() { if (!super.PrepareAdditionalForces()) return false; diff --git a/addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelDiscoveryFeedback.c b/addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelDiscoveryFeedback.c index cdaef14..483f03c 100644 --- a/addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelDiscoveryFeedback.c +++ b/addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelDiscoveryFeedback.c @@ -36,22 +36,16 @@ modded class ERR_GameMode protected void ERR_FlushDiscoveryFeedback(float now) { if (!Replication.IsServer() || m_ERRIntelFeedbackStopped || !Active() || m_ERRIntelPending.IsEmpty() || now < m_ERRIntelMessageAt) return; - int identified; - int rough; - string label; + string message; foreach (ERR_LocationComponent location, int knowledge : m_ERRIntelPending) { if (!location) continue; - if (knowledge == 2) - { - identified++; - label = location.Label(); - } - else rough++; + string entry = ERR_IntelDiscoveryText.Message(knowledge == 2, knowledge == 1, ERR_IntelDiscoveryText.Type(location.Kind(), location.Id())); + if (!message.IsEmpty()) message += "\n"; + message += entry; } m_ERRIntelPending.Clear(); m_ERRIntelMessageAt = now + 5000; - string message = ERR_IntelDiscoveryText.Message(identified, rough, label); if (message.IsEmpty()) return; // The listen host and remote recipients share the same per-round sequence. // Local delivery is explicit and cannot duplicate a transport callback. @@ -66,6 +60,15 @@ modded class ERR_GameMode m_ERRIntelReceivedSequence = sequence; SCR_ChatComponent.RadioProtocolMessage(message); } + void ERR_IntelExhausted(bool duplicate = false) + { + if (!Replication.IsServer() || m_ERRIntelFeedbackStopped || !Active()) return; + string message = "Every known location has already been identified. This intel doesn't help us."; + if (duplicate) message = "This document concerns a location we have already identified."; + m_ERRIntelMessageSequence++; + ERR_ReceiveDiscoveryFeedback(m_ERRIntelMessageSequence, message); + Rpc(ERR_ReceiveDiscoveryFeedback, m_ERRIntelMessageSequence, message); + } protected void ERR_StopDiscoveryFeedback() { m_ERRIntelFeedbackStopped = true; @@ -79,15 +82,22 @@ modded class ERR_GameMode class ERR_IntelDiscoveryText { + static string Type(int kind, string id) + { + if (kind == ERR_LocationKind.RADIO) return "radio station"; + if (kind == ERR_LocationKind.OUTPOST) return "outpost"; + if (kind == ERR_LocationKind.MORTAR) return "mortar site"; + if (id.Contains("medical")) return "medical stash"; + if (kind == ERR_LocationKind.DEPOT) return "supply cache"; + return "location"; + } + // Exact wording from the owner's original EI_IntelService. New site types + // supply their own noun; unknown clues never reveal the noun or site name. static string Message(int identified, int rough, string label) { if (identified + rough == 0) return ""; - if (identified == 0 && rough == 1) return "A new 500 m search area is marked on your field map. Recover another folder to identify the location."; - if (identified == 1 && rough == 0) - { - if (label.IsEmpty()) label = "A location"; - return label + " identified. Check your field map."; - } - return string.Format("Field map updated: %1 identified locations and %2 search areas. Open a carried map to review them.", identified, rough); + if (identified == 0) return "New location marked. Type unknown."; + if (label.IsEmpty()) label = "location"; + return "Hey! A " + label + " was identified in the intel!"; } } diff --git a/addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelEvidence.c b/addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelEvidence.c new file mode 100644 index 0000000..d4e80d1 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelEvidence.c @@ -0,0 +1,197 @@ +// Issued document identities and their sealed contents. This is local logistics +// bookkeeping, not HQ knowledge of player inventories or map discoveries. +class ERR_IssuedIntel +{ + IEntity Folder; + string SiteId; +} +class ERR_BodyEvidence +{ + IEntity Actor; + string SourceId; + ref array Issued = {}; + bool Searched; + bool Reported; +} +class ERR_IntelEvidence +{ + protected ref array m_Roster = {}; + protected bool m_Stopped; + protected int m_Cursor; + void Retire(IEntity folder) { foreach (ERR_BodyEvidence record : m_Roster) for (int i = record.Issued.Count() - 1; i >= 0; i--) if (record.Issued[i].Folder == folder) record.Issued.Remove(i); + } + ERR_BodyEvidence Find(IEntity actor) + { + foreach (ERR_BodyEvidence record : m_Roster) { if (record.Actor == actor) return record; + } + return null; + } + void Register(SCR_AIGroup group) + { + ERR_GameMode mode = ERR_GameMode.Get(); + if (m_Stopped || !mode || !Replication.IsServer() || !group || group.GetFaction() != mode.EnemyFaction()) return; + ERR_ForceRecord force = mode.Forces().FindGroup(group); + if (!force) return; + array agents = {}; + group.GetAgents(agents); + foreach (AIAgent agent : agents) + { + if (!agent || !agent.GetControlledEntity() || Find(agent.GetControlledEntity()) || m_Roster.Count() >= 4096) continue; + ERR_BodyEvidence record = new ERR_BodyEvidence(); + record.Actor = agent.GetControlledEntity(); + record.SourceId = force.SourceId; + m_Roster.Insert(record); + SCR_GarbageSystem garbage = SCR_GarbageSystem.GetByEntityWorld(record.Actor); + if (garbage) garbage.UpdateBlacklist(record.Actor, true); + } + } + void Capture(IEntity actor) + { + ERR_BodyEvidence record = Find(actor); + if (!record || record.Searched || !ERR_Actors.Alive(actor) || ERR_Actors.Player(actor)) return; + InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent)); + if (!inventory) return; + array folders = {}; + ERR_DiscoveryService.Folders(inventory, folders); + array locations = {}; + ERR_GameMode.Get().Locations(locations); + array eligible = {}; + foreach (ERR_LocationComponent location : locations) + if (location && location.Kind() != ERR_LocationKind.PRISON && location.Kind() != ERR_LocationKind.EXTRACTION) eligible.Insert(location); + if (eligible.IsEmpty()) return; + foreach (IEntity folder : folders) + { + bool known; + foreach (ERR_IssuedIntel issued : record.Issued) if (issued.Folder == folder) known = true; + if (known || record.Issued.Count() >= 16) continue; + ERR_IssuedIntel item = new ERR_IssuedIntel(); + item.Folder = folder; + ERR_DocumentComponent document = ERR_DocumentComponent.Cast(folder.FindComponent(ERR_DocumentComponent)); + if (document) item.SiteId = document.TargetId(); + else item.SiteId = eligible.GetRandomElement().Id(); + record.Issued.Insert(item); + } + } + string Target(IEntity folder) + { + foreach (ERR_BodyEvidence record : m_Roster) + foreach (ERR_IssuedIntel issued : record.Issued) if (issued.Folder == folder) return issued.SiteId; + return ""; + } + void Tick(float now) + { + if (m_Stopped) return; + // Preserve only the mission-owned cohort. Transferred/player-controlled actors + // are released from retention instead of becoming forensic targets. + for (int budget = 0; budget < Math.Min(16, m_Roster.Count()); budget++) + { + if (m_Cursor >= m_Roster.Count()) m_Cursor = 0; + ERR_BodyEvidence record = m_Roster[m_Cursor++]; + if (!record.Actor || record.Reported) continue; + if (ERR_Actors.Player(record.Actor) || SCR_FactionManager.SGetFaction(record.Actor) != ERR_GameMode.Get().EnemyFaction()) + { record.Reported = true; + record.Searched = true; + Release(record); + continue; + } + if (ERR_Actors.Alive(record.Actor)) Capture(record.Actor); + } + } + bool Inspect(IEntity body, IEntity observer, out array missing) + { + missing.Clear(); + if (!Replication.IsServer() || m_Stopped || !ERR_GameMode.Get() || !ERR_GameMode.Get().Active()) return false; + ERR_BodyEvidence record = Find(body); + if (!record || record.Searched || ERR_Actors.Alive(body) || !ERR_Actors.CanFight(observer) || ERR_Actors.Player(observer) || CompartmentAccessComponent.GetVehicleIn(observer)) return false; + if (vector.Distance(observer.GetOrigin(), body.GetOrigin()) > 3) return false; + TraceParam trace = new TraceParam(); + trace.Start = observer.GetOrigin() + "0 1.5 0"; + trace.End = body.GetOrigin() + "0 0.3 0"; + trace.Exclude = observer; + trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD; + if (GetGame().GetWorld().TraceMove(trace, null) < 1 && trace.TraceEnt != body) return false; + InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(body.FindComponent(InventoryStorageManagerComponent)); + if (!inventory) return false; + array folders = {}; + ERR_DiscoveryService.Folders(inventory, folders); + foreach (ERR_IssuedIntel issued : record.Issued) + if (!folders.Contains(issued.Folder) && !missing.Contains(issued.SiteId)) missing.Insert(issued.SiteId); + record.Searched = true; + return true; + } + void Release(ERR_BodyEvidence record) + { + if (!record || !record.Actor) return; + SCR_GarbageSystem garbage = SCR_GarbageSystem.GetByEntityWorld(record.Actor); + if (garbage) { garbage.UpdateBlacklist(record.Actor, false); + if (!ERR_Actors.Alive(record.Actor)) garbage.Insert(record.Actor, 300); + } + } + // Searching alone does not authorize cleanup: the missing-document report + // must first reach HQ. Duplicate delivery never renews the cleanup timer. + void Delivered(IEntity body) + { + if (!Replication.IsServer() || m_Stopped) return; + ERR_BodyEvidence record = Find(body); + if (!record || !record.Searched || record.Reported) return; + record.Reported = true; + Release(record); + } + bool Retained(IEntity actor) { ERR_BodyEvidence record = Find(actor); + return !m_Stopped && record && !record.Reported; + } + bool RetainedWithin(IEntity entity) + { + if (m_Stopped || !entity) return false; + foreach (ERR_BodyEvidence record : m_Roster) + { + if (!record.Actor || record.Reported || ERR_Actors.Alive(record.Actor)) continue; + IEntity parent = record.Actor; + for (int depth = 0; parent && depth < 32; depth++) + { + if (parent == entity) return true; + parent = parent.GetParent(); + } + } + return false; + } + void Stop() { m_Stopped = true; + foreach (ERR_BodyEvidence record : m_Roster) Release(record); + m_Roster.Clear(); + } +} +modded class ERR_GameMode +{ + protected ref ERR_IntelEvidence m_Evidence = new ERR_IntelEvidence(); + ERR_IntelEvidence Evidence() { return m_Evidence; + } + protected override void EOnInit(IEntity owner) + { + super.EOnInit(owner); + if (SCR_Global.IsEditMode() || !Replication.IsServer()) return; + OnGroup().Insert(m_Evidence.Register); + OnTick().Insert(m_Evidence.Tick); + OnStop().Insert(m_Evidence.Stop); + } +} + +// Native vehicle garbage collection must not delete a retained corpse through +// its parent hierarchy (for example a dead occupant in a wreck). +modded class SCR_GarbageSystem +{ + protected bool ERR_RetainedEvidence(IEntity entity) + { + ERR_GameMode mode = ERR_GameMode.Get(); + return Replication.IsServer() && mode && mode.Evidence() && mode.Evidence().RetainedWithin(entity); + } + override protected float OnInsertRequested(IEntity entity, float lifetime) + { + if (ERR_RetainedEvidence(entity)) return 0; + return super.OnInsertRequested(entity, lifetime); + } + override protected bool OnBeforeDelete(IEntity entity) + { + if (ERR_RetainedEvidence(entity)) return false; + return super.OnBeforeDelete(entity); + } +} diff --git a/addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelReroll.c b/addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelReroll.c index 57e6c5d..697d83a 100644 --- a/addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelReroll.c +++ b/addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelReroll.c @@ -127,6 +127,7 @@ class ERR_IntelReroll // the existing phase budget without spending or owning the folder. if (!inventory.TryRemoveItemFromInventory(folder)) { entry.WaitingFor = "removal-refused"; return; } m_Mode.Discovery().MarkSpent(folder); + m_Mode.Evidence().Retire(folder); m_Mode.Own(folder); entry.Removing = folder; entry.WaitingFor = "native-removal"; diff --git a/addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelService.c b/addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelService.c index f429cdd..c2cbb09 100644 --- a/addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelService.c +++ b/addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelService.c @@ -41,7 +41,7 @@ class ERR_IntelService IEntity actor = member.GetControlledEntity(); if (!ERR_Actors.Alive(actor) || ERR_Actors.Player(actor) || m_Rolled.Contains(actor)) continue; m_Rolled.Insert(actor); - if (Math.RandomInt(0, 100) >= m_Mode.Settings().Get("aiIntelChancePercent")) continue; + if (Math.RandomInt(0, 100) >= m_Mode.Settings().Runtime("aiIntelChancePercent")) continue; if (m_Requests.Count() >= 256) continue; ERR_IntelRequest request = new ERR_IntelRequest(); request.Actor = actor; @@ -68,6 +68,7 @@ class ERR_IntelService if (inventory) ERR_DiscoveryService.Folders(inventory, folders); if (request.Accepted && request.Operation && request.Operation.Finished) { + m_Mode.Evidence().Capture(request.Actor); m_Requests.Remove(i); continue; } if (!request.Accepted && !folders.IsEmpty()) { m_Requests.Remove(i); continue; } @@ -92,7 +93,7 @@ class ERR_IntelService bool BeginReroll() { if (!Replication.IsServer() || m_Stopped || !m_Mode || !m_Mode.Active() || !m_Mode.Discovery() || (m_Reroll && m_Reroll.Running())) return false; - m_Reroll = new ERR_IntelReroll(m_Mode, this, m_Mode.Settings().Get("aiIntelChancePercent")); + m_Reroll = new ERR_IntelReroll(m_Mode, this, m_Mode.Settings().Runtime("aiIntelChancePercent")); set cohort = new set(); foreach (IEntity actor : m_Rolled) { diff --git a/addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSmoke.c b/addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSmoke.c index 1772dbf..965ee61 100644 --- a/addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSmoke.c +++ b/addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSmoke.c @@ -78,7 +78,7 @@ modded class ERR_GameMode m_ERRTestLocalHandoff = outcome == "hq-local-handoff"; m_ERRTestHQBody = outcome == "hq-body"; m_ERRTestInfantryTransfer = outcome == "infantry-transfer"; - m_ERRTestConvoy = outcome == "convoy"; + m_ERRTestConvoy = outcome == "convoy" || outcome == "supply-convoys"; m_ERRTestMortar = outcome == "mortar-inventory"; m_ERRTestMapOwnership = outcome == "map-ownership"; m_ERRTestAircraftField = outcome == "aircraft-field"; @@ -96,6 +96,7 @@ modded class ERR_GameMode m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 150000; if (m_ERRTestLocalHandoff) m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 240000; if (m_ERRTestConvoy) m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 540000; + if (outcome == "supply-convoys") m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 840000; if (ERR_NativeConvoyCoverProbe.Enabled()) m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 840000; if (m_ERRTestHQMemory) m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 360000; if (m_ERRTestInfantryTransfer) m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 300000; @@ -542,6 +543,7 @@ modded class ERR_GameMode if (m_ERRTestMortarFire) { DamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false); + if (!ERR_Check(UnlockPrison(m_ERRSmokePlayer), "mortar firing fixture opens prison before waiting for support AI")) return; m_ERRMortarFire = new ERR_NativeMortarFiringProbe(this); m_ERRSmokeStep = 150; return; @@ -580,7 +582,13 @@ modded class ERR_GameMode if (m_ERRTestConvoy) { DamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false); - m_ERRConvoyProbe = new ERR_NativeConvoyProbe(this); + string convoyOutcome; System.GetCLIParam("errSmokeOutcome", convoyOutcome); + if (convoyOutcome == "supply-convoys") + { + if (!ERR_Check(UnlockPrison(m_ERRSmokePlayer), "supply fixture unlocks prison to start normal mission AI")) return; + m_ERRConvoyProbe = new ERR_NativeSupplyConvoyProbe(this); + } + else m_ERRConvoyProbe = new ERR_NativeConvoyProbe(this); m_ERRSmokeStep = 106; return; } diff --git a/addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSupplyConvoyProbe.c b/addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSupplyConvoyProbe.c new file mode 100644 index 0000000..925a791 --- /dev/null +++ b/addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSupplyConvoyProbe.c @@ -0,0 +1,156 @@ +#ifdef WORKBENCH +// Explicit opt-in fixture. Startup, seating, cargo and driving remain production-owned. +// The final phase injects one clearly labelled contact to exercise native dismount/reboard. +class ERR_NativeSupplyConvoyProbe : ERR_NativeConvoyProbe +{ + protected ref array m_StartPositions = {}; + protected ref array m_Moved = {}; + protected bool m_AllMoved; + protected float m_Began = -1; + protected float m_PhaseBegan; + protected int m_Phase; + protected ref ERR_SupplyConvoyTask m_CombatTask; + protected bool m_SawDismount; + protected bool m_SawBoarding; + protected int m_TestLegs; + protected vector m_RouteResumePosition; + protected int ObserveMotion(ERR_HQ hq) + { + if (m_StartPositions.Count() != 18) return 0; + int cursor, moved; + for (int index = 0; index < 3; index++) + { + foreach (ERR_ConvoyVehicle vehicle : hq.Convoys().At(index).Vehicles) + { + if (vector.DistanceXZ(vehicle.Entity.GetOrigin(), m_StartPositions[cursor]) >= 20) m_Moved[cursor] = true; + if (m_Moved[cursor]) moved++; + cursor++; + } + } + if (moved == 18 && !m_AllMoved) + { + m_AllMoved = true; + Print("ERR SUPPLY PASS: all eighteen original vehicles drove at least twenty metres from their recorded spawn positions through native AI"); + } + return moved; + } + override int Tick(ERR_HQ hq, float now) + { + if (!hq || !hq.SupplyConvoys()) return -1; + if (m_Began < 0) m_Began = now; + if (now - m_Began > 720000) { Print("ERR SUPPLY FAIL: native convoy fixture deadline"); return -1; } + int moved = ObserveMotion(hq); + if (now >= m_NextLog) + { + PrintFormat("ERR SUPPLY MOTION: %1/18 original vehicles moved at least20m", moved); + PrintFormat("ERR SUPPLY STATE: phase=%1 elapsed=%2 %3", m_Phase, now - m_Began, hq.ConvoyStatus()); + for (int logIndex = 0; logIndex < hq.Convoys().Count(); logIndex++) + { + ERR_SupplyConvoyTask logged = ERR_SupplyConvoyTask.Cast(hq.Convoys().At(logIndex)); + int state = -1; if (logged.Tactics) state = logged.Tactics.State(); + PrintFormat("ERR SUPPLY TASK: index=%1 ready=%2 stopped=%3 reason=%4 teams=%5 cargo=%6 legs=%7 combat=%8", logIndex, logged.Ready, logged.Stopped, logged.Reason, logged.Teams.Count(), logged.Cargo.Count(), logged.Legs, state); + } + if (m_CombatTask) + { + ERR_ConvoyVehicle truck = m_CombatTask.Vehicles[2]; + SCR_VehicleDamageManagerComponent health = SCR_VehicleDamageManagerComponent.Cast(truck.Entity.FindComponent(SCR_VehicleDamageManagerComponent)); + PrintFormat("ERR SUPPLY TRUCK: released=%1 driverAlive=%2 driverGroup=%3 driverAboard=%4 health=%5 retained=%6 position=%7", truck.Released, ERR_Actors.CanFight(truck.Driver), ERR_Actors.Group(truck.Driver) == truck.Group, CompartmentAccessComponent.GetVehicleIn(truck.Driver) == truck.Entity, health.GetHealthScaled(), ERR_ConvoyMountedCrew.Retain(truck.Driver, truck.Entity), truck.Entity.GetOrigin()); + foreach (ERR_SupplyTeam diagnostic : m_CombatTask.Teams) + { + int aboard; + foreach (IEntity passenger : diagnostic.Members) if (CompartmentAccessComponent.GetVehicleIn(passenger) == truck.Entity) aboard++; + PrintFormat("ERR SUPPLY INFANTRY: aboard=%1 members=%2 released=%3 groupPosition=%4 orders=%5", aboard, diagnostic.Members.Count(), diagnostic.Released, diagnostic.Group.GetOrigin(), diagnostic.Orders.Count()); + foreach (IEntity remaining : diagnostic.Members) + { + AIControlComponent control = AIControlComponent.Cast(remaining.FindComponent(AIControlComponent)); + SCR_ChimeraAIAgent agent = SCR_ChimeraAIAgent.Cast(control.GetControlAIAgent()); + CompartmentAccessComponent access = CompartmentAccessComponent.Cast(remaining.FindComponent(CompartmentAccessComponent)); + PrintFormat("ERR SUPPLY SEAT: name=%1 fit=%2 active=%3 canExit=%4 exiting=%5 action=%6 waypoint=%7", CompartmentAccessComponent.GetVehicleIn(remaining) == truck.Entity, ERR_Actors.CanFight(remaining), agent.IsAIActivated(), access.CanGetOutVehicle(), access.IsGettingOut(), agent.m_UtilityComponent.GetCurrentAction(), diagnostic.Group.GetCurrentWaypoint()); + } + } + } + m_NextLog = now + 10000; + } + if (m_Phase == 0) + { + if (hq.SupplyConvoys().Target() != 3) { Print("ERR SUPPLY FAIL: fixture requires default three convoys"); return -1; } + if (hq.Convoys().Count() != 3) return 0; + for (int i = 0; i < 3; i++) + { + ERR_SupplyConvoyTask task = ERR_SupplyConvoyTask.Cast(hq.Convoys().At(i)); + if (!task || task.Stopped) return -1; + if (!task.Ready || !task.MagazinesIssued) return 0; + foreach (ERR_ConvoyCargoCallback pending : task.Cargo) if (!pending.Finished) return 0; + if (task.Vehicles.Count() != 6 || task.Teams.Count() != 2 || task.Receipt.OutstandingBodies != 0 || task.Receipt.OutstandingGroups != 0) return -1; + foreach (ERR_SupplyTeam team : task.Teams) + if (!team.Registered || team.Group == task.Vehicles[2].Group || team.Members.Count() != 7) return -1; + if (task.Teams[0].Group == task.Teams[1].Group) return -1; + int successful; + foreach (ERR_ConvoyCargoCallback load : task.Cargo) if (load.Succeeded) successful++; + if (successful < 14) { Print("ERR SUPPLY FAIL: weapon and ammunition storage operations missing"); return -1; } + InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(task.Vehicles[1].Entity.FindComponent(InventoryStorageManagerComponent)); + array items = {}; ERR_Inventory.Items(inventory, items); + foreach (ERR_ConvoyCargoCallback inserted : task.Cargo) + { + if (!inserted.Succeeded) continue; + bool matched; + for (int itemIndex = 0; itemIndex < items.Count(); itemIndex++) + { + IEntity item = items[itemIndex]; + if (!item || !item.GetPrefabData() || item.GetPrefabData().GetPrefabName() != inserted.Prefab) continue; + items.Remove(itemIndex); matched = true; break; + } + if (!matched) { Print("ERR SUPPLY FAIL: successful cargo callback lacks its physical inventory item"); return -1; } + } + + } + for (int convoy = 0; convoy < 3; convoy++) + { + ERR_SupplyConvoyTask original = ERR_SupplyConvoyTask.Cast(hq.Convoys().At(convoy)); + if (original.Starts.Count() != 6) return -1; + foreach (vector start : original.Starts) { m_StartPositions.Insert(start); m_Moved.Insert(false); } + } + Print("ERR SUPPLY PASS: production auto-started three six-vehicle convoys, exact ledger admission, two separate seated teams each, finite native cargo"); + m_Phase = 1; m_PhaseBegan = now; + } + if (m_Phase == 1) + { + if (!m_AllMoved && now - m_PhaseBegan < 45000) return 0; + m_CombatTask = ERR_SupplyConvoyTask.Cast(hq.Convoys().At(0)); + vector contact = m_CombatTask.Vehicles[2].Entity.GetOrigin() + "0 0 200"; + m_CombatTask.Tactics.Report(contact, now, now); + Print("ERR SUPPLY FIXTURE: injected one contact for native dismount/reboard validation; this phase does not prove perception"); + m_Phase = 2; m_PhaseBegan = now; + } + if (m_Phase == 2) + { + if (m_CombatTask.Stopped) return -1; + bool dismounted = true; + foreach (ERR_SupplyTeam foot : m_CombatTask.Teams) + if (!foot.Aboard(m_CombatTask.Vehicles[2].Entity, false)) dismounted = false; + if (dismounted && !m_SawDismount) { m_SawDismount = true; Print("ERR SUPPLY PASS: both original infantry teams physically dismounted"); } + if (m_CombatTask.Tactics.State() == 2) m_SawBoarding = true; + if (now - m_PhaseBegan > 60000 && m_CombatTask.Tactics.State() == 0) + { + if (!m_SawDismount || !m_SawBoarding) return -1; + foreach (ERR_SupplyTeam boarded : m_CombatTask.Teams) + if (boarded.Released || !boarded.Aboard(m_CombatTask.Vehicles[2].Entity, true)) return -1; + Print("ERR SUPPLY PASS: both original teams reboarded and production travel resumed after quiet interval"); + m_TestLegs = m_CombatTask.Legs; m_RouteResumePosition = m_CombatTask.Vehicles[2].Entity.GetOrigin(); + m_CombatTask.TravelDeadline = now; m_CombatTask.Waiting = false; + Print("ERR SUPPLY FIXTURE: expire this trip's travel deadline to exercise the real pause and next base leg without a ten-minute test delay"); + m_Phase = 3; + } + } + if (m_Phase == 3 && m_CombatTask.Legs > m_TestLegs && vector.DistanceXZ(m_CombatTask.Vehicles[2].Entity.GetOrigin(), m_RouteResumePosition) >= 20) + { + if (m_CombatTask.Stopped || m_CombatTask.Vehicles[2].Released || m_CombatTask.Bases.Count() < 2) return -1; + Print("ERR SUPPLY PASS: original truck drives a new native base leg after the production pause; no replacement convoy"); + m_Phase = 4; + } + if (m_Phase == 4 && m_AllMoved) return 1; + return 0; + } + override void Stop() {} +} +#endif diff --git a/addons/core/UI/layouts/ERR/AdminMenu.layout b/addons/core/UI/layouts/ERR/AdminMenu.layout index 391c41b..b7949ed 100644 --- a/addons/core/UI/layouts/ERR/AdminMenu.layout +++ b/addons/core/UI/layouts/ERR/AdminMenu.layout @@ -20,497 +20,510 @@ FrameWidgetClass { } Color 0.035 0.05 0.065 0.98 } - TextWidgetClass { - Name "Title" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 24 OffsetTop 24 SizeX 790 OffsetRight -818 SizeY 44 OffsetBottom -68 - } - Color 0.9 0.92 0.94 1 Text "Mission settings" "Font Size" 34 "Min Font Size" 34 Wrap 1 FontProperties FontProperties { - Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" - } - } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "Close" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 844 OffsetLeft 844 PositionY 24 OffsetTop 24 SizeX 120 OffsetRight -964 SizeY 42 OffsetBottom -66 - } - components { - SCR_ButtonTextComponent "{5548F0143AE99095}" { - m_sText "Close" - } - } - } - TextWidgetClass { - Name "Connection" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 78 OffsetTop 78 SizeX 936 OffsetRight -964 SizeY 28 OffsetBottom -106 - } - Color 0.9 0.92 0.94 1 Text "Requesting administrator access..." "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { - Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" - } - } - FrameWidgetClass { - Name "SettingsPage" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 0 OffsetTop 0 SizeX 1000 OffsetRight -1000 SizeY 760 OffsetBottom -760 - } - { - TextWidgetClass { - Name "SettingsHelp" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 110 OffsetTop 110 SizeX 936 OffsetRight -964 SizeY 28 OffsetBottom -138 - } - Color 0.9 0.92 0.94 1 Text "Changes are saved on the server. Clock, date and weather apply next round." "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { - Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" - } - } - TextWidgetClass { - Name "Label_radioDefenceSeconds" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 152 OffsetTop 152 SizeX 350 OffsetRight -378 SizeY 27 OffsetBottom -179 - } +FrameWidgetClass { Name "SettingsPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1000 SizeY 486 OffsetBottom -646 } { +TextWidgetClass { + Name "SettingsHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 936 OffsetRight -964 SizeY 50 OffsetBottom -56 } + Color 0.9 0.92 0.94 1 Text "Mission objectives and traffic. Choose a value or Random, then Apply." "Font Size" 19 "Min Font Size" 19 Wrap 1 + FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } +} +TextWidgetClass { + Name "Label_radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 80 OffsetTop 80 SizeX 368 OffsetRight -396 SizeY 28 OffsetBottom -108 } Color 0.9 0.92 0.94 1 Text "Radio defence (30-1800 seconds)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } - TextWidgetClass { - Name "Saved_radioDefenceSeconds" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 180 OffsetTop 180 SizeX 350 OffsetRight -378 SizeY 22 OffsetBottom -202 - } +TextWidgetClass { + Name "Saved_radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 112 OffsetTop 112 SizeX 936 OffsetRight -964 SizeY 24 OffsetBottom -136 } Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } - ButtonWidgetClass : "{0022F0B45ADBC5AC}UI/layouts/WidgetLibrary/EditBox/WLib_EditBox.layout" { - Name "radioDefenceSeconds" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 386 OffsetLeft 386 PositionY 152 OffsetTop 152 SizeX 260 OffsetRight -646 SizeY 42 OffsetBottom -194 - } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { + Name "radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 408 OffsetLeft 408 PositionY 80 OffsetTop 80 SizeX 240 OffsetRight -648 SizeY 42 OffsetBottom -122 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "Apply_radioDefenceSeconds" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 664 OffsetLeft 664 PositionY 152 OffsetTop 152 SizeX 150 OffsetRight -814 SizeY 42 OffsetBottom -194 - } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Apply_radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 660 OffsetLeft 660 PositionY 80 OffsetTop 80 SizeX 112 OffsetRight -772 SizeY 42 OffsetBottom -122 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } - TextWidgetClass { - Name "Label_aiIntelChancePercent" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 210 OffsetTop 210 SizeX 350 OffsetRight -378 SizeY 27 OffsetBottom -237 +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Random_radioDefenceSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 784 OffsetLeft 784 PositionY 80 OffsetTop 80 SizeX 180 OffsetRight -964 SizeY 42 OffsetBottom -122 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Random" + } } - Color 0.9 0.92 0.94 1 Text "AI carrying intel (0-100%)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { + } +TextWidgetClass { + Name "Label_convoyCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 168 OffsetTop 168 SizeX 368 OffsetRight -396 SizeY 28 OffsetBottom -196 } + Color 0.9 0.92 0.94 1 Text "Convoys next round (0-20)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } - TextWidgetClass { - Name "Saved_aiIntelChancePercent" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 238 OffsetTop 238 SizeX 350 OffsetRight -378 SizeY 22 OffsetBottom -260 - } +TextWidgetClass { + Name "Saved_convoyCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 200 OffsetTop 200 SizeX 936 OffsetRight -964 SizeY 24 OffsetBottom -224 } Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } - ButtonWidgetClass : "{0022F0B45ADBC5AC}UI/layouts/WidgetLibrary/EditBox/WLib_EditBox.layout" { - Name "aiIntelChancePercent" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 386 OffsetLeft 386 PositionY 210 OffsetTop 210 SizeX 260 OffsetRight -646 SizeY 42 OffsetBottom -252 - } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { + Name "convoyCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 408 OffsetLeft 408 PositionY 168 OffsetTop 168 SizeX 240 OffsetRight -648 SizeY 42 OffsetBottom -210 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "Apply_aiIntelChancePercent" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 664 OffsetLeft 664 PositionY 210 OffsetTop 210 SizeX 150 OffsetRight -814 SizeY 42 OffsetBottom -252 - } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Apply_convoyCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 660 OffsetLeft 660 PositionY 168 OffsetTop 168 SizeX 112 OffsetRight -772 SizeY 42 OffsetBottom -210 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } - TextWidgetClass { - Name "Label_intelRevealChancePercent" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 268 OffsetTop 268 SizeX 350 OffsetRight -378 SizeY 27 OffsetBottom -295 +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Random_convoyCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 784 OffsetLeft 784 PositionY 168 OffsetTop 168 SizeX 180 OffsetRight -964 SizeY 42 OffsetBottom -210 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Random" + } } - Color 0.9 0.92 0.94 1 Text "First-document exact reveal (0-100%)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { + } +TextWidgetClass { + Name "MissionHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 272 OffsetTop 272 SizeX 936 OffsetRight -964 SizeY 60 OffsetBottom -332 } + Color 0.9 0.92 0.94 1 Text "Convoy count applies next round. Use the other pages above for world, intel and HQ settings." "Font Size" 19 "Min Font Size" 19 Wrap 1 + FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } +} +} } +FrameWidgetClass { Name "HQPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1000 SizeY 486 OffsetBottom -646 } { +TextWidgetClass { + Name "HQHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 936 OffsetRight -964 SizeY 50 OffsetBottom -56 } + Color 0.9 0.92 0.94 1 Text "Control how often HQ receives reports and how it assigns ground support." "Font Size" 19 "Min Font Size" 19 Wrap 1 + FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } +} +TextWidgetClass { + Name "Label_reportIntervalSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 80 OffsetTop 80 SizeX 368 OffsetRight -396 SizeY 28 OffsetBottom -108 } + Color 0.9 0.92 0.94 1 Text "Report interval (5-60 seconds)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } - TextWidgetClass { - Name "Saved_intelRevealChancePercent" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 296 OffsetTop 296 SizeX 350 OffsetRight -378 SizeY 22 OffsetBottom -318 - } +TextWidgetClass { + Name "Saved_reportIntervalSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 112 OffsetTop 112 SizeX 936 OffsetRight -964 SizeY 24 OffsetBottom -136 } Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } - ButtonWidgetClass : "{0022F0B45ADBC5AC}UI/layouts/WidgetLibrary/EditBox/WLib_EditBox.layout" { - Name "intelRevealChancePercent" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 386 OffsetLeft 386 PositionY 268 OffsetTop 268 SizeX 260 OffsetRight -646 SizeY 42 OffsetBottom -310 - } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { + Name "reportIntervalSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 408 OffsetLeft 408 PositionY 80 OffsetTop 80 SizeX 240 OffsetRight -648 SizeY 42 OffsetBottom -122 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "Apply_intelRevealChancePercent" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 664 OffsetLeft 664 PositionY 268 OffsetTop 268 SizeX 150 OffsetRight -814 SizeY 42 OffsetBottom -310 - } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Apply_reportIntervalSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 660 OffsetLeft 660 PositionY 80 OffsetTop 80 SizeX 112 OffsetRight -772 SizeY 42 OffsetBottom -122 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } - TextWidgetClass { - Name "Label_startTimeMinutes" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 326 OffsetTop 326 SizeX 350 OffsetRight -378 SizeY 27 OffsetBottom -353 +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Random_reportIntervalSeconds" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 784 OffsetLeft 784 PositionY 80 OffsetTop 80 SizeX 180 OffsetRight -964 SizeY 42 OffsetBottom -122 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Random" + } } + } +TextWidgetClass { + Name "Label_supportSquadLimit" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 168 OffsetTop 168 SizeX 368 OffsetRight -396 SizeY 28 OffsetBottom -196 } + Color 0.9 0.92 0.94 1 Text "Support squad limit (0-8)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { + Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" + } + } +TextWidgetClass { + Name "Saved_supportSquadLimit" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 200 OffsetTop 200 SizeX 936 OffsetRight -964 SizeY 24 OffsetBottom -224 } + Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { + Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" + } + } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { + Name "supportSquadLimit" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 408 OffsetLeft 408 PositionY 168 OffsetTop 168 SizeX 240 OffsetRight -648 SizeY 42 OffsetBottom -210 } + components { + SCR_EditBoxComponent "{547290FFBD5B33E9}" { + m_sLabel "" + } + } + } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Apply_supportSquadLimit" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 660 OffsetLeft 660 PositionY 168 OffsetTop 168 SizeX 112 OffsetRight -772 SizeY 42 OffsetBottom -210 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Apply" + } + } + } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Random_supportSquadLimit" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 784 OffsetLeft 784 PositionY 168 OffsetTop 168 SizeX 180 OffsetRight -964 SizeY 42 OffsetBottom -210 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Random" + } + } + } +TextWidgetClass { + Name "Label_responseRadiusMetres" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 256 OffsetTop 256 SizeX 368 OffsetRight -396 SizeY 28 OffsetBottom -284 } + Color 0.9 0.92 0.94 1 Text "Response radius (500-5000 metres)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { + Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" + } + } +TextWidgetClass { + Name "Saved_responseRadiusMetres" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 288 OffsetTop 288 SizeX 936 OffsetRight -964 SizeY 24 OffsetBottom -312 } + Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { + Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" + } + } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { + Name "responseRadiusMetres" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 408 OffsetLeft 408 PositionY 256 OffsetTop 256 SizeX 240 OffsetRight -648 SizeY 42 OffsetBottom -298 } + components { + SCR_EditBoxComponent "{547290FFBD5B33E9}" { + m_sLabel "" + } + } + } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Apply_responseRadiusMetres" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 660 OffsetLeft 660 PositionY 256 OffsetTop 256 SizeX 112 OffsetRight -772 SizeY 42 OffsetBottom -298 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Apply" + } + } + } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Random_responseRadiusMetres" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 784 OffsetLeft 784 PositionY 256 OffsetTop 256 SizeX 180 OffsetRight -964 SizeY 42 OffsetBottom -298 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Random" + } + } + } +} } +FrameWidgetClass { Name "WorldPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1000 SizeY 486 OffsetBottom -646 } { +TextWidgetClass { + Name "WorldHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 936 OffsetRight -964 SizeY 50 OffsetBottom -56 } + Color 0.9 0.92 0.94 1 Text "Clock, date and weather apply next round. Random day chooses a daylight start time." "Font Size" 19 "Min Font Size" 19 Wrap 1 + FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } +} +TextWidgetClass { + Name "Label_startTimeMinutes" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 70 OffsetTop 70 SizeX 368 OffsetRight -396 SizeY 28 OffsetBottom -98 } Color 0.9 0.92 0.94 1 Text "Next-round start time (HH:MM)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } - TextWidgetClass { - Name "Saved_startTimeMinutes" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 354 OffsetTop 354 SizeX 350 OffsetRight -378 SizeY 22 OffsetBottom -376 - } +TextWidgetClass { + Name "Saved_startTimeMinutes" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 102 OffsetTop 102 SizeX 936 OffsetRight -964 SizeY 24 OffsetBottom -126 } Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } - ButtonWidgetClass : "{0022F0B45ADBC5AC}UI/layouts/WidgetLibrary/EditBox/WLib_EditBox.layout" { - Name "startTimeMinutes" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 386 OffsetLeft 386 PositionY 326 OffsetTop 326 SizeX 260 OffsetRight -646 SizeY 42 OffsetBottom -368 - } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { + Name "startTimeMinutes" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 408 OffsetLeft 408 PositionY 70 OffsetTop 70 SizeX 240 OffsetRight -648 SizeY 42 OffsetBottom -112 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "Apply_startTimeMinutes" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 664 OffsetLeft 664 PositionY 326 OffsetTop 326 SizeX 150 OffsetRight -814 SizeY 42 OffsetBottom -368 - } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Apply_startTimeMinutes" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 660 OffsetLeft 660 PositionY 70 OffsetTop 70 SizeX 112 OffsetRight -772 SizeY 42 OffsetBottom -112 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } - TextWidgetClass { - Name "Label_timeMultiplier" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 384 OffsetTop 384 SizeX 350 OffsetRight -378 SizeY 27 OffsetBottom -411 +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Random_startTimeMinutes" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 784 OffsetLeft 784 PositionY 70 OffsetTop 70 SizeX 180 OffsetRight -964 SizeY 42 OffsetBottom -112 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Random" + } } + } +TextWidgetClass { + Name "Label_timeMultiplier" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 154 OffsetTop 154 SizeX 368 OffsetRight -396 SizeY 28 OffsetBottom -182 } Color 0.9 0.92 0.94 1 Text "Next-round time speed (1-24x)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } - TextWidgetClass { - Name "Saved_timeMultiplier" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 412 OffsetTop 412 SizeX 350 OffsetRight -378 SizeY 22 OffsetBottom -434 - } +TextWidgetClass { + Name "Saved_timeMultiplier" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 186 OffsetTop 186 SizeX 936 OffsetRight -964 SizeY 24 OffsetBottom -210 } Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } - ButtonWidgetClass : "{0022F0B45ADBC5AC}UI/layouts/WidgetLibrary/EditBox/WLib_EditBox.layout" { - Name "timeMultiplier" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 386 OffsetLeft 386 PositionY 384 OffsetTop 384 SizeX 260 OffsetRight -646 SizeY 42 OffsetBottom -426 - } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { + Name "timeMultiplier" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 408 OffsetLeft 408 PositionY 154 OffsetTop 154 SizeX 240 OffsetRight -648 SizeY 42 OffsetBottom -196 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "Apply_timeMultiplier" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 664 OffsetLeft 664 PositionY 384 OffsetTop 384 SizeX 150 OffsetRight -814 SizeY 42 OffsetBottom -426 - } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Apply_timeMultiplier" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 660 OffsetLeft 660 PositionY 154 OffsetTop 154 SizeX 112 OffsetRight -772 SizeY 42 OffsetBottom -196 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } - TextWidgetClass { - Name "Label_startDateCode" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 442 OffsetTop 442 SizeX 350 OffsetRight -378 SizeY 27 OffsetBottom -469 +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Random_timeMultiplier" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 784 OffsetLeft 784 PositionY 154 OffsetTop 154 SizeX 180 OffsetRight -964 SizeY 42 OffsetBottom -196 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Random" + } } + } +TextWidgetClass { + Name "Label_startDateCode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 238 OffsetTop 238 SizeX 368 OffsetRight -396 SizeY 28 OffsetBottom -266 } Color 0.9 0.92 0.94 1 Text "Start date (YYYY-MM-DD or authored)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } - TextWidgetClass { - Name "Saved_startDateCode" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 470 OffsetTop 470 SizeX 350 OffsetRight -378 SizeY 22 OffsetBottom -492 - } +TextWidgetClass { + Name "Saved_startDateCode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 270 OffsetTop 270 SizeX 936 OffsetRight -964 SizeY 24 OffsetBottom -294 } Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } - ButtonWidgetClass : "{0022F0B45ADBC5AC}UI/layouts/WidgetLibrary/EditBox/WLib_EditBox.layout" { - Name "startDateCode" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 386 OffsetLeft 386 PositionY 442 OffsetTop 442 SizeX 260 OffsetRight -646 SizeY 42 OffsetBottom -484 - } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { + Name "startDateCode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 408 OffsetLeft 408 PositionY 238 OffsetTop 238 SizeX 240 OffsetRight -648 SizeY 42 OffsetBottom -280 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "Apply_startDateCode" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 664 OffsetLeft 664 PositionY 442 OffsetTop 442 SizeX 150 OffsetRight -814 SizeY 42 OffsetBottom -484 - } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Apply_startDateCode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 660 OffsetLeft 660 PositionY 238 OffsetTop 238 SizeX 112 OffsetRight -772 SizeY 42 OffsetBottom -280 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } - TextWidgetClass { - Name "Label_weatherProfile" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 500 OffsetTop 500 SizeX 350 OffsetRight -378 SizeY 27 OffsetBottom -527 +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Random_startDateCode" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 784 OffsetLeft 784 PositionY 238 OffsetTop 238 SizeX 180 OffsetRight -964 SizeY 42 OffsetBottom -280 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Random" + } } + } +TextWidgetClass { + Name "Label_weatherProfile" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 322 OffsetTop 322 SizeX 368 OffsetRight -396 SizeY 28 OffsetBottom -350 } Color 0.9 0.92 0.94 1 Text "Weather (authored / clear / overcast / rain)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } - TextWidgetClass { - Name "Saved_weatherProfile" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 528 OffsetTop 528 SizeX 350 OffsetRight -378 SizeY 22 OffsetBottom -550 - } +TextWidgetClass { + Name "Saved_weatherProfile" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 354 OffsetTop 354 SizeX 936 OffsetRight -964 SizeY 24 OffsetBottom -378 } Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } - ButtonWidgetClass : "{0022F0B45ADBC5AC}UI/layouts/WidgetLibrary/EditBox/WLib_EditBox.layout" { - Name "weatherProfile" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 386 OffsetLeft 386 PositionY 500 OffsetTop 500 SizeX 260 OffsetRight -646 SizeY 42 OffsetBottom -542 - } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { + Name "weatherProfile" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 408 OffsetLeft 408 PositionY 322 OffsetTop 322 SizeX 240 OffsetRight -648 SizeY 42 OffsetBottom -364 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "Apply_weatherProfile" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 664 OffsetLeft 664 PositionY 500 OffsetTop 500 SizeX 150 OffsetRight -814 SizeY 42 OffsetBottom -542 - } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Apply_weatherProfile" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 660 OffsetLeft 660 PositionY 322 OffsetTop 322 SizeX 112 OffsetRight -772 SizeY 42 OffsetBottom -364 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "IntelSummary" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 575 OffsetTop 575 SizeX 200 OffsetRight -228 SizeY 42 OffsetBottom -617 +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Random_weatherProfile" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 784 OffsetLeft 784 PositionY 322 OffsetTop 322 SizeX 180 OffsetRight -964 SizeY 42 OffsetBottom -364 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Random" + } } + } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "RandomDay_startTimeMinutes" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 784 OffsetLeft 784 PositionY 112 OffsetTop 112 SizeX 180 OffsetRight -964 SizeY 32 OffsetBottom -144 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Random day" + } + } + } +} } +FrameWidgetClass { Name "IntelPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1000 SizeY 486 OffsetBottom -646 } { +TextWidgetClass { + Name "IntelHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 936 OffsetRight -964 SizeY 50 OffsetBottom -56 } + Color 0.9 0.92 0.94 1 Text "Set document frequency and how much each discovery reveals." "Font Size" 19 "Min Font Size" 19 Wrap 1 + FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } +} +TextWidgetClass { + Name "Label_aiIntelChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 80 OffsetTop 80 SizeX 368 OffsetRight -396 SizeY 28 OffsetBottom -108 } + Color 0.9 0.92 0.94 1 Text "AI carrying intel (0-100%)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { + Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" + } + } +TextWidgetClass { + Name "Saved_aiIntelChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 112 OffsetTop 112 SizeX 936 OffsetRight -964 SizeY 24 OffsetBottom -136 } + Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { + Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" + } + } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { + Name "aiIntelChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 408 OffsetLeft 408 PositionY 80 OffsetTop 80 SizeX 240 OffsetRight -648 SizeY 42 OffsetBottom -122 } + components { + SCR_EditBoxComponent "{547290FFBD5B33E9}" { + m_sLabel "" + } + } + } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Apply_aiIntelChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 660 OffsetLeft 660 PositionY 80 OffsetTop 80 SizeX 112 OffsetRight -772 SizeY 42 OffsetBottom -122 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Apply" + } + } + } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Random_aiIntelChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 784 OffsetLeft 784 PositionY 80 OffsetTop 80 SizeX 180 OffsetRight -964 SizeY 42 OffsetBottom -122 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Random" + } + } + } +TextWidgetClass { + Name "Label_intelRevealChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 168 OffsetTop 168 SizeX 368 OffsetRight -396 SizeY 28 OffsetBottom -196 } + Color 0.9 0.92 0.94 1 Text "First-document exact reveal (0-100%)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { + Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" + } + } +TextWidgetClass { + Name "Saved_intelRevealChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 200 OffsetTop 200 SizeX 936 OffsetRight -964 SizeY 24 OffsetBottom -224 } + Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { + Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" + } + } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { + Name "intelRevealChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 408 OffsetLeft 408 PositionY 168 OffsetTop 168 SizeX 240 OffsetRight -648 SizeY 42 OffsetBottom -210 } + components { + SCR_EditBoxComponent "{547290FFBD5B33E9}" { + m_sLabel "" + } + } + } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Apply_intelRevealChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 660 OffsetLeft 660 PositionY 168 OffsetTop 168 SizeX 112 OffsetRight -772 SizeY 42 OffsetBottom -210 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Apply" + } + } + } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Random_intelRevealChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 784 OffsetLeft 784 PositionY 168 OffsetTop 168 SizeX 180 OffsetRight -964 SizeY 42 OffsetBottom -210 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Random" + } + } + } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "IntelSummary" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 276 OffsetTop 276 SizeX 260 OffsetRight -288 SizeY 42 OffsetBottom -318 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Intel summary" } } } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "RerollIntel" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 250 OffsetLeft 250 PositionY 575 OffsetTop 575 SizeX 200 OffsetRight -450 SizeY 42 OffsetBottom -617 - } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "RerollIntel" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 306 OffsetLeft 306 PositionY 276 OffsetTop 276 SizeX 260 OffsetRight -566 SizeY 42 OffsetBottom -318 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Reroll AI intel" } } } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "OpenHQ" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 714 OffsetLeft 714 PositionY 575 OffsetTop 575 SizeX 250 OffsetRight -964 SizeY 42 OffsetBottom -617 - } - components { - SCR_ButtonTextComponent "{5548F0143AE99095}" { - m_sText "HQ / status" - } - } - } - TextWidgetClass { - Name "RerollHelp" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 625 OffsetTop 625 SizeX 936 OffsetRight -964 SizeY 26 OffsetBottom -651 - } - Color 0.9 0.92 0.94 1 Text "Reroll updates existing AI through the Intel service; recovered intel is preserved." "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { - Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" - } - } - } - } - FrameWidgetClass { - Name "HQPage" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 0 OffsetTop 0 SizeX 1000 OffsetRight -1000 SizeY 760 OffsetBottom -760 - } - { - TextWidgetClass { - Name "Label_reportIntervalSeconds" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 140 OffsetTop 140 SizeX 350 OffsetRight -378 SizeY 27 OffsetBottom -167 - } - Color 0.9 0.92 0.94 1 Text "Report interval (5-60 seconds)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { - Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" - } - } - TextWidgetClass { - Name "Saved_reportIntervalSeconds" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 168 OffsetTop 168 SizeX 350 OffsetRight -378 SizeY 22 OffsetBottom -190 - } - Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { - Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" - } - } - ButtonWidgetClass : "{0022F0B45ADBC5AC}UI/layouts/WidgetLibrary/EditBox/WLib_EditBox.layout" { - Name "reportIntervalSeconds" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 386 OffsetLeft 386 PositionY 140 OffsetTop 140 SizeX 260 OffsetRight -646 SizeY 42 OffsetBottom -182 - } - components { - SCR_EditBoxComponent "{547290FFBD5B33E9}" { - m_sLabel "" - } - } - } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "Apply_reportIntervalSeconds" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 664 OffsetLeft 664 PositionY 140 OffsetTop 140 SizeX 150 OffsetRight -814 SizeY 42 OffsetBottom -182 - } - components { - SCR_ButtonTextComponent "{5548F0143AE99095}" { - m_sText "Apply" - } - } - } - TextWidgetClass { - Name "Label_supportSquadLimit" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 207 OffsetTop 207 SizeX 350 OffsetRight -378 SizeY 27 OffsetBottom -234 - } - Color 0.9 0.92 0.94 1 Text "Support squad limit (0-8)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { - Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" - } - } - TextWidgetClass { - Name "Saved_supportSquadLimit" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 235 OffsetTop 235 SizeX 350 OffsetRight -378 SizeY 22 OffsetBottom -257 - } - Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { - Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" - } - } - ButtonWidgetClass : "{0022F0B45ADBC5AC}UI/layouts/WidgetLibrary/EditBox/WLib_EditBox.layout" { - Name "supportSquadLimit" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 386 OffsetLeft 386 PositionY 207 OffsetTop 207 SizeX 260 OffsetRight -646 SizeY 42 OffsetBottom -249 - } - components { - SCR_EditBoxComponent "{547290FFBD5B33E9}" { - m_sLabel "" - } - } - } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "Apply_supportSquadLimit" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 664 OffsetLeft 664 PositionY 207 OffsetTop 207 SizeX 150 OffsetRight -814 SizeY 42 OffsetBottom -249 - } - components { - SCR_ButtonTextComponent "{5548F0143AE99095}" { - m_sText "Apply" - } - } - } - TextWidgetClass { - Name "Label_responseRadiusMetres" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 274 OffsetTop 274 SizeX 350 OffsetRight -378 SizeY 27 OffsetBottom -301 - } - Color 0.9 0.92 0.94 1 Text "Response radius (500-5000 metres)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { - Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" - } - } - TextWidgetClass { - Name "Saved_responseRadiusMetres" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 302 OffsetTop 302 SizeX 350 OffsetRight -378 SizeY 22 OffsetBottom -324 - } - Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { - Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" - } - } - ButtonWidgetClass : "{0022F0B45ADBC5AC}UI/layouts/WidgetLibrary/EditBox/WLib_EditBox.layout" { - Name "responseRadiusMetres" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 386 OffsetLeft 386 PositionY 274 OffsetTop 274 SizeX 260 OffsetRight -646 SizeY 42 OffsetBottom -316 - } - components { - SCR_EditBoxComponent "{547290FFBD5B33E9}" { - m_sLabel "" - } - } - } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "Apply_responseRadiusMetres" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 664 OffsetLeft 664 PositionY 274 OffsetTop 274 SizeX 150 OffsetRight -814 SizeY 42 OffsetBottom -316 - } - components { - SCR_ButtonTextComponent "{5548F0143AE99095}" { - m_sText "Apply" - } - } - } - TextWidgetClass { - Name "Status" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 350 OffsetTop 350 SizeX 936 OffsetRight -964 SizeY 210 OffsetBottom -560 - } - Color 0.9 0.92 0.94 1 Text "Waiting for mission status..." "Font Size" 19 "Min Font Size" 19 Wrap 1 FontProperties FontProperties { - Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" - } - } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "BackSettings" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 595 OffsetTop 595 SizeX 250 OffsetRight -278 SizeY 42 OffsetBottom -637 - } - components { - SCR_ButtonTextComponent "{5548F0143AE99095}" { - m_sText "Back to settings" - } - } - } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "OpenJournal" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 714 OffsetLeft 714 PositionY 595 OffsetTop 595 SizeX 250 OffsetRight -964 SizeY 42 OffsetBottom -637 - } - components { - SCR_ButtonTextComponent "{5548F0143AE99095}" { - m_sText "Open live journal" - } - } - } - } - } - FrameWidgetClass { - Name "JournalPage" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 0 OffsetTop 0 SizeX 1000 OffsetRight -1000 SizeY 760 OffsetBottom -760 - } - { - TextWidgetClass { - Name "JournalHelp" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 110 OffsetTop 110 SizeX 936 OffsetRight -964 SizeY 30 OffsetBottom -140 - } - Color 0.9 0.92 0.94 1 Text "Radio reports, orders and physical support activity from the mission HQ." "Font Size" 19 "Min Font Size" 19 Wrap 1 FontProperties FontProperties { - Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" - } - } - ScrollLayoutWidgetClass { - Name "JournalScroll" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 150 OffsetTop 150 SizeX 936 OffsetRight -964 SizeY 400 OffsetBottom -550 - } +TextWidgetClass { + Name "IntelRerollHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 336 OffsetTop 336 SizeX 936 OffsetRight -964 SizeY 52 OffsetBottom -388 } + Color 0.9 0.92 0.94 1 Text "Reroll updates existing AI documents. Recovered intel is preserved." "Font Size" 19 "Min Font Size" 19 Wrap 1 + FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } +} +TextWidgetClass { + Name "EvidenceHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 402 OffsetTop 402 SizeX 936 OffsetRight -964 SizeY 60 OffsetBottom -462 } + Color 0.9 0.92 0.94 1 Text "Enemy bodies stay until a recovery team searches them and reports to HQ. Cleanup is then enabled with a five-minute delay." "Font Size" 19 "Min Font Size" 19 Wrap 1 + FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } +} +} } +FrameWidgetClass { Name "FactionsPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1000 SizeY 486 OffsetBottom -646 } { +TextWidgetClass { + Name "FactionHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 936 OffsetRight -964 SizeY 50 OffsetBottom -56 } + Color 0.9 0.92 0.94 1 Text "Choose three distinct factions or Random. Saved choices apply next round." "Font Size" 19 "Min Font Size" 19 Wrap 1 + FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } +} +TextWidgetClass { Name "FactionLabel_escapists" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 76 OffsetTop 76 SizeX 280 OffsetRight -308 SizeY 32 OffsetBottom -108 } Color 0.9 0.92 0.94 1 Text "Escapists faction" "Font Size" 22 "Min Font Size" 22 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +ButtonWidgetClass : "{4B5AE6E64037FFB4}UI/layouts/WidgetLibrary/ComboBox/WLib_ComboBox.layout" { Name "FactionSelect_escapists" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 320 OffsetLeft 320 PositionY 76 OffsetTop 76 SizeX 644 OffsetRight -964 SizeY 42 OffsetBottom -118 } components { SCR_ComboBoxComponent "{546B1D464BC32853}" { m_bUseLabel 0 m_sLabel "" m_fSizeWithoutLabel 42 } } } +TextWidgetClass { Name "FactionState_escapists" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 124 OffsetTop 124 SizeX 936 OffsetRight -964 SizeY 44 OffsetBottom -168 } Color 0.9 0.92 0.94 1 Text "Waiting for server..." "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "FactionLabel_enemy" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 184 OffsetTop 184 SizeX 280 OffsetRight -308 SizeY 32 OffsetBottom -216 } Color 0.9 0.92 0.94 1 Text "Enemy faction" "Font Size" 22 "Min Font Size" 22 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +ButtonWidgetClass : "{4B5AE6E64037FFB4}UI/layouts/WidgetLibrary/ComboBox/WLib_ComboBox.layout" { Name "FactionSelect_enemy" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 320 OffsetLeft 320 PositionY 184 OffsetTop 184 SizeX 644 OffsetRight -964 SizeY 42 OffsetBottom -226 } components { SCR_ComboBoxComponent "{546B1D464BC32853}" { m_bUseLabel 0 m_sLabel "" m_fSizeWithoutLabel 42 } } } +TextWidgetClass { Name "FactionState_enemy" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 232 OffsetTop 232 SizeX 936 OffsetRight -964 SizeY 44 OffsetBottom -276 } Color 0.9 0.92 0.94 1 Text "Waiting for server..." "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "FactionLabel_independent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 292 OffsetTop 292 SizeX 280 OffsetRight -308 SizeY 32 OffsetBottom -324 } Color 0.9 0.92 0.94 1 Text "Independent / FIA faction" "Font Size" 22 "Min Font Size" 22 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +ButtonWidgetClass : "{4B5AE6E64037FFB4}UI/layouts/WidgetLibrary/ComboBox/WLib_ComboBox.layout" { Name "FactionSelect_independent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 320 OffsetLeft 320 PositionY 292 OffsetTop 292 SizeX 644 OffsetRight -964 SizeY 42 OffsetBottom -334 } components { SCR_ComboBoxComponent "{546B1D464BC32853}" { m_bUseLabel 0 m_sLabel "" m_fSizeWithoutLabel 42 } } } +TextWidgetClass { Name "FactionState_independent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 340 OffsetTop 340 SizeX 936 OffsetRight -964 SizeY 44 OffsetBottom -384 } Color 0.9 0.92 0.94 1 Text "Waiting for server..." "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "FactionsRandomAll" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 418 OffsetTop 418 SizeX 220 OffsetRight -248 SizeY 42 OffsetBottom -460 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Random all" } } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "FactionsApply" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 742 OffsetLeft 742 PositionY 418 OffsetTop 418 SizeX 222 OffsetRight -964 SizeY 42 OffsetBottom -460 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply factions" } } } +} } +FrameWidgetClass { Name "ModsPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1000 SizeY 486 OffsetBottom -646 } { +TextWidgetClass { + Name "ModsHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 936 OffsetRight -964 SizeY 50 OffsetBottom -56 } + Color 0.9 0.92 0.94 1 Text "Choose content the mission may use. Required Workshop downloads stay installed." "Font Size" 19 "Min Font Size" 19 Wrap 1 + FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } +} +TextWidgetClass { + Name "ModsScope" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 58 OffsetTop 58 SizeX 936 OffsetRight -964 SizeY 46 OffsetBottom -104 } + Color 0.9 0.92 0.94 1 Text "Disabling a pack disables its factions. Saved preferences apply next round." "Font Size" 19 "Min Font Size" 19 Wrap 1 + FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } +} +ScrollLayoutWidgetClass { Name "ModsScroll" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 112 OffsetTop 112 SizeX 936 OffsetRight -964 SizeY 362 OffsetBottom -474 } { +VerticalLayoutWidgetClass { Name "ModList" Slot AlignableSlot { HorizontalAlign 3 VerticalAlign 3 } { } } +} } +} } +FrameWidgetClass { Name "JournalPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1000 SizeY 486 OffsetBottom -646 } { +TextWidgetClass { + Name "JournalHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 936 OffsetRight -964 SizeY 50 OffsetBottom -56 } + Color 0.9 0.92 0.94 1 Text "Radio reports, orders and physical support activity from the mission HQ." "Font Size" 19 "Min Font Size" 19 Wrap 1 + FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } +} +ScrollLayoutWidgetClass { + Name "JournalScroll" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 58 OffsetTop 58 SizeX 936 OffsetRight -964 SizeY 320 OffsetBottom -378 } components { SCR_GamepadScrollComponent "{5707100AC31E657E}" { } @@ -533,50 +546,121 @@ FrameWidgetClass { } } } - TextWidgetClass { - Name "JournalState" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 558 OffsetTop 558 SizeX 936 OffsetRight -964 SizeY 34 OffsetBottom -592 - } +TextWidgetClass { + Name "JournalState" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 386 OffsetTop 386 SizeX 936 OffsetRight -964 SizeY 42 OffsetBottom -428 } Color 0.9 0.92 0.94 1 Text "Waiting for the server..." "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "BackHQ" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 605 OffsetTop 605 SizeX 250 OffsetRight -278 SizeY 42 OffsetBottom -647 - } - components { - SCR_ButtonTextComponent "{5548F0143AE99095}" { - m_sText "Back to HQ" - } - } - } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "PauseJournal" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 365 OffsetLeft 365 PositionY 605 OffsetTop 605 SizeX 250 OffsetRight -615 SizeY 42 OffsetBottom -647 - } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "PauseJournal" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 438 OffsetTop 438 SizeX 220 OffsetRight -248 SizeY 42 OffsetBottom -480 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Pause live" } } } - ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "LatestJournal" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 714 OffsetLeft 714 PositionY 605 OffsetTop 605 SizeX 250 OffsetRight -964 SizeY 42 OffsetBottom -647 - } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "LatestJournal" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 742 OffsetLeft 742 PositionY 438 OffsetTop 438 SizeX 222 OffsetRight -964 SizeY 42 OffsetBottom -480 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Jump to latest" } } } +} } +FrameWidgetClass { Name "StatusPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1000 SizeY 486 OffsetBottom -646 } { +TextWidgetClass { + Name "StatusHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 936 OffsetRight -964 SizeY 50 OffsetBottom -56 } + Color 0.9 0.92 0.94 1 Text "Live mission state. Scroll to see all services and support activity." "Font Size" 19 "Min Font Size" 19 Wrap 1 + FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } +} +ScrollLayoutWidgetClass { + Name "StatusScroll" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 58 OffsetTop 58 SizeX 936 OffsetRight -964 SizeY 416 OffsetBottom -474 } + components { + SCR_GamepadScrollComponent "{5707100AC31E657E}" { + } + } + { + VerticalLayoutWidgetClass { + Name "StatusContent" Slot AlignableSlot { + HorizontalAlign 3 VerticalAlign 3 Padding 8 4 14 4 + } + { + TextWidgetClass { + Name "Status" Slot LayoutSlot { + HorizontalAlign 3 Padding 0 0 0 8 SizeMode Fill + } + Clipping Ancestor Color 0.9 0.92 0.94 1 Text "Waiting for mission status..." "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { + Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" + } + } + } + } + } + } +} } +TextWidgetClass { + Name "Title" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 20 OffsetTop 20 SizeX 790 OffsetRight -818 SizeY 44 OffsetBottom -64 } + Color 0.9 0.92 0.94 1 Text "Escapists Reintroduced" "Font Size" 32 "Min Font Size" 32 Wrap 1 + FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } +} +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Close" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 844 OffsetLeft 844 PositionY 24 OffsetTop 24 SizeX 120 OffsetRight -964 SizeY 42 OffsetBottom -66 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Close" + } } } - TextWidgetClass { - Name "Feedback" Slot FrameWidgetSlot { - Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 668 OffsetTop 668 SizeX 936 OffsetRight -964 SizeY 78 OffsetBottom -746 +TextWidgetClass { + Name "Connection" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 70 OffsetTop 70 SizeX 936 OffsetRight -964 SizeY 28 OffsetBottom -98 } + Color 0.9 0.92 0.94 1 Text "Requesting administrator access..." "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { + Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } + } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "OpenSettings" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 106 OffsetTop 106 SizeX 112 OffsetRight -140 SizeY 42 OffsetBottom -148 } + components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Mission" } } +} +ImageWidgetClass { Name "Selected_OpenSettings" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 151 OffsetTop 151 SizeX 112 OffsetRight -140 SizeY 3 OffsetBottom -154 } Color 0.9 0.66 0.25 1 } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "OpenWorld" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 146 OffsetLeft 146 PositionY 106 OffsetTop 106 SizeX 112 OffsetRight -258 SizeY 42 OffsetBottom -148 } + components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "World" } } +} +ImageWidgetClass { Name "Selected_OpenWorld" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 146 OffsetLeft 146 PositionY 151 OffsetTop 151 SizeX 112 OffsetRight -258 SizeY 3 OffsetBottom -154 } Color 0.9 0.66 0.25 1 } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "OpenFactions" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 264 OffsetLeft 264 PositionY 106 OffsetTop 106 SizeX 112 OffsetRight -376 SizeY 42 OffsetBottom -148 } + components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Factions" } } +} +ImageWidgetClass { Name "Selected_OpenFactions" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 264 OffsetLeft 264 PositionY 151 OffsetTop 151 SizeX 112 OffsetRight -376 SizeY 3 OffsetBottom -154 } Color 0.9 0.66 0.25 1 } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "OpenMods" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 382 OffsetLeft 382 PositionY 106 OffsetTop 106 SizeX 112 OffsetRight -494 SizeY 42 OffsetBottom -148 } + components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Mods" } } +} +ImageWidgetClass { Name "Selected_OpenMods" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 382 OffsetLeft 382 PositionY 151 OffsetTop 151 SizeX 112 OffsetRight -494 SizeY 3 OffsetBottom -154 } Color 0.9 0.66 0.25 1 } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "OpenHQ" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 500 OffsetLeft 500 PositionY 106 OffsetTop 106 SizeX 112 OffsetRight -612 SizeY 42 OffsetBottom -148 } + components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "HQ" } } +} +ImageWidgetClass { Name "Selected_OpenHQ" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 500 OffsetLeft 500 PositionY 151 OffsetTop 151 SizeX 112 OffsetRight -612 SizeY 3 OffsetBottom -154 } Color 0.9 0.66 0.25 1 } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "OpenIntel" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 618 OffsetLeft 618 PositionY 106 OffsetTop 106 SizeX 112 OffsetRight -730 SizeY 42 OffsetBottom -148 } + components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Intel" } } +} +ImageWidgetClass { Name "Selected_OpenIntel" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 618 OffsetLeft 618 PositionY 151 OffsetTop 151 SizeX 112 OffsetRight -730 SizeY 3 OffsetBottom -154 } Color 0.9 0.66 0.25 1 } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "OpenStatus" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 736 OffsetLeft 736 PositionY 106 OffsetTop 106 SizeX 112 OffsetRight -848 SizeY 42 OffsetBottom -148 } + components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Status" } } +} +ImageWidgetClass { Name "Selected_OpenStatus" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 736 OffsetLeft 736 PositionY 151 OffsetTop 151 SizeX 112 OffsetRight -848 SizeY 3 OffsetBottom -154 } Color 0.9 0.66 0.25 1 } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "OpenJournal" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 854 OffsetLeft 854 PositionY 106 OffsetTop 106 SizeX 112 OffsetRight -966 SizeY 42 OffsetBottom -148 } + components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Journal" } } +} +ImageWidgetClass { Name "Selected_OpenJournal" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 854 OffsetLeft 854 PositionY 151 OffsetTop 151 SizeX 112 OffsetRight -966 SizeY 3 OffsetBottom -154 } Color 0.9 0.66 0.25 1 } +TextWidgetClass { + Name "Feedback" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 670 OffsetTop 670 SizeX 936 OffsetRight -964 SizeY 70 OffsetBottom -740 } Color 0.9 0.92 0.94 1 Text "" "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } diff --git a/addons/core/UI/layouts/ERR/AdminValue.layout b/addons/core/UI/layouts/ERR/AdminValue.layout new file mode 100644 index 0000000..3ced327 --- /dev/null +++ b/addons/core/UI/layouts/ERR/AdminValue.layout @@ -0,0 +1,21 @@ +ButtonWidgetClass : "{0022F0B45ADBC5AC}UI/layouts/WidgetLibrary/EditBox/WLib_EditBox.layout" { + Name "AdminValue" + components { + SCR_EditBoxComponent "{547290FFBD5B33E9}" { + m_bUseLabel 0 + m_sLabel "" + m_fSizeWithoutLabel 42 + } + } + { + VerticalLayoutWidgetClass "{5AA4D27F39EB2514}" { + Prefab "{5AA4D27F39EB2514}" + { + SizeLayoutWidgetClass "{5538C205B27B0F5D}" { + Prefab "{5538C205B27B0F5D}" + HeightOverride 42 + } + } + } + } +} diff --git a/addons/core/UI/layouts/ERR/AdminValue.layout.meta b/addons/core/UI/layouts/ERR/AdminValue.layout.meta new file mode 100644 index 0000000..39f2380 --- /dev/null +++ b/addons/core/UI/layouts/ERR/AdminValue.layout.meta @@ -0,0 +1,11 @@ +MetaFileClass { + Name "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" + Configurations { + LayoutResourceClass PC { } + LayoutResourceClass HEADLESS : PC { } + LayoutResourceClass XBOX_ONE : PC { } + LayoutResourceClass XBOX_SERIES : PC { } + LayoutResourceClass PS4 : PC { } + LayoutResourceClass PS5 : PC { } + } +} diff --git a/addons/core/UI/layouts/ERR/ContentRow.layout b/addons/core/UI/layouts/ERR/ContentRow.layout new file mode 100644 index 0000000..45745f1 --- /dev/null +++ b/addons/core/UI/layouts/ERR/ContentRow.layout @@ -0,0 +1,8 @@ +SizeLayoutWidgetClass { Name "ContentRow" Slot LayoutSlot { HorizontalAlign 3 Padding 0 0 0 16 } WidthOverride 900 HeightOverride 160 { +FrameWidgetClass { Name "ContentRowBody" Slot AlignableSlot { HorizontalAlign 3 VerticalAlign 3 } { +TextWidgetClass { Name "PackLabel" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 0 OffsetTop 0 SizeX 900 OffsetRight -900 SizeY 32 OffsetBottom -32 } Color 0.9 0.92 0.94 1 Text "Content pack" "Font Size" 26 "Min Font Size" 26 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "PackState" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 40 OffsetTop 40 SizeX 900 OffsetRight -900 SizeY 30 OffsetBottom -70 } Color 0.9 0.92 0.94 1 Text "Waiting for server settings..." "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Enable" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 90 OffsetTop 90 SizeX 180 OffsetRight -180 SizeY 42 OffsetBottom -132 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Enable" } } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Disable" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 196 OffsetLeft 196 PositionY 90 OffsetTop 90 SizeX 180 OffsetRight -376 SizeY 42 OffsetBottom -132 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Disable" } } } +} } +} } diff --git a/addons/core/UI/layouts/ERR/ContentRow.layout.meta b/addons/core/UI/layouts/ERR/ContentRow.layout.meta new file mode 100644 index 0000000..05986d6 --- /dev/null +++ b/addons/core/UI/layouts/ERR/ContentRow.layout.meta @@ -0,0 +1,11 @@ +MetaFileClass { + Name "{F092D45656A8E02B}UI/layouts/ERR/ContentRow.layout" + Configurations { + LayoutResourceClass PC { } + LayoutResourceClass HEADLESS : PC { } + LayoutResourceClass XBOX_ONE : PC { } + LayoutResourceClass XBOX_SERIES : PC { } + LayoutResourceClass PS4 : PC { } + LayoutResourceClass PS5 : PC { } + } +} diff --git a/addons/core/UI/layouts/ERR/PauseModsButton.layout b/addons/core/UI/layouts/ERR/PauseModsButton.layout new file mode 100644 index 0000000..4c31dcb --- /dev/null +++ b/addons/core/UI/layouts/ERR/PauseModsButton.layout @@ -0,0 +1,9 @@ +ButtonWidgetClass : "{9ECCD201BCF07E95}UI/layouts/Menus/PauseMenu/PauseMenuButton.layout" { + Name "ERR_MissionMods" + Slot LayoutSlot { } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Escapists Reintroduced" + } + } +} diff --git a/addons/core/UI/layouts/ERR/PauseModsButton.layout.meta b/addons/core/UI/layouts/ERR/PauseModsButton.layout.meta new file mode 100644 index 0000000..2824897 --- /dev/null +++ b/addons/core/UI/layouts/ERR/PauseModsButton.layout.meta @@ -0,0 +1,11 @@ +MetaFileClass { + Name "{F0351A624C87DE90}UI/layouts/ERR/PauseModsButton.layout" + Configurations { + LayoutResourceClass PC { } + LayoutResourceClass HEADLESS : PC { } + LayoutResourceClass XBOX_ONE : PC { } + LayoutResourceClass XBOX_SERIES : PC { } + LayoutResourceClass PS4 : PC { } + LayoutResourceClass PS5 : PC { } + } +} diff --git a/addons/core/addon.gproj b/addons/core/addon.gproj index 8773a4f..a1c5eff 100644 --- a/addons/core/addon.gproj +++ b/addons/core/addon.gproj @@ -7,6 +7,7 @@ GameProject { "595F2BF2F44836FB" "629B2BA37EFFD577" "5E524E4FEECCA92B" + "65930CB4CD0237B2" } Configurations { GameProjectConfig PC { diff --git a/docs/aggression-ai.md b/docs/aggression-ai.md new file mode 100644 index 0000000..ca4270c --- /dev/null +++ b/docs/aggression-ai.md @@ -0,0 +1,59 @@ +# Identified-body escalation and objective defence + +Implemented 2026-09-23 in the combined Core addon. These are server-owned mission services; clients do not request spawns or supply casualty counts. + +## Aggression and hunter UAZs + +Only a fresh BODY report delivered to HQ identifies a body for aggression. The identity is the immutable formation SourceId plus original roster slot. A status-known death can subsequently be identified once; multiple observers, repeat deliveries and status reports cannot inflate this counter. Missing or silent units do not count. + +`ERR_HQWatchRules.Aggression(bodies)` is `clamp(floor(bodies / 3), 0, 30)`. `Hunters(level)` is `ceil(level / 2)`, capped at 15: + +| Identified bodies | Aggression | Cumulative UAZ allowance | +|---:|---:|---:| +| 0–2 | 0 | 0 | +| 3–8 | 1–2 | 1 | +| 9–14 | 3–4 | 2 | +| 45 | 15 | 8 | +| 87–89 | 29 | 15 | +| 90+ | 30 | 15 | + +The existing confirmed-loss infantry response budget remains independent. The new allowance creates **additional hunter UAZ-PKM vehicles**, separate from supply convoys, each with the selected primary enemy faction's driver and gunner. There are at most 15 deployments per round, not an endless replacement pool. Destroyed, captured or failed vehicles consume their deployment; surviving entities and loot remain physical. Surviving AI crew retain their HQ registration and skill profile when a driving task ends; capture releases the mission binding. The status string distinguishes active vehicles from the cumulative allowance. + +Deployment waits until the prison is unlocked and checks friendly-site road access, terrain, collision, the force-ledger cap and a 400 m player exclusion radius. A blocked candidate is retried after 30 seconds; native crew readiness has a 30-second deadline. Vehicles are registered only with their actual original native agents and seat occupants. Faction selection, inventory, radio fitting and body/intel ownership use existing mission services. + +Without delivered sightings, hunters patrol roads between friendly objectives. With fresh actionable HQ memory, they intercept a road near the last reported/predicted position. Destination selection does not query live player positions. Orders must traverse the existing physical radio network. Loss of communications prevents new orders; it does not magically reveal a player or cancel the last received order. + +## Objective crews + +Radio, supply/medical, extraction and outpost locations have permanent local defence roles. HQ still receives their real reports, but cannot borrow them for searches, couriers, resupply or reinforcement assignments elsewhere. Prison patrols retain their existing release behaviour, and mortar/scout/air-support reservations remain specialized. + +Within 600 m of an objective, existing living guards are reused. If no guards remain, the site can receive one finite 3–5-member relief crew per round, preserving the site's original faction. Spawning is deferred within 200 m of players and requires standable terrain. Complete native expansion is required before admission. Its native Defend waypoint uses the actual terrain-height objective position and a 40 m radius. No recurring refill loop is introduced. + +The mission currently has a **medical supply stash**, not a spawned hospital building. This stash participates in objective defence. New objective sites can use the same service by registering their site kind in `ERR_ObjectiveDefence.Objective`. + +## Skill, accuracy and perception + +Mission-owned hostile military AI use `EAISkill.CYLON` for maximum skill while the native aim-error node uses the `REGULAR` random-error distribution. This avoids CYLON's zero-error shooting. Recognition uses the native alerted factor (2.5) even in safe/suppressed states, preserving equipment factors and the engine's visibility/occlusion checks. This does not create through-wall vision or knowledge of unseen players. + +The authorized original AI code supplied the scoped skill/equipment and detection-hook approach. The separate aim-error reduction is new: no corresponding independent accuracy hook was found in the inspected original. Native `SCR_AIGetAimErrorOffset` and `SCR_AICombatComponent` implementations were used to separate these behaviours. + +Actual enemy-detection events activate local native combat and bring forward the next HQ report; report delivery still obeys radio routing. Primary-enemy units share HQ. Other hostile factions receive the same scoped skill/aim policy but do not leak intelligence to a mutually hostile faction. Specialist scouts and flight crews retain their authored equipment. Captured/ineligible actors are excluded from the profile, and normal teardown restores prior skill and native perception. + +## Audit trail and adjustment + +The complete HQ journal is `$logs:rereintroduced-hq.log` (existing filename retained). Relevant events: + +- `AGGRESSION`: delivered body source/slot, distinct-body count, level and allowance. +- `HUNTER SPAWN`, `READY`, `WAIT`, `ORDER`, `FAILED`, `RELEASE`: why/where each UAZ deployed, base, crew size, radio order and lifecycle. +- `REGISTER`, `SPAWN MEMBER`: group and force identity, faction, prefab, position, members, home and dedicated role. +- `ROLE RESERVED`, `OBJECTIVE DEFENCE`: site assignment, reuse or relief deployment and reassignment restriction. +- `CONTACT LOCAL`: observed position, source group and local role; at most one diagnostic per group per ten seconds. +- `SPAWN PURPOSE`: links body-inspection teams to their reported-body destination. + +`SPAWN GROUP` also appears in the normal script/console log for mission groups, including non-primary factions. Coordinates are world metres. Changes are event-driven or throttled, not a per-frame unit dump. Existing radio, order, status, search and support journal records remain available. + +## Verification + +Production-method policy tests cover body/status deduplication, three-body thresholds, 30/15 caps, reset, authority/capture boundaries, maximum skill binding/restoration, regular aim-error selection and native perception fallback. The native `aggression` fixture exercises actual body intake, a physical UAZ with seated crew, radio delivery and movement, objective reservations, medical-stash reinforcement admission and teardown. This is local native Workbench validation, not a dedicated-server multiplayer or full 15-UAZ performance benchmark. + +Final local run: `/tmp/erri-aggression-native7/logs/logs_2026-09-23_15-01-06`. All 14 native assertions and the completion marker passed: full medical defence at terrain height, seated driver/gunner, real radio delivery, driving over 30 m, bounded deployment and surviving-crew profile after release. Source stayed unchanged during the run. The runner reported a transient empty-argv Wine `enfMain` during cleanup; a subsequent explicit private-prefix idle check passed. Existing external localization/resource messages remain in the native log; this is not a clean-log claim. `python tools/check.py` passed, and the final hunter lifecycle and affected skill/body/registration policy tests passed separately. diff --git a/docs/ai-equipment-migration.md b/docs/ai-equipment-migration.md index 6d7e519..8514f7a 100644 --- a/docs/ai-equipment-migration.md +++ b/docs/ai-equipment-migration.md @@ -1,5 +1,7 @@ # Enemy skill and finite startup equipment migration +Current policy (2026-09-23): [aggression and AI profile](aggression-ai.md) supersedes the historical Expert infantry floor below. All mission hostile military AI now use maximum decision skill with separate regular aim error. + Adapted from independently authored, user-authorized AI code. Original files remain read-only; no original Escapists helpers, protected-derived `EAI_GroupLookup`, patched prefabs or asset bytes are imported. | Owned source | SHA-256 | Reused behavior | diff --git a/docs/ai-feature-migration.json b/docs/ai-feature-migration.json index a5df3ba..6b96d65 100644 --- a/docs/ai-feature-migration.json +++ b/docs/ai-feature-migration.json @@ -8,8 +8,8 @@ "counts": { "features": 39, "statuses": { - "partial": 17, - "missing": 18, + "partial": 21, + "missing": 14, "implemented": 4 }, "scripts": 54, @@ -181,19 +181,23 @@ { "id": "body_discovery", "title": "Visible bodies and confirmed losses", - "status": "missing", + "status": "partial", "original_files": [ "Scripts/Game/EscapistsAI/EAI_HQBodySearch.c", "Scripts/Game/EscapistsAI/EAI_HQWatch.c", "Scripts/Game/EscapistsAI/EAI_HQ.c" ], - "replacement_files": [], + "replacement_files": [ + "addons/core/Scripts/Game/ERR/10_AI/ERR_HQBodyObserver.c", + "addons/core/Scripts/Game/ERR/10_AI/ERR_BodyRecovery.c", + "addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelEvidence.c" + ], "behavior": [ "Death callback registers local evidence;60m view limit/12m close range;15s per-unit scans;12-trace shared budget;16 local discoveries", "radio-only deduplicated confirmations" ], - "remaining": "Independent death hook and bounded physical LOS intake; share evidence identity across status/body/missing-patrol reports. A casualty is not a known hostile position.", - "evidence": "code only", + "remaining": "Dedicated multiplayer, inaccessible bodies, interrupted recovery teams and hostile-faction permutations remain runtime acceptance cases. Missing documents indicate potentially compromised sites, not an enemy sighting.", + "evidence": "Native retained corpse, delivered body report, separately spawned recovery team, physical inspection and missing-document radio uplink passed on 22 September. See docs/hq-air-support.md.", "asset_scope": "vanilla" }, { @@ -293,7 +297,12 @@ "replacement_files": [ "addons/core/Scripts/Game/ERR/10_AI/ERR_AircraftService.c", "addons/core/Scripts/Game/ERR/10_AI/ERR_AircraftFlight.c", - "addons/core/Scripts/Game/ERR/10_AI/ERR_AircraftRouteFlight.c" + "addons/core/Scripts/Game/ERR/10_AI/ERR_AircraftRouteFlight.c", + "addons/core/Scripts/Game/ERR/10_AI/ERR_ReconAirports.c", + "addons/core/Scripts/Game/ERR/10_AI/ERR_AirReconSystem.c", + "addons/core/Scripts/Game/ERR/10_AI/ERR_ReconFlightComponent.c", + "addons/core/Scripts/Game/ERR/10_AI/ERR_ReconCrewLocks.c", + "addons/core/Scripts/Game/ERR/10_AI/ERR_NativeAirReconProbe.c" ], "behavior": [ "Incremental airport discovery", @@ -303,19 +312,24 @@ "runway return/landing retry", "player takeover release" ], - "remaining": "Flight mechanisms have isolated short-route evidence; airport search, useful routes, HQ-selected recon launch/orbit/endurance/service operational loop are missing.", - "evidence": "isolated20m flight only; no operational recon", - "asset_scope": "vanilla Mi8 candidate; original recon wrapper must be rebuilt" + "remaining": "Dedicated multiplayer acceptance and player boarding/takeover; faction-specific aircraft catalogs and full endurance/service/rearm cycle. Air assault remains separate.", + "evidence": "Native airfield launch, real player sighting/shadow orbit, physical crew radio delivery to HQ, flight, same-aircraft landing, full loss delay and retained wreck replacement passed in erri-recon-native4. Forced player boarding remained unverified. Final ownership/deletion results and exact limits: docs/search-helicopter.md.", + "asset_scope": "Current vanilla armed Mi8 wrapped with successor flight component and four original native pilot/gunner occupants; no obsolete custom left-gun resource." }, { "id": "air_assault", "title": "HQ-selected air reinforcement cycle", - "status": "missing", + "status": "partial", "original_files": [ "Scripts/Game/EscapistsAI/EAI_AirAssault.c", "Scripts/Game/EscapistsAI/EAI_AirSupportPolicy.c" ], - "replacement_files": [], + "replacement_files": [ + "addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultSystem.c", + "addons/core/Scripts/Game/ERR/10_AI/ERR_AirSupportPolicy.c", + "addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultLoad.c", + "addons/core/Scripts/Game/ERR/10_AI/ERR_AirfieldSupport.c" + ], "behavior": [ "Up to8 carried troops/two teams", "wait60s for mortar response", @@ -324,38 +338,44 @@ "abort stale/moved/conflicting reports;2min cover", "return/reload/reboard" ], - "remaining": "Build one shared HQ insertion capability with actual passenger reservations and mortar deconfliction; no new independent timer/spawn system.", - "evidence": "missing", - "asset_scope": "optional parachute framework; vanilla landing-and-dismount alternative changes behavior" + "remaining": "Dedicated multiplayer and adversarial remote ownership/packet-loss acceptance remain; current implementation uses four finite attempted loads and HQ memory/ground-strength decisions.", + "evidence": "Production memory/strength policy and cover-target/deadline regressions pass. Controlled native eight-person drop, same-airframe return, service and second eight-person departure passed. See docs/hq-air-support.md for scope.", + "asset_scope": "External Parachute Framework 65930CB4CD0237B2 is required; vanilla Mi-8 and original physical troops." }, { "id": "parachutes", "title": "Physical staggered AI parachute drop", - "status": "missing", + "status": "partial", "original_files": [ "Scripts/Game/EscapistsAI/EAI_AssaultParachute.c", "Scripts/Game/EscapistsAI/EAI_AirAssault.c" ], - "replacement_files": [], + "replacement_files": [ + "addons/core/Scripts/Game/ERR/10_AI/ERR_AssaultParachute.c", + "addons/core/Prefabs/ERR/AssaultParachute.et" + ], "behavior": [ "Moving25m/s flyby at90\u2013180m AGL;>=2s/50m stagger", "native compartment exits;5m/s descent/2m/s flare", "no duplicate soldiers", "released canopies finish after carrier loss" ], - "remaining": "Framework-dependent mechanics cannot enter vanilla-only closure; choose optional asset integration or landing insertion; do not silently claim equivalence.", - "evidence": "missing", + "remaining": "Dedicated-client replication, canopy destruction and late native transfer acceptance remain.", + "evidence": "Eight original actors boarded physical canopies and landed in the 22 September native airborne fixture.", "asset_scope": "Parachute Framework65930CB4CD0237B2" }, { "id": "aircraft_ammo", "title": "Aircraft weapon ammunition and airfield reload", - "status": "missing", + "status": "partial", "original_files": [ "Scripts/Game/EscapistsAI/EAI_AircraftAmmo.c", "Scripts/Game/EscapistsAI/EAI_AirRecon.c" ], - "replacement_files": [], + "replacement_files": [ + "addons/core/Scripts/Game/ERR/10_AI/ERR_AircraftAmmo.c", + "addons/core/Scripts/Game/ERR/10_AI/ERR_AirfieldSupport.c" + ], "behavior": [ "Native mounted-magazine/rocket-barrel accounting", "return at<=25% usable ammunition", @@ -363,8 +383,8 @@ "verified physical passenger boarding/retries", "fixed finite payload per departure" ], - "remaining": "Replace original free service refill/new troops with shared depot cargo/personnel allocation and physically confirmed consumption.", - "evidence": "code only", + "remaining": "Airport service has finite gun-round, rocket-attempt and fuel budgets associated with physical depot entities. Spent rocket-pod runtime and dedicated multiplayer acceptance remain.", + "evidence": "Native same-airframe service consumed mounted-gun rounds and fuel before a second departure. Finite rocket-pod service compiles; spent-pod refill is not separately qualified. See docs/hq-air-support.md.", "asset_scope": "vanilla" }, { @@ -2410,12 +2430,12 @@ "post_audit_updates": [ { "id": "convoys", - "updated_on": "2026-09-20", + "updated_on": "2026-09-21", "status": "partial", - "implementation": "Finite six-vehicle/19-body shared-budget service with one opt-in canonical HQ dispatch, actual native road driving, exact initial seat membership and finite eight-rifle/48-magazine truck cargo. Delivered-report HQ HOLD/RESUME preserves each original crew, route index and owned waypoint; native mounted-combat registration is enabled. Automatic initial startup now defaults enabled, snapshots its saved setting per round, waits for existing admission and makes one bounded canonical attempt. Manual and automatic share the same receipt; no replacement after losses.", - "evidence": "native-2026-09-20-025035-convoy-driving: 181 assertions, all six original vehicles at least20m and lead over100m, duplicate dispatch rejected; zero script/runtime failures. Four external localization errors keep strict qualification failed. Current-source native-2026-09-20-041150-convoy-cover-driving repeats181 assertions after the cover integration; radio command transitions have executable-method tests, not native combat proof. native-2026-09-20-081658-automatic-convoy:93 assertions/one round, automatic six vehicles/19 original seated crew, duplicate dispatch rejected, zero script/runtime failures; four external localization errors remain.", - "remaining": "Direct native HOLD/RESUME and mounted firing; dismount/flank/reboard tactics, delivery/unloading, full-route reliability, multiplayer and FPS qualification. Capacity stays one initial convoy, not predecessor3/20.", - "detail": "docs/convoy-migration.md" + "implementation": "Original default-three, configurable0\u201320 finite six-vehicle convoys. Each has25 bodies/eight ledger groups: eleven mounted crew and two seven-person infantry teams. Bounded separated road startup, friendly-base circuits,45\u201390s rests,250m arrival/10min travel deadline. Native perception and delivered HQ contacts drive dismount/flank/escort cover/truck withdrawal and60s quiet reboarding with120s timeout. Random faction arsenal weapons and compatible finite ammunition, no replacements/refills. Saved count/random choice applies next round. Shared ledger and associated group bookkeeping expanded to256 formations.", + "evidence": "native-2026-09-20-025035-convoy-driving: 181 assertions, all six original vehicles at least20m and lead over100m, duplicate dispatch rejected; zero script/runtime failures. Four external localization errors keep strict qualification failed. Current-source native-2026-09-20-041150-convoy-cover-driving repeats181 assertions after the cover integration; radio command transitions have executable-method tests, not native combat proof. native-2026-09-20-081658-automatic-convoy:93 assertions/one round, automatic six vehicles/19 original seated crew, duplicate dispatch rejected, zero script/runtime failures; four external localization errors remain. Current full supply-convoy implementation: local 2026-09-21 16:50:45 smoke completed,36 common assertions, no script/runtime failures;18 vehicles/75 original soldiers,174 physical cargo inserts,all18 drove20m,both seven-person teams dismounted/reboarded and original truck drove next base leg after native pause. Source fingerprint380aec8a078825b165593fc4445b603f80b5d1ec51360c018d34973d65302b62 unchanged; cleanup confirmed. Controlled contact/travel-deadline inputs explicitly labelled. Nine pre-existing dependency errors prevent strict engine qualification. Details in docs/supply-convoys.md.", + "remaining": "Dedicated multiplayer, maximum-fleet performance and full-island route reliability qualification. Separate infantry ammunition rendezvous still uses the first convoy. Historical 2026-09-20 evidence predates the new circuit and infantry controller; see current validation in docs/supply-convoys.md.", + "detail": "docs/supply-convoys.md" }, { "id": "body_discovery", diff --git a/docs/ai-migration-progress.md b/docs/ai-migration-progress.md index 4992ca8..9970d03 100644 --- a/docs/ai-migration-progress.md +++ b/docs/ai-migration-progress.md @@ -1,5 +1,8 @@ # AI migration progress — 20 September 2026 +Current convoy implementation: [Supply convoys](supply-convoys.md) supersedes the single-convoy stages recorded below. + + The successor now has a persistent HQ belief model, bounded infantry pursuit, radio check-ins, missing-patrol investigations, local body discovery and a Core force ledger. These extend the existing playable mission and finite mortar/courier diff --git a/docs/airfield-extraction-and-map-icons.md b/docs/airfield-extraction-and-map-icons.md new file mode 100644 index 0000000..04d6d13 --- /dev/null +++ b/docs/airfield-extraction-and-map-icons.md @@ -0,0 +1,48 @@ +# Airfield extraction and map icons + +The live Everon scenario no longer creates `everon-supply-2`, its guards, UAZ or +crate. The remaining supply cache and medical stash provide the two reachable +radio-intel sources. Mortar reserve/courier preparation uses the existing eastern +outpost, preserving its finite logistics without creating a replacement site. + +Extraction is authored at 4800.953 / 11487.412 on Everon's airfield. Before +spawning the helicopter, `ERR_AirfieldPlacement` checks nearby physical airfield +anchors, dry terrain, slope and the aircraft/overhead clearance volume. A failed +check fails preparation; there is no fallback into the woods. New islands need +their own authored pad and recognizable airfield anchors. Aircraft damage and +physics remain normal after spawning. + +Exact discoveries use native wrapper images and matching glow images: +helicopter for extraction, radio for radio camps, artillery for mortar sites, +building for outposts, medical for medical supplies, and supplies for other caches. +The prison is now always hidden. Partial clues use a question mark. Icons are +applied after native marker initialization and reapplied when the map opens. +Partial markers use stable 500 m grid centres; all objective markers remain uneditable. + +## Verification — 22 September 2026 + +The full repository check passed during implementation; the mortar-logistics +regression also passed after changing its preparation source. The final Core +Workbench `airfield-markers` fixture passed 12 assertions and completed without +script errors or VM exceptions. It checked the removed site, a settled usable +aircraft on the real pad, rejection of the old forest point and occupied pad, +two reachable intel sources, all six site icon selections, and native map opening. + +Final local session: `/tmp/erri-airfield-markers6/logs/logs_2026-09-22_15-44-19`. +A direct capture of the private Workbench viewport visually confirmed the prison +lock replacing its circle. The other sites were outside that captured viewport; +the fixture checks their icon selections, not visual quality at every map zoom. + +Existing RHS/WCS localization, stock resource-keyword and native map-UI resource +errors remain visible in the strict report. The runner recorded smoke completion +but returned failure because its immediate cleanup check encountered an exiting +`enfMain` process with an empty command line. A subsequent host-visible +`assert_prefix_idle` check confirmed the private prefix was idle. This is local +Workbench validation, not dedicated multiplayer acceptance or a clean strict +engine report. No server deployment or Workshop publication was performed. + +Historical geometry-dependent smoke tests explicitly load `Tests/LegacySites.conf`; +production, `generated-sites`, `opening-tools` and `airfield-markers` use the live +scenario definitions. + +The lock-icon screenshot below is historical; the subsequent [mortar and prison changes](mortar-intel-and-prison-map.md) removed that marker and restored question marks. diff --git a/docs/configuration.md b/docs/configuration.md index df8a997..a7b5bc2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,7 @@ Open the normal game chat and enter a command. Core provides: | Command | Access | Effect | | --- | --- | --- | | `/esc status` | Server administrator | Current objective or startup failure cause; adds HQ status when Chat is loaded | +| `/esc killais` | Server administrator | Kill AI characters within 1,000 m of your living character; preserve players and vehicles | | `/esc settings` | Server administrator | Current shared settings and values | | `/esc mission radio show` | Server administrator | Radio defence duration in seconds | | `/esc mission radio set N` | Server administrator | Save a whole-number duration from 30 to 1800 seconds | @@ -41,9 +42,11 @@ Additional AI commands: | `/esc hq stinger show` | Show the saved one-in-N initial equipment chance | | `/esc hq stinger set N` | Set 0 to disable, 1 for every eligible actor, or 2–10000 for one in N; affects actors that have not rolled yet | | `/esc hq convoy show` | Show the finite deployment task, retained vehicles, native cargo insertion results and stop reason | -| `/esc hq convoy start` | Opt into one initial six-vehicle deployment near the authored southern supply site; requires a suitable actual road, physical readiness and room for all 19 crew within the 60-body convoy admission cap | +| `/esc hq convoy start` | Deploy the next unused slot in this round’s configured convoy allowance after physical force admission | +| `/esc convoys show` | Show the saved count and active fleet | +| `/esc convoys set 0..20` / `/esc convoys set random` | Save the next round’s convoy count; default 3; also available on the Mission page | -Convoy deployment is under native verification. It is not automatically enabled at round startup. The same per-round receipt is retained after retries, partial startup and losses; this command never replaces a consumed convoy. Cargo is issued once and insertion failures are reported. Combat dismount/reboarding, complete supply routing and HQ response retasking remain separate migration work. The admission cap is not yet a global presence/materialization service. See [AI progress](ai-migration-progress.md) and [external dependencies](external-addons.md). +Convoys automatically deploy after prison escape by default, travel between friendly military bases, and use the original infantry dismount/flank/reboard behavior. Each has six vehicles, 25 soldiers and finite random faction-arsenal cargo. Startup and losses consume slots permanently. The existing `automaticConvoyEnabled=0` preference remains respected. See [Supply convoys](supply-convoys.md) for configuration, timing and validation limits. The command parser and setting mutations have native service tests. Actual remote RPC ownership, chat usability and pre-spawn presentation still need multiplayer/UI acceptance. @@ -54,3 +57,100 @@ If the faction/catalog setup or authored site preparation fails, the mode retain Waiting for normal site preparation does not consume a player's spawn retry budget. Once ready, actual failed spawn requests are bounded. This does not remove the one-life or identity requirements. Core also provides `/esc mission abandon`: an administrator-only explicit defeat and normal restart for an active round. See [recovery policy](recovery.md). + +## Mission menu + +**Esc → Escapists Reintroduced** opens one administrator menu. Its top navigation +stays visible on every page: + +| Page | Controls | +| --- | --- | +| Mission | Radio defence duration and next-round convoy count | +| World | Start time, Random day, time speed, date and weather | +| Factions | Escapists, enemy and independent faction choices; Random all | +| Mods | Content pack and individual faction switches | +| HQ | Report interval, support squad limit and response radius | +| Intel | Document frequency, exact-reveal chance, summary and reroll | +| Status | Scrollable live mission and service status | +| Journal | Scrollable HQ activity, pause/resume and jump to latest | + +Changing pages preserves drafts and never applies them. Escape returns to Mission; +a second Escape closes the menu (an active text edit consumes Escape first). +Server authorization and capability checks still control every mutation. + +## Optional content + +Use **Esc → Escapists Reintroduced → Mods** for server-admin RHS and individual faction switches. +Selections apply after mission restart, while Workshop dependencies remain loaded. +See [content packs and the modder registry](content-packs.md). + +## Random settings + +Every editable setting on **Mission**, **World**, **HQ** and **Intel** has a +**Random** button. It selects the draft; press **Apply** to save it. You can still +enter a fixed value. The start-time row also offers **Random day**. + +- Random start time: any whole minute from 00:00 through 23:59. +- Random day: a whole minute strictly between the island's sunrise and sunset, + calculated after the selected date is applied. If no daylight interval is + available, preparation fails rather than silently starting at night. +- Random date: any valid date in the world's authored year (1900–2050), including + leap days where applicable. +- Random weather: clear, overcast or rain, with equal probability. +- Other fields: a uniformly selected integer within the range displayed beside + the field, including zero where allowed. Random speed uses 1–24×. + +The server retains the **Random** choice in its profile and selects a fresh value +for each mission instance. Reads and menu polling reuse that value. Applying the +same Random choice again does not reroll it; changing to a fixed value and then +back to Random does. Live settings retain their existing apply timing; clock, +calendar, weather and speed still apply only on the next round. Saved Random +values for live settings also show the resolved number in the menu. + +Text commands accept `random` in place of any of these ten values, for example +`/esc hq squads set random` or `/esc mission weather set random`. Time additionally +accepts `/esc mission time set random day` (or `random-day`). Existing numeric, +clock, date and named-weather values remain supported. Storage uses `-1` for +Random and `-2` exclusively for Random day; these are saved modes, never concrete +values passed to gameplay or the world clock. Existing settings files keep their +fixed choices without migration or an automatic rewrite. + +## Generated camps and map discovery + +Everon generates a fresh clear-terrain prison, radio camp and four mortar sites +each round. Partial intel shows question marks; exact discoveries use site icons. +The prison is never marked. See +[generated sites and debug commands](generated-sites.md) for placement limits, +authoring fields, failure behavior and validation. + +`/esc cheat intel` is an administrator-only command that places six fresh intel folders on clear ground within about 4.3 metres of the current living prisoner. It requires being on foot on terrain with enough nearby space. All six positions are checked first; a spawn failure removes the partial batch. Picking up the folders uses normal intel discovery and the configured reveal chance. + +The initial prison patrol contains 3–5 soldiers, chosen once during preparation from the selected enemy faction roster. An existing RTO is retained. If that roster has no large radio, one soldier is equipped with a physical manpack (US/USAF: AN/PRC-77; otherwise R-107M). Preparation waits for that radio to be present. The guards start at separate random clear positions 12–24 m around the prison and remain frozen until it is unlocked; other site squads keep their configured size. + +## Airfield extraction and site icons + +Everon extraction uses the airfield pad at 4800.953 / 11487.412. Site authors set +`RequireAirfield 1` and `AircraftOffset 0 0 0` on that extraction definition. +Preparation checks airfield anchors, terrain and clearance before creating the +aircraft, and refuses an invalid pad without relocating it elsewhere. + +Identified sites use helicopter, radio, artillery, building, medical and supplies +icons. Partial intel shows a question mark at an approximate position; the prison +remains hidden regardless of discovery. +The western guarded UAZ/crate site is removed; the medical stash provides the +second radio-intel source. See [implementation and validation](airfield-extraction-and-map-icons.md). + +See [mortar intel, original messages and perimeter guards](mortar-intel-and-prison-map.md) for the four-site configuration and validation. + +## Permanent radio security + +Radio sites require a guard squad. HQ permanently reserves that squad for the +radio's 20 m defend area; it cannot be dispatched on searches, reinforcements or +courier work. The generated Everon radio and table sit inside a full-size tent, +with standing room for interaction. See [radio camp security](radio-camp-security.md). + +Everon also sets `ScoutCount 10` in its scenario definition. This creates ten finite hilltop sniper/scout posts with radio-equipped ammunition packs; see [hill scouts](hill-scouts.md) for terrain criteria, loadouts and reporting. Other authored scenarios default to zero. + +Search helicopter: `heliTimerMinutes` defaults to 15 minutes after prison escape +and after aircraft loss. Use `/esc hq heli show`, `/esc hq heli timer set 15`, +or `/esc hq heli return`. See [search-helicopter.md](search-helicopter.md). diff --git a/docs/content-packs.md b/docs/content-packs.md new file mode 100644 index 0000000..5fef6be --- /dev/null +++ b/docs/content-packs.md @@ -0,0 +1,133 @@ +# Mission content packs and factions + +Administrators open **Esc → Escapists Reintroduced → Mods** (also available in `/esc menu`). RHS +and its US Marines, Russian Armed Forces and ION factions default to enabled. +Each faction can be disabled independently. Disabling the parent pack excludes +all its factions and the mission's optional RHS optics, while retaining child +preferences. Enable/disable controls are authenticated on the server; saved +preferences and current effective selections are shown separately. + +Changes apply on mission restart. Preferences live in +`$profile:EscapistsContent.json` on the server; use numeric `0`/`1` values for +`rhs`, `rhs_usmc`, `rhs_afrf`, and `rhs_ion`. Invalid existing files are preserved; +validated backup recovery and verified writes follow the mission settings +pattern. New registry entries use their authored default when absent from an +older file. Workshop dependencies stay installed and loaded. This controls what +the mission selects, not third-party scripts or a player's downloaded mods. + +Open **Mission settings → Factions** to select the **Escapists**, **Enemy**, and +**Independent / FIA** roles. Each dropdown offers Random plus enabled registered +factions (US Army, Soviet Army, FIA, and enabled RHS factions). **Random all** +sets all three drafts to Random; **Apply factions** saves them together. +Explicit choices must differ. Random reserves explicit choices first and fills +the remaining roles without duplicates. A previously selected faction that is +now disabled falls back to another eligible faction at round start. + +Role preferences are separate from pack enablement and live in +`$profile:EscapistsFactions.json`, for example: + +```json +{"escapists":"us","enemy":"ussr","independent":"fia"} +``` + +Each value can instead be `"random"`; RHS IDs are `rhs_usmc`, `rhs_afrf`, and +`rhs_ion`. The equivalent authenticated command is +`/esc factions set us ussr fia`. Saved choices and the current resolved factions +are displayed separately. Changes require mission restart and never reassign +live players or AI mid-round. + +The three selected sides are mutually hostile and friendly within their own +faction. Prisoners join the selected Escapists faction. Ordinary enemy guards +use the selected enemy group's native infantry. Sites with `AllowFactionPool` +alternate independent and main-enemy groups. The strategic HQ, radio network, +convoys and mortar logistics follow the main enemy; independent groups execute +native guard/patrol orders. Convoy infantry use the selected enemy profile. +Existing convoy vehicles, specialist mortar kits and the unarmed prisoner outfit +are retained; faction selection does not replace every vehicle or item asset. +All mission hostiles stay frozen until the prison is unlocked. + +The prison starter container is the original Kolobok backpack, stocked once with +five PM pistols, eight spare magazines and one map. Existing readiness checks +wait for actual inventory insertion and client storage ownership. + +## Adding a pack or faction + +No menu code, command code or fixed settings schema edits are needed. + +1. Add the Workshop dependency to `addons/core/addon.gproj` and install it in + Workbench. Update the test tooling allowlist in `tools/external_addons.py` and + extend `docs/external-addons.json` with the reviewed dependency + closure and exact resource GUID/path records used by this repository's tools. +2. Add a pack `ERR_ContentOption` to + `addons/core/Configs/ERR/Content/Registry.conf`: unique lowercase `Id`, readable + `Label`, `DefaultEnabled` (0 or 1), and optional `EquipmentGuids`. +3. Add one child option per combat faction with `Parent` set to the pack ID, + the exact `NativeFactionKey`, and `GuardGroup` resource. The group must spawn + its physical members immediately, with every member in that native faction. + Use distinct native keys for factions that should fight each other. +4. Add its `SCR_Faction` config to `Prefabs/ERR/FactionManager.et`, with + `m_bIsPlayable 0` and an empty `m_aFriendlyFactionsIds` list. The startup + validator checks the enabled factions and establishes mutual hostility. +5. Set `AllowFactionPool 1` on scenario sites eligible for faction variation. + These sites alternate the selected independent and main-enemy groups. + Other guarded sites use the main enemy profile. Specialist units retain + their mission equipment while following the selected enemy faction. +6. For new optional equipment selection code, call + `mode.Content().Allows(prefab)` before granting it, and register its GUID in + the owning option. Existing inherited equipment on faction characters stays + part of their prefab. Arbitrary native resource spawns are not intercepted. + +Example child entry (substitute real faction key/resource): + +```text +ERR_ContentOption { + Id "my_pack_infantry" + Label "My pack / Infantry" + Parent "my_pack" + DefaultEnabled 1 + NativeFactionKey "MY_INFANTRY" + GuardGroup "{REAL_RESOURCE_GUID}Prefabs/Groups/MyInfantry.et" +} +``` + +IDs are persistent preference keys: keep them stable across releases. The +registry rejects duplicate IDs, faction keys, resource ownership, missing +parents and nested parent chains. It supports up to 64 entries and the UI +scrolls automatically. Registered factions also populate the role dropdowns; +no separate menu registration is needed. Use keys distinct from US, USSR and FIA. +The server must load the faction and its group resource. This registry does not +automatically discover arbitrary unregistered Workshop factions. + +Commands: `/esc mods show`, `/esc mods rhs disable`, +`/esc mods rhs_usmc enable`, `/esc mods help`. + +## Verification — 21 September 2026 + +Production-policy tests cover role resolution, explicit-choice reservation, +unique Random-all selection, disabled-choice fallback, invalid choices, pack +and child gating, guard eligibility, and selected non-USSR convoy dispatch. + +The isolated native `core / prisoner-kit` run at +`/tmp/erri-factions-native3/logs/logs_2026-09-21_17-49-09` completed **341 assertions** +with USSR prisoners, RHS AFRF enemies and US independent guards. It compiled the +scripts and exercised actual player affiliation, physical guard groups, +hostility, authenticated role-menu save/restore, Random-all drafts, unchanged +active roles after save, unauthorized-command rejection, persistence reload, +backpack storage/readiness and the existing prisoner-kit checks. Sources and +dependencies stayed unchanged during the run; private runtime cleanup completed. +The faction page was rendered and inspected at `/tmp/erri-factions-menu.png`. +A second run (`/tmp/erri-factions-native4`) also completed 341 assertions +with RHS ION prisoners, US enemies and FIA independent guards, confirming +RHS player eligibility and FIA physical squads. Repository checks passed with +`python3 tools/check.py`. + +Native validation still reports four external RHS/WCS localization errors and +five unknown-keyword errors while preloading inherited RHS vehicle catalogs +(`SlidingTrackMaterial` and BTR70 `Parent` entries). There were no mission script +or smoke failures in the completed run. These are local scripted Workbench +checks, not dedicated-server, ordinary mouse/gamepad-input, or multiplayer +replication acceptance. No Workshop publishing was performed. + +Faction hostility follows the native +[SCR_FactionManager API](https://community.bistudio.com/wikidata/external-data/arma-reforger/ArmaReforgerScriptAPIPublic/interfaceSCR__FactionManager.html), +with both directions validated before preparation can finish. diff --git a/docs/convoy-migration.md b/docs/convoy-migration.md index 8cf40aa..434bbb3 100644 --- a/docs/convoy-migration.md +++ b/docs/convoy-migration.md @@ -1,5 +1,8 @@ # Finite physical convoy controller +> Superseded production behavior: see [Supply convoys](supply-convoys.md) for the original default-three fleet, full troop truck, base circuit and dismount/reboarding implementation. The evidence and implementation notes below describe the earlier single-convoy stages. + + Independent adaptation of owned `escapists-ai/Scripts/Game/EscapistsAI/EAI_SupplyConvoys.c`, SHA-256 `a896fdacfd9c0f2db52e588642623545a3a5d6e8041fb72000a68e720fc2b7a5`. Reused responsibilities are the six-vehicle catalog/order, native seat creation, independent native crew groups, finite cargo and owned-waypoint release. Original Escapists/ScrtCore helpers, original game mode hooks and `EAI_GroupLookup` are excluded. Native `BaseCompartmentSlot.SpawnCharacterInCompartment`, group vehicle manager and waypoint APIs replace those dependencies. The round owns one `ERR_ConvoyPool(mode,maxConvoys)` and one `ERR_ConvoyService(mode,pool)`. Reserve a unique authored source ID; dispatch its canonical one-use receipt with six authored positions/angles and a sealed, nonlooping Core `ERR_ForceRoute`. Resource, terrain/water/obstacle, faction and shared budget validation precede consumption. Consumption precedes native entity creation. Partial native startup consumes the slot and preserves every successfully created group, vehicle and actor; there is no refund or automatic retry/replacement. diff --git a/docs/convoy-startup.md b/docs/convoy-startup.md index 3eedfe2..aae332d 100644 --- a/docs/convoy-startup.md +++ b/docs/convoy-startup.md @@ -1,5 +1,8 @@ # Automatic finite convoy startup +> Superseded production behavior: see [Supply convoys](supply-convoys.md) for the original default-three fleet, full troop truck, base circuit and dismount/reboarding implementation. The evidence and implementation notes below describe the earlier single-convoy stages. + + The full AI scenario now enables one automatic initial convoy by default. Core-only scenarios remain unchanged. HQ freezes `automaticConvoyEnabled` when the round creates its command service; saving a different value affects the next round only. Commands use the existing authenticated mission administration: diff --git a/docs/discovery-map-usability.md b/docs/discovery-map-usability.md index aef53c1..bbb27f3 100644 --- a/docs/discovery-map-usability.md +++ b/docs/discovery-map-usability.md @@ -23,3 +23,10 @@ The actual coordinate policy passes positive, negative, boundary and exhaustive Vanilla local-marker deletion removes only active membership. A culled objective can remain in the disabled list and later reappear. The successor now removes only its own local discovery-marker type from both lists and detaches that marker's native map-close/layer listeners on deletion; duplicate cleanup is safe and never reactivates or creates a widget. The native fixture now moves its owned marker into the actual disabled list before testing both-membership removal. Native verification of this extension is pending. The [100619 integrated Core regression](evidence/native-2026-09-19-100619/report.json) now passes the revised native coordinate and disabled-list cleanup fixture in each of two complete rounds (612 total assertions, zero captured errors). Earlier pending statements describe the pre-run changes; manual map interaction remains separate. + +## Current intel presentation + +Partial clues use the native question-mark icon at the stable 500 m grid centre. +Identification replaces it with the site-specific icon; mortar sites use artillery. +The prison stays hidden. Messages match the owner’s original Intel addon. See +[mortar intel and prison visibility](mortar-intel-and-prison-map.md). diff --git a/docs/evidence/admin-pages-2026-09-22/README.md b/docs/evidence/admin-pages-2026-09-22/README.md new file mode 100644 index 0000000..1371745 --- /dev/null +++ b/docs/evidence/admin-pages-2026-09-22/README.md @@ -0,0 +1,25 @@ +# Mission pages and corpse retention — 22 September 2026 + +- `python tools/check.py`: passed; complete output in repository-checks.txt. +- `tests/test_intel_body_inspection.py`: executes the production Inspect, + RetainedWithin, Delivered and Release methods against engine boundary doubles. + Premature, client-side, stopped, unknown and duplicate reports cannot release or + renew corpse cleanup. A searched body with a delivered report releases once, + with a 300-second native cleanup lifetime. +- Native Core `prisoner-kit` fixture compiled and completed. The menu checks cover + all eight pages through the shared top navigation, only one visible page, + navigation geometry, all Random controls and Random day, preserved drafts, + authenticated Apply, mod/faction save and restoration, journal rendering, + close/reopen and authorization denial. This is local Workbench validation; + dedicated multiplayer was not run for this change. +- `page-0.png` through `page-7.png` are direct, unedited captures of the isolated + Workbench World Editor window. Page order: Mission, HQ, Journal, Mods, Factions, + World, Intel, Status. All pages were visually inspected. Workbench's built-in + engine screenshots omit UI, so they are not used as visual evidence here. +- `sources.json` records the tested source hashes. No source edits were made + during the native run. No Workshop publishing or server deployment was done. + +Native profile: `/tmp/erri-menu-pages-native5`. The runner reported a transient +exit-time idle-verification warning for an exiting process with an empty command +line. A subsequent explicit prefix-idle verification passed; no test process +remains. The fixture completed 345 assertions without script/runtime failures. diff --git a/docs/evidence/admin-pages-2026-09-22/native-menu-checks.txt b/docs/evidence/admin-pages-2026-09-22/native-menu-checks.txt new file mode 100644 index 0000000..5aaf6f4 --- /dev/null +++ b/docs/evidence/admin-pages-2026-09-22/native-menu-checks.txt @@ -0,0 +1,47 @@ +21:00:08.675 SCRIPT : ERR SMOKE PASS: admin integration native Esc menu entry opens mission administration +21:00:09.123 SCRIPT : ERR SMOKE PASS: admin integration real owner RPC opens menu and returns current settings/capabilities +21:00:09.125 SCRIPT : ERR MOD MENU: page=3 rows=4 saved=4 active=4 +21:00:09.125 SCRIPT : ERR MOD ROW: id=rhs state=Current round: Enabled | Saved preference: Enabled +21:00:09.125 SCRIPT : ERR MOD BOUNDS: id=rhs width=192.917 height=27.5 +21:00:09.126 SCRIPT : ERR MOD ROW: id=rhs_ion state=Current round: Enabled | Saved preference: Enabled +21:00:09.126 SCRIPT : ERR MOD BOUNDS: id=rhs_ion width=192.917 height=27.5 +21:00:09.126 SCRIPT : ERR MOD ROW: id=rhs_afrf state=Current round: Enabled | Saved preference: Enabled +21:00:09.126 SCRIPT : ERR MOD BOUNDS: id=rhs_afrf width=192.917 height=27.5 +21:00:09.126 SCRIPT : ERR MOD ROW: id=rhs_usmc state=Current round: Enabled | Saved preference: Enabled +21:00:09.126 SCRIPT : ERR MOD BOUNDS: id=rhs_usmc width=192.917 height=27.5 +21:00:09.127 SCRIPT : ERR MOD CAPTURE READY +21:00:09.127 SCRIPT : ERR ADMIN CAPTURE PAGE: 0 +21:00:12.188 SCRIPT : ERR ADMIN CAPTURE PAGE: 1 +21:00:15.267 SCRIPT : ERR ADMIN CAPTURE PAGE: 2 +21:00:18.376 SCRIPT : ERR ADMIN CAPTURE PAGE: 3 +21:00:21.450 SCRIPT : ERR ADMIN CAPTURE PAGE: 4 +21:00:24.494 SCRIPT : ERR ADMIN CAPTURE PAGE: 5 +21:00:27.601 SCRIPT : ERR ADMIN CAPTURE PAGE: 6 +21:00:30.682 SCRIPT : ERR ADMIN CAPTURE PAGE: 7 +21:00:33.765 SCRIPT : ERR SMOKE PASS: admin integration all eight pages reached through shared top navigation with one visible page +21:00:36.875 SCRIPT : ERR SMOKE PASS: admin integration all Random buttons and Random day select editable drafts +21:00:39.947 SCRIPT : ERR SMOKE PASS: admin integration background polling preserves editable draft +21:00:41.490 SCRIPT : ERR SMOKE PASS: admin integration native Apply control routes and saves only requested setting +21:00:44.020 SCRIPT : ERR SMOKE PASS: admin integration test setting restored without resetting menu drafts +21:00:46.080 SCRIPT : ERR MOD MENU: page=3 rows=4 saved=4 active=4 +21:00:46.080 SCRIPT : ERR MOD ROW: id=rhs state=Current round: Enabled | Saved preference: Disabled +21:00:46.081 SCRIPT : ERR MOD BOUNDS: id=rhs width=192.917 height=27.5 +21:00:46.081 SCRIPT : ERR MOD ROW: id=rhs_ion state=Current round: Enabled | Saved preference: Enabled +21:00:46.081 SCRIPT : ERR MOD BOUNDS: id=rhs_ion width=192.917 height=27.5 +21:00:46.081 SCRIPT : ERR MOD ROW: id=rhs_afrf state=Current round: Enabled | Saved preference: Enabled +21:00:46.081 SCRIPT : ERR MOD BOUNDS: id=rhs_afrf width=192.917 height=27.5 +21:00:46.081 SCRIPT : ERR MOD ROW: id=rhs_usmc state=Current round: Enabled | Saved preference: Enabled +21:00:46.081 SCRIPT : ERR MOD BOUNDS: id=rhs_usmc width=192.917 height=27.5 +21:00:46.081 SCRIPT : ERR SMOKE PASS: admin integration native mod toggle persists through authenticated owner RPC without changing active content +21:00:48.131 SCRIPT : ERR SMOKE PASS: admin integration native mod toggle restores saved preference +21:00:48.132 SCRIPT : ERR SMOKE PASS: admin integration three faction selectors and Random all create distinct-role drafts +21:00:52.710 SCRIPT : ERR SMOKE PASS: admin integration faction Apply persists all three through authenticated owner RPC without changing active round +21:00:54.249 SCRIPT : ERR SMOKE PASS: admin integration saved faction choices restored +21:00:57.348 SCRIPT : ERR ADMIN JOURNAL TEXT: lines=7 chars=528 bounds=153.833,79.5833,188.333,31.5938 +21:00:57.348 SCRIPT : ERR ADMIN JOURNAL VIEWPORT: bounds=152.167,78.75,195,66.6667 +21:00:57.348 SCRIPT : ERR ADMIN JOURNAL FOLLOW: slider=0,1 overflow=-35.0729 +21:00:57.349 SCRIPT : ERR SMOKE PASS: admin integration HQ capability and journal page remain within current mission services +21:00:58.866 SCRIPT : ERR SMOKE PASS: admin integration dialog closes and authenticated god command affects current body +21:01:00.415 SCRIPT : ERR SMOKE PASS: admin integration god off restores exact prior damage state +21:01:02.474 SCRIPT : ERR SMOKE PASS: admin integration repeated open works and denied snapshot closes dialog +21:01:20.888 SCRIPT : ERR SMOKE COMPLETE: native prisoner-kit; reset requested by fixture diff --git a/docs/evidence/admin-pages-2026-09-22/page-0.png b/docs/evidence/admin-pages-2026-09-22/page-0.png new file mode 100644 index 0000000..da045b8 Binary files /dev/null and b/docs/evidence/admin-pages-2026-09-22/page-0.png differ diff --git a/docs/evidence/admin-pages-2026-09-22/page-1.png b/docs/evidence/admin-pages-2026-09-22/page-1.png new file mode 100644 index 0000000..69a281e Binary files /dev/null and b/docs/evidence/admin-pages-2026-09-22/page-1.png differ diff --git a/docs/evidence/admin-pages-2026-09-22/page-2.png b/docs/evidence/admin-pages-2026-09-22/page-2.png new file mode 100644 index 0000000..776bddf Binary files /dev/null and b/docs/evidence/admin-pages-2026-09-22/page-2.png differ diff --git a/docs/evidence/admin-pages-2026-09-22/page-3.png b/docs/evidence/admin-pages-2026-09-22/page-3.png new file mode 100644 index 0000000..7865f66 Binary files /dev/null and b/docs/evidence/admin-pages-2026-09-22/page-3.png differ diff --git a/docs/evidence/admin-pages-2026-09-22/page-4.png b/docs/evidence/admin-pages-2026-09-22/page-4.png new file mode 100644 index 0000000..f68d4e7 Binary files /dev/null and b/docs/evidence/admin-pages-2026-09-22/page-4.png differ diff --git a/docs/evidence/admin-pages-2026-09-22/page-5.png b/docs/evidence/admin-pages-2026-09-22/page-5.png new file mode 100644 index 0000000..6137c10 Binary files /dev/null and b/docs/evidence/admin-pages-2026-09-22/page-5.png differ diff --git a/docs/evidence/admin-pages-2026-09-22/page-6.png b/docs/evidence/admin-pages-2026-09-22/page-6.png new file mode 100644 index 0000000..59adff6 Binary files /dev/null and b/docs/evidence/admin-pages-2026-09-22/page-6.png differ diff --git a/docs/evidence/admin-pages-2026-09-22/page-7.png b/docs/evidence/admin-pages-2026-09-22/page-7.png new file mode 100644 index 0000000..a71684b Binary files /dev/null and b/docs/evidence/admin-pages-2026-09-22/page-7.png differ diff --git a/docs/evidence/admin-pages-2026-09-22/repository-checks.txt b/docs/evidence/admin-pages-2026-09-22/repository-checks.txt new file mode 100644 index 0000000..d271799 --- /dev/null +++ b/docs/evidence/admin-pages-2026-09-22/repository-checks.txt @@ -0,0 +1,476 @@ +PASS: missing, child-only, malformed, zero and duplicate prefab root IDs rejected +PASS: delayed scene/inventory arrival, deferred preload, identity checks, stale replies, timeout and observer cleanup +PASS: actual inventory manifest, delayed/missing/wrong-owner item links, finite cap, consumed-stock refresh and preload ordering +PASS: spawn scene remains held through final acknowledgement and nearby use; leaving, reassignment, disconnect and teardown release it +PASS: production prison AI hold/release, repeat activation, authority/faction/player exclusion, dead actor and stop cleanup +PASS: actual inspection requires dead registered body, server authority, conscious on-foot nonplayer, 3m reach, clear LOS and inventory; only missing issued site IDs reported once +PASS: production airborne memory confidence, persistence, ground coverage, confirmed losses, artillery veto, cooldown and invalid-input rules +PASS: production cover retains the carried HQ target until its deadline; airport reload waits until the drop pass and cover have finished +PASS: air logistics reservation preserves delivered search until radio handoff, excludes guards/combat/in-flight orders, and quarantines unresolved native loads/moves +PASS: original 15-minute launch/loss cadence, active/captured timer disarm, saved delay reset, stop, 300m orbit and close-contact standoff +PASS: actual aircraft-removal notification guards owner/server/active/stop and schedules one full delay without replay extension +PASS: hill prominence/elevation, actual road edges/heights/bends and server-only finite scout admission, pending stock, timeout and no replacements/refills +PASS: mortar duty scope excludes players, other crews, stopped/client/inactive states; only destruction releases an active assignment, while the gunner may approach the gun +PASS: random mode restrictions, full numeric ranges, independent calendar dates and strict daylight boundaries +PASS: actual killais eligibility includes the 1000m boundary, excludes players/retained bodies/dead/non-character entities and checks full 3D distance +PASS: actual generation scheduler bounds each tick, enforces deadline/candidate ceiling, validates fallback, commits only accepted positions, and terminates without repeated searches +PASS: actual faction resolution reserves explicit roles, samples without replacement, substitutes disabled selections, and rejects duplicate/unknown/missing choices and insufficient pools +PASS: actual content policy gates parent packs, individual factions, faction identity and finite group rotation with vanilla fallback +PASS: actual delivered road eligibility, ahead projection without chords, reservations, stock road query bounds, terrain/water/obstruction refusal and clean outputs +PASS: actual delivered-task lineage, expiry, old-order retention, native commit, release, road precision expiry and duplicate delivery +PASS: actual original-roster local handoff,100m3D bound,busy carrier/budget exclusion,origin interruption including deletedsource,normal radio delivery and per-source replay history +PASS: actual visual intake retains source/time/position/id, rejects replay, prefers usable own route, excludes simultaneous callers and conservatively resets negative-sweep dwell +PASS: actual fixture local/radio gating, immutable source/time/position, interrupted-report exclusion and exact-owner cleanup +PASS: local-handoff runner refuses wrong project/round count/insufficient deadline before runtime work +PASS: actual fixture allocator terminal/unsubmitted guard, original-group lookup and both candidate gates +PASS: actual report-derived waypoint insertion/order and tighter positive radii; no formation navigation claim +PASS: actual Place/Stop waits native pose update, bounds four attempts, preserves original position, restores pending/settled placement and respects ownership/accepted-move quarantine +PASS: actual fixture current/original recipient ownership, unresolved transfer cleanup, exact-token release and idempotent Stop +PASS: actual authored storage anchors, native trace flags, own-hierarchy exclusion, external wall/vehicle obstruction, range, unknown geometry and bounded context refusal +PASS: actual stock capture, unique finite identities, original recipients, HQ reservations, delivered report replay/cooldown and failed-downlink cleanup +PASS: actual original turret identity, native/canonical guards, scoped >=80/>=100 profile, reset, dismount/reboard, requested-skill preservation and restoration +PASS: bounded150s startup followed by exactly one120s memory window; only HQ-memory outer deadline extended +PASS: actual read migrates old valid settings, preserves explicit disabled state, rejects invalid data; next-round feedback and fixed menu remain honest +PASS: actual automatic convoy timing/authority/admission/one-attempt/manual-task/stop policy; native deployment unproved +PASS: late authoritative failure reason refreshes open result; detach, mode change and success subscriptions preserved +infantry resupply actual rules, radio transitions, owned cleanup and unresolved-operation tests passed +PASS: actual resupply Roster preserves permanent release, original slot mapping and bounded ownership +PASS: actual single-item transfer, physical ownership/native callback proof, stop quarantine, compatibility and strict original actor/vehicle boundaries +PASS: actual early ammunition policy and status-first shared uplink; complete threshold, phases, cooldown, failures and requested check-ins +PASS: actual group radio startup preservation, finite native grants, callback quarantine, frozen selection/actor ownership and bounded observation +PASS: actual maintenance native-stock check rejects missing/duplicate/wrong-identity/unowned/depleted/malformed stock and is read-only; native runtime not executed +.................. +---------------------------------------------------------------------- +Ran 18 tests in 0.269s + +OK +PASS: actual radio node rejects current incapacitation, death, ownership and faction changes despite cached inventory; one-second capability refresh remains explicit +PASS: removed guarded vehicle cache and maintenance stock, airfield-only extraction config, and two remaining finite radio-clue sources +PASS: actual diagnostic fallback runs only on failed missing-context call, derives unique native pilot door, preserves stock eligibility and restores prior index on success/failure; diagnostics mutate no game state. +PASS: actual native-prelude cleanup restores only original changed damage, refuses stale/incapacitated/aboard teleport; bounded five-seat/four-door fixture wiring checked. Native execution remains pending. +PASS: actual CreateTransport requires successful scoped Configure after handbrake, preserves ordinary vehicles, tracks spawned entity before failures and never treats failed lock registration as ready. +PASS: actual preparation Configure guards/idempotence/breach preservation, security eligibility/final current-body server mutation and all scoped action guards; all five seat/four door resource overrides. Native hold, prefab merge, replication and boarding remain unverified. +PASS: actual convoy cover observation/snapshot/uplink/HOLD/RESUME/cleanup transitions; native boundaries mocked, non-convoy runner opt-in refused +Enemy optics actual-service ownership, preservation, callback, deadline and bounded-inspection policies passed +PASS: actual delivered cover freshness/lineage/replay and canonical executor; owned waypoint-only hold, one resume at preserved route index, invalid ownership/crew/ledger refusal and fail-closed native recreation +PASS: actual Stinger show/set/help state precise actor-roll effects; read-only help, bounded values, malformed syntax and failed-save preservation; no rerolls/gameplay edits and no native persistence claim +PASS: new/retasked search and radio delivery ammo gates; active tasks and delivered release preserved +HQ convoy delivered-report planning and downlink regressions passed +PASS: actual own-inventory ammo inspection deduplicates loaded/carried/shared wells, separates chambers/launchers, freezes scalar snapshots and treats unsupported/capped/foreign inventory as unknown +PASS: native body fixture uses existing immutable actors, real production LOS/queued uplink, pre-delivery isolation, actual status overlap, bounded cleanup and no fabricated reports/spawns +PASS: actual native fixture lookup selects exact BODY kind/identity/message from bounded evaluations, never sighting-only Get +PASS: actual ammo snapshot comparison checks every scalar/category flag; native probe requires real roster rescan, loaded-round lower bound and separate immutable pending/Watch objects +PASS: actual delivered slot-union bodies preserve overlap/disjoint evidence, replay idempotence, status subset integrity, full-wipe closure and formation-local bounds +test_review_added_allowed_media_invalidates_snapshot (__main__.ReviewTests.test_review_added_allowed_media_invalidates_snapshot) ... ok +test_review_added_source_directory_rejected (__main__.ReviewTests.test_review_added_source_directory_rejected) ... ok +test_review_loose_root_resource_and_symlink_rejected (__main__.ReviewTests.test_review_loose_root_resource_and_symlink_rejected) ... ok +test_review_metadata_media_and_sidecars_remain_allowed_and_bound (__main__.ReviewTests.test_review_metadata_media_and_sidecars_remain_allowed_and_bound) ... ok +test_review_missing_link_rejected (__main__.ReviewTests.test_review_missing_link_rejected) ... ok +test_review_package_guard_sees_new_scripts (__main__.ReviewTests.test_review_package_guard_sees_new_scripts) ... ok +test_review_retargeted_private_link_rejected (__main__.ReviewTests.test_review_retargeted_private_link_rejected) ... ok + +---------------------------------------------------------------------- +Ran 7 tests in 0.075s + +OK +PASS: actual report methods reproduce old starvation; status-first scheduler delivers check-ins and requires 30s without combat or actual visible contacts before negative reports +PASS: actual actor group lookup retains controlled character membership when seated/inherited vehicle agents differ; missing control or parent refuses +................. +---------------------------------------------------------------------- +Ran 17 tests in 3.105s + +OK +PASS: actual convoy motion thresholds, native-only proof boundaries, physical cargo/crew ownership and pre-launch runner restrictions +PASS: actual filled rotated-footprint TraceBox and nine terrain/water samples; broad tree/cable bounds alone accepted, actual collision/nonfinite trace refused, nonzero sweep and ENTS/OCEAN flags, bounded diagnostic query and reset +PASS: actual road interpolation/projection/orientation, six35m starts,200m forward tail, preserved bends, short/far/degenerate/nonfinite/overdense/hairpin refusal and deterministic candidate ties +PASS: actual authenticated convoy dispatch, active/ready admission gates, canonical receipt, safe unconsumed preflight retry, pool refusal, retained partial failure and consumed no-retry +PASS: actual native readiness exact membership, one-time per-vehicle ledger admission/reservation, partial readiness retry without duplicate records and one-pass56-item cargo allocation without replenishment +PASS: actual per-vehicle readiness reasons, native agent/activity counts, intended-seat diagnostics, five-second throttle and read-only observation +PASS: actual convoy dispatch validates before receipt consumption, consumes once before native mutation, retains all12 partial-failure paths, accepts the selected non-USSR enemy, and allocates exact19 bodies without capacity shrinking +PASS: own memberless prefab disables automatic spawn before initialization; actual admission rejects populated, immediate or authored-slot templates before finite crew creation +PASS: actual native seated-group arrival outside35m, native waypoint removal, idempotent advance, and convoy waypoint idempotence/arrival/deadline, missing/dead/captured/reparented crew refusal, failed native waypoint release, original vehicle/member preservation and no replacement +PASS: actual convoy receipt identity/one-use, shared60-body and128-formation caps, outstanding/native-ledger accounting, exact19-body composition, source reuse, mode/client guards and no consumed receipt refunds +PASS: actual supply route cycling/arrival/stops, finite startup bounds, contact freshness, dismount hold, quiet interval, interrupted boarding, timeout and lost-truck release +PASS: moving-convoy perception boundaries and scoped individual/group abandonment priorities +PASS: actual body observer enforces owned physical roster, local death-only candidates, native trace gating, budget/cadence/caps, immutable body-only snapshots, radio retries and confirmed suppression +PASS: own initial roster, explicit retained corpse evidence, unconscious/missing/ref transfer guards, fixed strength, no replacement adoption, detached samples and surviving radio sender requirement +PASS: delivered-only status snapshots, two missed check-ins/reply deadline, last reported coordinates, episode/replay guards, confirmed-loss idempotence and 256-record cap +PASS: actual assignment cancellation/release preserves radio ownership, native waypoint lifecycle and failed-order retry boundaries +PASS: actual force ledger, finite casualties/identity/cap, routes/arrival/elevation/overshoot, server and physical guards, staggered 256-record simulated hour without world queries +PASS: actual enemy eligibility, scoped native skill floor/reset/restoration, one-roll inventory issuance, completion/failure, timeout quarantine, actor capture and terminal stop; native inventory runtime unproven +PASS: actual equipment coordinator registration dedupe, startup delay, five member scans, finite component retries, unregister/stop terminal behavior and 256-group bound +PASS: actual objective heading/threat/approach rules, persistent doctrine weight, fresh-contact coverage invalidation, stale-sweep rejection and atomic radio retask ownership +PASS: actual bounded delivered-negative coverage, replay/freshness/expiry/invalid guards, oldest eviction, small-circle and nearest-unswept selection, reserved-cell exclusion and cleanup +PASS: actual bounded pursuit roles/objectives/scores, stale search degradation, physical/busy/home guards, finite lifetime and two-bearing escape gap; no hidden player reads or spawning +PASS: actual HQ memory rules/service preserve delivered snapshots, chronology, replay bounds, decay, motion limits, identity and negative-search revisions +....... +---------------------------------------------------------------------- +Ran 7 tests in 0.025s + +OK +PASS: actual session Tick + journal router delivers each grant completion and new HQ line once, without repeats on empty/unavailable feeds or unauthorized ticks +..... +---------------------------------------------------------------------- +Ran 5 tests in 0.278s + +OK +PASS: actual current-controller/admin/mode authorization, per-round anti-replay, token-rotation refusal, command/snapshot throttles and client scope; owner-only replies and player-tool cleanup wiring +PASS: mission provider registration is idempotent, optional dispatch is ordered, status aggregates, journals preserve cursors, aliases stay with their addons and topic help is bounded +PASS: actual menu command routing covers all eleven settings, rejects unsupported keys, gates companions, and matches unique authored widgets +PASS: actual admin body authorization, god restoration, vanilla item deposit, callback completion/failure, timeout quarantine and stop/revocation paths +PASS: same-body revoke/regrant preserves pending insertions and timeout quarantine; new-body binding isolates late callbacks and never inherits god mode +PASS: actual Intel commands distinguish status/start/failure, preserve authorization gate and reject malformed requests; spent ledger is idempotent, server-only and bounded +PASS: actual reroll snapshot, authority, cancellation, ownership, accepted operations, quarantine, bounded work and stop methods; no native runtime claim +PASS: production Intel feedback coalescing, rough secrecy, rate limit, bounded dedupe, local/remote delivery and round cleanup; native UI/RPC not exercised +PASS: actual per-squad bounded report selector rotates visible targets, survives ordering/turnover, records only queued attempts, and preserves radio intake ordering +PASS: actual action denial/clearing, server request failure, single submitted-result feedback, current rescuer identity and terminal/stop suppression; native UI/RPC pending +PASS: actual physical denial precedence and transport-seat occupancy/reservation/access/lock checks +PASS: actual initial admission exhaustion and ServerTick grace; new/pending/empty/controller/life guards, normal death preserved +...................... +---------------------------------------------------------------------- +Ran 22 tests in 0.276s + +OK +...................... +---------------------------------------------------------------------- +Ran 22 tests in 27.219s + +OK +.................. +---------------------------------------------------------------------- +Ran 18 tests in 3.077s + +OK +......... +---------------------------------------------------------------------- +Ran 9 tests in 2.379s + +OK +............... +---------------------------------------------------------------------- +Ran 15 tests in 1.269s + +OK +PASS: actual field result/braking observer and Chat dispatch rules; one finite fixed candidate, deferred callback failure and guarded cleanup; no native measurement +PASS: actual opt-in terrain audit records one fixed1681-point grid, preserves original survey refusal/success and exact no-allocation scope; malformed heights stop without retry; no native data acquired +.... +---------------------------------------------------------------------- +Ran 4 tests in 0.729s + +OK +PASS: actual field PI/ramp/vector caps, antiwindup, braking hypothesis, immutable admission limits and segment bounds; no native physics tested +PASS: actual100m whole-field survey sampling/4096cap, rotated coverage, all-node terrain and native trace refusal; original certificate lineage retained; no native qualification +PASS: actual QualifyField preserves original identity/footprint/time, orders complete surveys before issuance, and refuses failed stages/moved body/expired or changed world; native boundaries faked +PASS: WORKBENCH first-refusal diagnostics preserve ordinary predicates, query order/count/budget and opt-in scope; no native logging run +PASS: actual field startup delegation, immutable lease/target, braking/settling/cancel transitions, early abort, callback scope, physical terminal policy and shared landing demand; native boundaries faked +PASS: actual inherited airborne pre-CLIMB cancellation enables verified native autohover once, refuses failed enable and never writes after capture +PASS: actual Core maintenance/default/tick/stop methods; empty/populated authority, terminal reentry, no tactical resume or new scheduler; native boundaries substituted +PASS: actual finite registration, unique claims/tokens, retention/cancel/stop, readiness gating, capture/expiry retirement and bounded cross-kind order history and typed field/route construction; native boundaries faked +PASS: actual WORKBENCH route observer returns exact retained route subtype and refuses absent/field controllers +..... +---------------------------------------------------------------------- +Ran 5 tests in 0.695s + +OK +...................... +---------------------------------------------------------------------- +Ran 22 tests in 0.195s + +OK +................ +---------------------------------------------------------------------- +Ran 16 tests in 11.109s + +OK +...... +---------------------------------------------------------------------- +Ran 6 tests in 0.021s + +OK +PASS: actual production bridge lookup, lease/world/mode/seat/capture guards, no allocation, native super ordering and read-only fixture hooks +............... +---------------------------------------------------------------------- +Ran 15 tests in 0.059s + +OK +............. +---------------------------------------------------------------------- +Ran 13 tests in 5.440s + +OK +PASS: actual route gain/caps/braking, callback selection, old landing/cancellation control and matching read-only telemetry +.............................. +---------------------------------------------------------------------- +Ran 30 tests in 0.453s + +OK +.............. +---------------------------------------------------------------------- +Ran 14 tests in 0.213s + +OK +......... +---------------------------------------------------------------------- +Ran 9 tests in 0.127s + +OK +PASS: actual gate observer binds native registered action/context and current life, reads accumulated progress, refuses stale bindings and never revives outside original bounds +PASS: actual read-only look observer methods enforce explicit scope, pinned native camera/body/identity, normalized pose, terminal cancellation and original sample/time bounds +........... +---------------------------------------------------------------------- +Ran 11 tests in 0.069s + +OK +....................... +---------------------------------------------------------------------- +Ran 23 tests in 0.042s + +OK +PASS: actual initial WAITING task/guide copy, configured duration, unchanged interruption and rounded progress; native task assertion pending +........ +---------------------------------------------------------------------- +Ran 8 tests in 0.012s + +OK +PASS: actual inventory adapter scope, grid placement, sole compatible host, refresh reuse, rollback, foreign hierarchy refusal and exactly-once native delegation; native compatibility pending +................................................................................. +---------------------------------------------------------------------- +Ran 81 tests in 0.559s + +OK +...... +---------------------------------------------------------------------- +Ran 6 tests in 0.078s + +OK +.............. +---------------------------------------------------------------------- +Ran 14 tests in 0.002s + +OK +........... +---------------------------------------------------------------------- +Ran 11 tests in 0.092s + +OK +......... +---------------------------------------------------------------------- +Ran 9 tests in 0.362s + +OK +PASS: actual observer nonce, admission pins, baseline/menu sequencing, snapshots, bounded clocks/samples, cleanup and one-attempt latch; native untested +PASS: actual corridor caps/height/hold and whole-rectangle survey bounds, terrain refusal, native trace refusal and exact certificate lineage; native route unqualified +PASS: actual route entry, fixed corridor/target, hover hold, cancellation landing freeze, shared lease/capture/deadline gate and unchanged native startup/tick sequence +PASS: finite opt-in route fixture, original native input order, meaningful physical-arrival evidence and guarded cleanup; no native launch +PASS: actual Chat route step result dispatch and scoped completion/cleanup wiring; six real early-parser refusal paths leave no prefix/profile output +PASS: actual retained-service fixture entry, immutable controller/token/deadline/count bindings, observer getter and cleanup ownership ordering; native route validation pending +PASS: actual measured cancellation gate, one-shot retained service Stop, no premature drain, immutable lease, fresh real controller readback and separate physical cancellation result policy +PASS: actual rough-intel objective selection, exact precedence, source grid dimensions and unchanged other-phase dispatch; native UI pending +................................. +---------------------------------------------------------------------- +Ran 33 tests in 0.105s + +OK +............... +---------------------------------------------------------------------- +Ran 15 tests in 0.036s + +OK +PASS: actual preparation status scope, persistent updates, spinner restoration, placeholder replacement/deletion/destruction, native observation persistence/timeout; rendering remains native-pending +PASS: actual guide/observer monitors retry native refusal, cache successful delivery, deduplicate and preserve living/preparing/spectator boundaries; native UI not exercised +PASS: actual native-end shutdown, existing terminal snapshots, reentrancy, unchanged native end data and stale preparation guards +.... +---------------------------------------------------------------------- +Ran 4 tests in 1.603s + +OK +PASS: actual three-state result wording and UI refresh; stale winner IDs never manufacture escape +........... +---------------------------------------------------------------------- +Ran 11 tests in 0.127s + +OK +PASS: actual immutable target, bounded autohover demand, drift refusal, five-second hold and unchanged qualification/deadline +PASS: actual admission attempt budget bounds blocked/missing prerequisites; preparation, spent life and async pending remain protected +PASS: actual action permission matrix unchanged; useful phase/intel/consciousness denials, stale reason cleared, native door denial preserved; no native UI claim +..... +---------------------------------------------------------------------- +Ran 5 tests in 0.250s + +OK +PASS: actual aircraft cancellation/stage rules, ground-only completion, expiry/capture faults, input ownership and no physical shortcuts +PASS: actual exact-instance single-use qualification, native-clock staleness/movement refusal, footprint bounds and ownership-safe ground/fault cleanup +PASS: actual optional screenshot helper flag/identity/world/life revalidation, one queued attempt, delayed capture and mission/world cleanup; no native image/presentation claim +PASS: actual yard diagnostic exact registered prison ownership, eight native-bound corner projections, no root-sibling escape, bounded duplicate/deep/wide hierarchy traversal; native projection/mask still unmeasured +PASS: actual CurrentActor, SpawnPosition and observer-selection methods reject pending/unregistered/replaced/recycled/foreign/dead bodies; canonical selection/cycling, unconscious following and prison/geometry fallback preserved; native boundaries adapted +PASS: actual native-map fixture ownership, cleanup and callback evidence fail closed; native execution remains separate +PASS: actual shared-map callbacks bind exact map/config/root, preserve replacement UI/camera, defer focus, and remove only owned delayed work +PASS: actual Intel/HQ methods and one-shot qualified readiness handoff deliver late actors once; duplicate readiness, accepted spawn, pickup/drop, death and original negative rolls do not mint assignments +PASS: Workbench-only read accessor rejects unknown/nonserver identities; Chat observes all distinct ready crew before existing shell-depletion mutation +PASS: actual native-event latch survives deselection, requires exact activity/order, detaches before cleanup, preserves failure/timeout and never infers completion from physical shot count +......... +---------------------------------------------------------------------- +Ran 9 tests in 0.115s + +OK +PASS: actual refresh-fixture gates pin immutable observations/actual downlinks/original activity, reject unaccepted or relabelled commits, stop queueing early and require all queued contacts claimed before replay +.......................... +---------------------------------------------------------------------- +Ran 26 tests in 0.221s + +OK +PASS: actual refresh scheduler/delivery/cancellation/projectile methods reject interrupted or replayed reports, retain committed ownership, preserve immutable snapshots/aim geometry and isolate stopped or replaced orders; native radio/crew behavior remains separate +..................... +---------------------------------------------------------------------- +Ran 21 tests in 0.037s + +OK +........... +---------------------------------------------------------------------- +Ran 11 tests in 0.037s + +OK +PASS: actual full-salvo gates require eight fresh committed physical launches, natural closure and exact finite inventory; reject partial/extra/replayed events +PASS: actual reason/availability guidance and refresh matrix; no-target copy omits cycling, hint retry retained, existing command/admin paths unchanged; native UI pending +................usage: test_native_package.py [-h] [--project {core}] --session SESSION + --compat COMPAT [--steam STEAM] + [--timeout TIMEOUT] + [--external-addons EXTERNAL_ADDONS] + [--silent-workbench | --exit-after-init] + [--continue-on-failure] +test_native_package.py: error: argument --exit-after-init: not allowed with argument --silent-workbench +................................ +---------------------------------------------------------------------- +Ran 48 tests in 11.221s + +OK +....... +---------------------------------------------------------------------- +Ran 7 tests in 0.314s + +OK +.............. +---------------------------------------------------------------------- +Ran 14 tests in 2.085s + +OK +.... +---------------------------------------------------------------------- +Ran 4 tests in 0.426s + +OK +.... +---------------------------------------------------------------------- +Ran 4 tests in 0.082s + +OK +PASS: actual finite-courier age, token, exact ownership/callback and delivered-status admission reject stale, replayed, captured, canceled and ambiguous work +PASS: actual native-gun ownership gate rejects player/foreign/replaced/dead occupant, faction change, missing seat and missing registry +PASS: four-battery courier selection preserves pending trip/report/transfer identity, skips unavailable crews, and selects the next depleted battery without new stock +PASS: 64 bounded distinct storage candidates; native gates, five-store barrier and finite-stock ownership retained; native placement pending +PASS: 128 coarse+80 local refinement+169 airport grid candidates retain all native clearance gates; real terrain untested +PASS: actual prison registration/crossing policies reject missing/duplicate/destroyed initial gate, incomplete ownership/scan, outside-only body, jumps and stale samples; physical exit independent of later gate damage +PASS: actual prison door execution blocks direct AI/UI bypass while locked and delegates unchanged native action once after unlock and permission approval +.................. +---------------------------------------------------------------------- +Ran 18 tests in 2.557s + +OK +..... +---------------------------------------------------------------------- +Ran 5 tests in 1.430s + +OK +PASS: native local-admission snapshot rejects changed life/key/body/participant/pending state; authentication is not simulated +PASS: actual mortar report ledger prevents duplicate response after cancellation, expiry and interleaved reports; native scheduler separate +......... +---------------------------------------------------------------------- +Ran 9 tests in 0.016s + +OK +.... +---------------------------------------------------------------------- +Ran 4 tests in 0.233s + +OK +PASS: actual aim renewal requires fresh same-report solve and original-command equivalence; native firing untested +........ +---------------------------------------------------------------------- +Ran 8 tests in 0.014s + +OK +PASS: actual startup/generation/ownership/timeout/callback policies and native-counter wait; integration preserves one-shot allocation and pending-crew cancellation +PASS: actual probe binds native events to fresh immutable commits, permits delayed emission, rejects unrelated expiry closure and premature stop +PASS: actual noSplash Load body requires both explicit diagnostic and noSplash, preserves vanilla otherwise, and handles repeated Load/reload +PASS: actual fixture method permits one native resilience operation and restores either prior protection state, including failure paths +PASS: actual safe-exit boundary policy, native action/door selection, stop/cache cleanup, separate old mechanism and opt-in runner contracts +PASS: actual one-shot deadline/stability policies and preparation ownership, fuel, cleanup integration contracts +PASS: pending-only cancellation retires copied instance intent, preserves physical/control actors, and stops once +PASS: actual short-cast hit, upward support normal, no self support and no dynamic support +PASS: actual committed-ticket structure preserves reservation after death, rejects missing bodies/seats and ends at observation expiry +PASS: actual pending callback expires an empty-server reservation once, preserves its full lease, and cancels on completion/Stop +PASS: rescue lease expiry/reordering, fresh five-second settling, independent rescue ranges and grounded upright aircraft motion boundaries +PASS: requested guard population requires complete physical conscious membership before deadline +.................................... +---------------------------------------------------------------------- +Ran 36 tests in 0.020s + +OK +.......... +---------------------------------------------------------------------- +Ran 10 tests in 0.003s + +OK +..... +---------------------------------------------------------------------- +Ran 5 tests in 1.057s + +OK +PASS: authored radio/optional-site bounds and physical horizontal/vertical boundaries +... +---------------------------------------------------------------------- +Ran 3 tests in 0.001s + +OK +PASS: all phase transitions, terminal states, defence boundaries, stale/future observations, intel discovery +PASS: Core document-discovery alias, companion-only alias exclusion, strict integer parsing and setting boundaries +PASS: player admission phase/life/identity/controller matrix, preload displacement limits, pending reservation overlap and owner exclusion +PASS: actual gate and pre-inventory document guards reject noncanonical/faction/life/disconnect/server mismatches; canonical reconnect and unconscious carried intel preserved +PASS: WB-only native fault injection restores both body mutations before checks/returns and requires eligible held-document negative/positive proof; runtime pending +PASS: actual discovery coordinates disclose stable 500m cell center until identified, then exact point, including negative/boundary coordinates +PASS: actual local admission context/selection/latch, same-round late identity, replacement/mode failure, fresh round and untouched network path +PASS: prison search packs 16 unique 3m-spaced inside-wall centers, prioritizes inner/edge positions and never bypasses clearance +PASS: physical body occupancy rejects overlap, permits diagonal neighboring slots, and excludes own body and unrelated broad-phase bounds +PASS: recovery state excludes dead bodies, preserves reservations, requires controlled rescuers and distinguishes empty/all-dead teams +PASS: every minute round-trips, malformed clocks rejected, defaults limited to added keys, progression bounds and day duration aligned +PASS: native settings policy matches calendar boundaries 1900-2050, date/profile parsing is strict, authored defaults preserve old worlds +..... +---------------------------------------------------------------------- +Ran 5 tests in 0.645s + +OK +. +---------------------------------------------------------------------- +Ran 1 test in 0.425s + +OK +PASS: radio authorization/discovery/phase matrix, exact distance limits, defender handover, interruption, empty samples, clock regression and duration boundary +PASS: actual radio feedback policy, initial wait, interruption persistence, resumption and terminal/new-round reset +............... +---------------------------------------------------------------------- +Ran 15 tests in 2.249s + +OK +.............................................. +---------------------------------------------------------------------- +Ran 46 tests in 0.972s + +OK +PASS: legacy values preserved, unsupported fields reported, invalid data rejected, existing files protected, runtime bounds aligned +PASS: real-carrier shortest-hop paths, 500m boundary, three-hop cap, no-manpack failure, three-second originating-call interruption, unchanged downlink hop timing, completed-hop survival, interrupted final uplink, radio loss, capture, motion, timeout, reset and network bounds +PASS: migrated aircraft availability policy (controller migration still pending) +PASS: migrated salvo offsets are deterministic, spread and bounded (native firing and complete salvo qualification are separate) +PASS: 32 prefabs have unique saved root entity IDs +PASS: 299 original script entries tracked across 18 unfinished responsibilities +PASS: 1 isolated addons, acyclic dependencies, 51 unique resources, exact vanilla/authorized external references +All repository checks passed. Native compilation and multiplayer tests are separate requirements. diff --git a/docs/evidence/admin-pages-2026-09-22/sources.json b/docs/evidence/admin-pages-2026-09-22/sources.json new file mode 100644 index 0000000..892f673 --- /dev/null +++ b/docs/evidence/admin-pages-2026-09-22/sources.json @@ -0,0 +1,10 @@ +{ + "addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c": "b9cf9b7e6f3135bff2fe2ad58d90bcb696936e984fb0c285172be627fccfeda5", + "addons/core/Scripts/Game/ERR/00_Core/ERR_ContentMenu.c": "c82b173496e7524b4570b2afc5493cb970252a19aed740a53011988db7700a01", + "addons/core/Scripts/Game/ERR/00_Core/ERR_FactionMenu.c": "b7dd477548322e674d9492fd8d037f787dc880ec7bf0e73dc89a2bf7f93cfdb5", + "addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelEvidence.c": "6bbf8d3b83e2c09fd9a23a9a5e5e11d7c29af2e618116bee613562fc45af2b33", + "addons/core/Scripts/Game/ERR/10_AI/ERR_BodyRecovery.c": "3aa41a8694e6409577fb07ef23a8d71ea104696e9e468a00c19a2a2b7e70172e", + "addons/core/UI/layouts/ERR/AdminMenu.layout": "e43f70720d4a33d37ae7f41c8d59aec753b8bbfb8c491b311850f6f87fff314b", + "addons/core/UI/layouts/ERR/PauseModsButton.layout": "393de5099e8aa5c849b95ed6955c09348f740d318f39f08c46768d1468cd4a0c", + "addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAdminMenuProbe.c": "4bdbaf54a9e325f631eb6f60003b3fceb7b1986148ca51100f5b1f4d765a794b" +} diff --git a/docs/evidence/hq-air-support-2026-09-22/README.md b/docs/evidence/hq-air-support-2026-09-22/README.md new file mode 100644 index 0000000..23f8d9c --- /dev/null +++ b/docs/evidence/hq-air-support-2026-09-22/README.md @@ -0,0 +1,21 @@ +# HQ support native evidence — 22 September 2026 + +These excerpts retain successful gameplay assertions from isolated Workbench runs. +The accompanying hashes identify relevant addon sources at each launch; earlier +runs are not represented as a full acceptance test of later code changes. + +- `airborne-native5`: eight original actors dropped and landed, both original teams + released, same-airframe airport return/service and second eight-person departure. + The fixture supplies a controlled drop order and shortens the post-drop cover wait. +- `recovery-native3`: physical corpse retention, ordinary delivered body sighting, + special recovery team, local inspection, radio report and compromised-site response. +- `cargo-native7`: ordinary low-ammo report and downlinks, physical loading/flight/ + landing, squad approach and three exact native magazine transfers. No body sighting + occurred in this run, so the combined cargo-plus-recovery completion condition was + not reached. The run was stopped after the handover evidence was captured. + +Repository checks and the production policy regressions passed. Native compilation +passed for the final cargo run. Strict runner shutdown can encounter a transient +exiting Wine process with empty argv; prefix idleness was verified separately before +any subsequent source edits. This evidence does not certify dedicated multiplayer, +all factions, packet loss, spent rocket-pod reload or a complete cargo repeat trip. diff --git a/docs/evidence/hq-air-support-2026-09-22/airborne-native5-excerpt.log b/docs/evidence/hq-air-support-2026-09-22/airborne-native5-excerpt.log new file mode 100644 index 0000000..18c6d46 --- /dev/null +++ b/docs/evidence/hq-air-support-2026-09-22/airborne-native5-excerpt.log @@ -0,0 +1,14 @@ +script.log +18:43:07.639 SCRIPT : ERR SMOKE PASS: air assault eight original passengers physically seated before takeoff +18:43:40.032 SCRIPT : ERR SMOKE PASS: air assault controlled order finds real clear landing corridor +18:44:52.363 SCRIPT : ERR SMOKE PASS: air assault same eight actors landed and two original groups released; original aircrew retained +18:44:52.364 SCRIPT : ERR SMOKE PASS: air assault post-drop cover active +18:48:28.438 SCRIPT : ERR SMOKE PASS: air assault same helicopter returned, serviced and departed with another native squad +18:48:28.438 SCRIPT : ERR SMOKE PASS: air assault separate cargo helicopter waiting at airport +18:48:28.438 SCRIPT : ERR SMOKE COMPLETE: native airborne cycle completed + +rereintroduced-hq.log +#260 t=201.2s M0 AIR TEAM LANDED original team searching carried HQ order at <4884.44, 31.8356, 11360.4> [session=1790095280-1] +#262 t=204.2s M0 AIR TEAM LANDED original team searching carried HQ order at <4884.44, 31.8356, 11360.4> [session=1790095280-1] +#263 t=205.2s M136 ORDER QUEUED AIRCRAFT RELOAD from report M0; cover complete; return to airport for another squad and ammunition [session=1790095280-1] +#482 t=418.4s M0 AIRFIELD SERVICE Airport: 1 services; gun rounds=23998 fuel=11354; ammo trips=0; original airframe and crew retained [session=1790095280-1] diff --git a/docs/evidence/hq-air-support-2026-09-22/airborne-native5-source-sha256.json b/docs/evidence/hq-air-support-2026-09-22/airborne-native5-source-sha256.json new file mode 100644 index 0000000..ce1835b --- /dev/null +++ b/docs/evidence/hq-air-support-2026-09-22/airborne-native5-source-sha256.json @@ -0,0 +1,14 @@ +{ + "addons/core/Prefabs/ERR/AssaultParachute.et": "b556997989605e2a05982418b7e9f26d12d427eb3ea6e3bc50e85c67ea7aa4b8", + "addons/core/Prefabs/ERR/AssaultParachute.et.meta": "e9dea2d533ad1ce6ce689e0e2815c97a6382e1b90c35ce1efa88a885527e8e18", + "addons/core/Scripts/Game/ERR/00_Core/ERR_AirfieldPlacement.c": "79d9baf40ed4890e129ec50f6852a6b86e3920390afda51cefe30daa045856a2", + "addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAirfieldMarkersProbe.c": "593e59664dbd7330b52674e5d4cc7b70db588cb75a1259ed808b74602501d541", + "addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultLoad.c": "6bc62ac73f4a682b2a3dfe0aee5a5f1f706d28bb1bd7739e601ddd02a9338696", + "addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultSystem.c": "b9d62818e3a7ab712fa2327e2f3341c0324d6c1d0703d563c7f58764d5c5f32a", + "addons/core/Scripts/Game/ERR/10_AI/ERR_AirSupportPolicy.c": "ed3e9a362583b092d840770eb8a4d2c68f22d376c21733803324765ae49d8eea", + "addons/core/Scripts/Game/ERR/10_AI/ERR_AirfieldSupport.c": "dbe9cbc9b326a27778841e941b745b7d96a420b47306f95d2aaa8c6f97c4f9dc", + "addons/core/Scripts/Game/ERR/10_AI/ERR_AssaultParachute.c": "a93c996059e7166988e9d1b0b498cbfd36f19a1cf8520cb4e9215a8884752e18", + "addons/core/Scripts/Game/ERR/10_AI/ERR_NativeAirAssaultProbe.c": "edf1e1e5393e10b6df40ed13fa2e33ad2a9c3cc77c1fb24852ee10c621fe9a41", + "addons/core/Scripts/Game/ERR/10_AI/ERR_ReconFlightComponent.c": "42cda3ea67f17c29dcd0c10f93475448e66284fb484b5125fb7dff55ef3629ef", + "addons/core/addon.gproj": "adee1fe3689108da7e2c0f4e7288a2f414d74208477ed5f9e4efad1c01030aa5" +} diff --git a/docs/evidence/hq-air-support-2026-09-22/cargo-native7-excerpt.log b/docs/evidence/hq-air-support-2026-09-22/cargo-native7-excerpt.log new file mode 100644 index 0000000..b00fede --- /dev/null +++ b/docs/evidence/hq-air-support-2026-09-22/cargo-native7-excerpt.log @@ -0,0 +1,33 @@ +script.log +19:33:17.224 SCRIPT : ERR SMOKE PASS: air logistics native corpse retention; actual issued document now absent +19:34:21.239 SCRIPT : ERR AIR CARGO ACCESS: enabled=1 contexts=85 +19:34:21.239 SCRIPT : ERR AIR CARGO ACCESS: storage context=door_l01_door rootOwner=1 transformed=1 point=<4787.85,29.8604,11416.8> +19:34:24.279 SCRIPT : ERR SMOKE PASS: air logistics native infantry low-ammunition request setup; waiting for real radio/HQ dispatch +19:38:31.807 SCRIPT : ERR SMOKE PASS: air logistics aircraft transported airport-loaded magazine and native inventory transfer confirmed + +rereintroduced-hq.log +#171 t=109.3s M64 ORDER QUEUED AIR AMMUNITION from report M47; load twelve magazines and fly to reported unit position <4951.63, 29.7016, 11342.8> [session=1790098354-1] +#187 t=124.5s M0 AIR AMMUNITION DEPARTURE native cargo aboard; destination from delivered unit request [session=1790098354-1] +#188 t=125.5s M0 HELICOPTER STATUS TAKEOFF: ammunition helicopter awaiting HQ order; position=<4789.08, 28.2043, 11414.7> [session=1790098354-1] +#189 t=140.7s M0 HELICOPTER STATUS TAKEOFF: ammunition helicopter awaiting HQ order; position=<4789.08, 59.858, 11414.7> [session=1790098354-1] +#190 t=155.8s M0 HELICOPTER STATUS TAKEOFF: ammunition helicopter awaiting HQ order; position=<4789.08, 133.749, 11414.7> [session=1790098354-1] +#196 t=171s M0 HELICOPTER STATUS LANDING: landing at airfield; position=<4808.99, 149.218, 11377.2> [session=1790098354-1] +#197 t=186.1s M0 HELICOPTER STATUS LANDING: landing at airfield; position=<4831.26, 147.225, 11335.3> [session=1790098354-1] +#198 t=201.3s M0 HELICOPTER STATUS LANDING: landing at airfield; position=<4834.18, 129.562, 11329.9> [session=1790098354-1] +#199 t=216.5s M0 HELICOPTER STATUS LANDING: landing at airfield; position=<4834.25, 112.119, 11329.7> [session=1790098354-1] +#272 t=231.7s M0 HELICOPTER STATUS LANDING: landing at airfield; position=<4834.25, 94.6399, 11329.7> [session=1790098354-1] +#291 t=246.9s M0 HELICOPTER STATUS LANDING: landing at airfield; position=<4834.25, 76.326, 11329.7> [session=1790098354-1] +#292 t=262s M0 HELICOPTER STATUS LANDING: landing at airfield; position=<4834.25, 58.6636, 11329.7> [session=1790098354-1] +#293 t=277.1s M0 HELICOPTER STATUS LANDING: landing at airfield; position=<4834.25, 40.9712, 11329.7> [session=1790098354-1] +#299 t=292.2s M0 HELICOPTER STATUS LANDING: landing at airfield; position=<4834.25, 32.9493, 11329.7> [session=1790098354-1] +#305 t=304.3s M0 AIR AMMUNITION RENDEZVOUS nearest original recipient=110.559m; confirmed magazines=0 [session=1790098354-1] +#306 t=319.5s M0 AIR AMMUNITION RENDEZVOUS nearest original recipient=96.7928m; confirmed magazines=0 [session=1790098354-1] +#307 t=334.6s M0 AIR AMMUNITION RENDEZVOUS nearest original recipient=46.0027m; confirmed magazines=0 [session=1790098354-1] +#310 t=349.8s M0 AIR AMMUNITION RENDEZVOUS nearest original recipient=3.20373m; confirmed magazines=0 [session=1790098354-1] +#314 t=364.9s M0 AIR AMMUNITION RENDEZVOUS nearest original recipient=3.20379m; confirmed magazines=3 [session=1790098354-1] +#315 t=380.1s M0 AIR AMMUNITION RENDEZVOUS nearest original recipient=2.45618m; confirmed magazines=3 [session=1790098354-1] +#316 t=395.2s M0 AIR AMMUNITION RENDEZVOUS nearest original recipient=2.4561m; confirmed magazines=3 [session=1790098354-1] +#368 t=410.3s M0 AIR AMMUNITION RENDEZVOUS nearest original recipient=2.45622m; confirmed magazines=3 [session=1790098354-1] +#408 t=425.5s M0 AIR AMMUNITION RENDEZVOUS nearest original recipient=2.45609m; confirmed magazines=3 [session=1790098354-1] +#409 t=440.6s M0 AIR AMMUNITION RENDEZVOUS nearest original recipient=2.4562m; confirmed magazines=3 [session=1790098354-1] +#410 t=455.7s M0 AIR AMMUNITION RENDEZVOUS nearest original recipient=2.45659m; confirmed magazines=3 [session=1790098354-1] diff --git a/docs/evidence/hq-air-support-2026-09-22/cargo-native7-source-sha256.json b/docs/evidence/hq-air-support-2026-09-22/cargo-native7-source-sha256.json new file mode 100644 index 0000000..11a9e5f --- /dev/null +++ b/docs/evidence/hq-air-support-2026-09-22/cargo-native7-source-sha256.json @@ -0,0 +1,13 @@ +{ + "addons/core/Scripts/Game/ERR/00_Core/ERR_AirfieldPlacement.c": "79d9baf40ed4890e129ec50f6852a6b86e3920390afda51cefe30daa045856a2", + "addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAirfieldMarkersProbe.c": "593e59664dbd7330b52674e5d4cc7b70db588cb75a1259ed808b74602501d541", + "addons/core/Scripts/Game/ERR/10_AI/ERR_AirAmmoMission.c": "293a2c9f59e533768a37ef8c3b1609da1800a8bf9b4a8828aa8015bbd6be27a6", + "addons/core/Scripts/Game/ERR/10_AI/ERR_AirfieldSupport.c": "c6d88ede0b1b6bac5a7075b6e2e783ccfa8850210aabd28bfa4bcdb698cc5f87", + "addons/core/Scripts/Game/ERR/10_AI/ERR_BodyRecovery.c": "73b00ff42f188503245d4b67a50d17a028a2071c3cbff5f2b6750441be647797", + "addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryAmmoAccess.c": "ab4262f78d54782c5ecbf5fff4ebe3da16d72329658d7e18c0c35387dc8b3c7d", + "addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryAmmoTransfer.c": "b398cbf166fe55dd2ff518a1ac028d87dc27edc225e25c1ca3703704a978d994", + "addons/core/Scripts/Game/ERR/10_AI/ERR_NativeAirLogisticsProbe.c": "befee1bd4838dd84847d09490c9dd82e2719db5cc0e6dd6aac8d0197b8e766fd", + "addons/core/Scripts/Game/ERR/10_AI/ERR_ReconFlightComponent.c": "d959749235758bb394543aef1c978ec1b3384850884df87e47ae4acc4affa537", + "addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelEvidence.c": "c0a24e36f718b81dc631ee5bee03f3ef710ae6e0fc091bc2ca7ed05d0abbda6e", + "addons/core/addon.gproj": "adee1fe3689108da7e2c0f4e7288a2f414d74208477ed5f9e4efad1c01030aa5" +} diff --git a/docs/evidence/hq-air-support-2026-09-22/recovery-native3-excerpt.log b/docs/evidence/hq-air-support-2026-09-22/recovery-native3-excerpt.log new file mode 100644 index 0000000..968c367 --- /dev/null +++ b/docs/evidence/hq-air-support-2026-09-22/recovery-native3-excerpt.log @@ -0,0 +1,16 @@ +script.log +19:05:15.637 SCRIPT : ERR SMOKE PASS: air logistics native corpse retention; actual issued document now absent +19:06:22.931 SCRIPT : ERR SMOKE PASS: air logistics native infantry low-ammunition request setup; waiting for real radio/HQ dispatch +19:06:31.970 SCRIPT : ERR SMOKE PASS: air logistics special native recovery team searched body and delivered missing-intel report + +rereintroduced-hq.log +#42 t=38.8s M0 BODY RECOVERY REQUEST delivered body sighting; search ordered at reported position <4895.11, 29.0835, 11869.3> [session=1790096676-1] +#49 t=38.8s M0 BODY RECOVERY TEAM special search team departing friendly site; bodies remain retained [session=1790096676-1] +#174 t=73.9s M49 ORDER QUEUED BODY SEARCH from report M0; inspect reported military body <4895.11, 29.0835, 11869.3> [session=1790096676-1] +#177 t=75.9s M0 BODY SEARCH PROGRESS nearest searcher=31.9729m; physical inspection required [session=1790096676-1] +#198 t=102.9s M0 BODY SEARCH COMPLETE local inspection complete; missing document report awaits radio [session=1790096676-1] +#199 t=102.9s M70 SUPPORT REPORT QUEUED searched body; missing documents=1 [session=1790096676-1] +#209 t=107.9s M0 INTEL COMPROMISE missing issued document concerns everon-mortar-1; defensive reinforcement requested [session=1790096676-1] +#217 t=138.1s M0 INTEL COMPROMISE missing issued document concerns everon-mortar-1; defensive reinforcement requested [session=1790096676-1] +#223 t=168.2s M0 INTEL COMPROMISE missing issued document concerns everon-mortar-1; defensive reinforcement requested [session=1790096676-1] +#224 t=198.4s M0 INTEL COMPROMISE missing issued document concerns everon-mortar-1; defensive reinforcement requested [session=1790096676-1] diff --git a/docs/evidence/hq-air-support-2026-09-22/recovery-native3-source-sha256.json b/docs/evidence/hq-air-support-2026-09-22/recovery-native3-source-sha256.json new file mode 100644 index 0000000..166290b --- /dev/null +++ b/docs/evidence/hq-air-support-2026-09-22/recovery-native3-source-sha256.json @@ -0,0 +1,13 @@ +{ + "addons/core/Scripts/Game/ERR/00_Core/ERR_AirfieldPlacement.c": "79d9baf40ed4890e129ec50f6852a6b86e3920390afda51cefe30daa045856a2", + "addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAirfieldMarkersProbe.c": "593e59664dbd7330b52674e5d4cc7b70db588cb75a1259ed808b74602501d541", + "addons/core/Scripts/Game/ERR/10_AI/ERR_AirAmmoMission.c": "4b39eafbea7e53f4105635552c44823b56bae1e8c48c327df0a5995c766f60ef", + "addons/core/Scripts/Game/ERR/10_AI/ERR_AirfieldSupport.c": "347e642638f67fb6547e8e88bb47310663f404121526e69020422036af36c194", + "addons/core/Scripts/Game/ERR/10_AI/ERR_BodyRecovery.c": "b91d767d14627867f1d5e08e3d6cc8e5d53352ca75b56cc242aeefee911a1b4a", + "addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryAmmoAccess.c": "917f9df36b93258a30b7af24b0ee52b65c5205405d8056bb721a9868010d64d1", + "addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryAmmoTransfer.c": "b398cbf166fe55dd2ff518a1ac028d87dc27edc225e25c1ca3703704a978d994", + "addons/core/Scripts/Game/ERR/10_AI/ERR_NativeAirLogisticsProbe.c": "72065de413a64b20d23f31b74f2d0cfd467e87ee463310db80c4da02ed0b31df", + "addons/core/Scripts/Game/ERR/10_AI/ERR_ReconFlightComponent.c": "629abd79dec91982741621b24d2bad79d8f950a9a87bbe7a998d4a0451aa2bf1", + "addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelEvidence.c": "dc5d4957d9b3bce9874a7b06073b03548bfd8e59b4ab04313a83aa7789768695", + "addons/core/addon.gproj": "adee1fe3689108da7e2c0f4e7288a2f414d74208477ed5f9e4efad1c01030aa5" +} diff --git a/docs/external-addons.json b/docs/external-addons.json index 8008a33..571afdb 100644 --- a/docs/external-addons.json +++ b/docs/external-addons.json @@ -1,10 +1,11 @@ { "schema_version": 1, - "authorization": "User authorized RHS, WCS and BMP dependencies on2026-09-20; external assets remain in installed addons.", + "authorization": "User authorized RHS, WCS and BMP dependencies on2026-09-20; external assets remain in installed addons. Parachute Framework is required by the requested original paratrooper port and HQ-controlled drops (2026-09-22).", "direct_ai_dependencies": [ "595F2BF2F44836FB", "629B2BA37EFFD577", - "5E524E4FEECCA92B" + "5E524E4FEECCA92B", + "65930CB4CD0237B2" ], "addons": { "595F2BF2F44836FB": { @@ -59,6 +60,14 @@ "629B2BA37EFFD577" ], "project_sha256": "ad44151140adf62e99eaa639984c2b9c400be17481e934186d61c307b5221259" + }, + "65930CB4CD0237B2": { + "title": "Parachute Framework", + "version": "1.2.0", + "dependencies": [ + "58D0FB3206B6F859" + ], + "project_sha256": "183fbb521382a95ad33af9efc547147a80241bd536a29d124c93064b5ae5e8bc" } }, "resources": { @@ -91,6 +100,41 @@ "guid": "8D4BAA235F7EDBEA", "addon": "595F2BF2F44836FB", "verification": "Exact path/type/GUID record in installed owning resourceDatabase.rdb; read-only audit 2026-09-20" + }, + "Configs/Factions/RHS_ION.conf": { + "guid": "CB76A36EACFBDF35", + "addon": "595F2BF2F44836FB", + "verification": "Exact path/type/GUID in installed owning resourceDatabase.rdb; read-only audit 2026-09-21. Faction keys and physical group slots checked in owning PAK." + }, + "Configs/Factions/RHS_RF_MSV.conf": { + "guid": "627A8CDE681D223D", + "addon": "595F2BF2F44836FB", + "verification": "Exact path/type/GUID in installed owning resourceDatabase.rdb; read-only audit 2026-09-21. Faction keys and physical group slots checked in owning PAK." + }, + "Configs/Factions/RHS_US_USMC.conf": { + "guid": "F33C7BF22CBF3A8A", + "addon": "595F2BF2F44836FB", + "verification": "Exact path/type/GUID in installed owning resourceDatabase.rdb; read-only audit 2026-09-21. Faction keys and physical group slots checked in owning PAK." + }, + "Prefabs/Groups/BLUFOR/RHS_USAF/RHS_USAF_USMC_MEF/Group_USAF_USMC_MEF_FireTeam.et": { + "guid": "19843E954790DF28", + "addon": "595F2BF2F44836FB", + "verification": "Exact path/type/GUID in installed owning resourceDatabase.rdb; read-only audit 2026-09-21. Faction keys and physical group slots checked in owning PAK." + }, + "Prefabs/Groups/INDFOR/RHS_ION/RHS_ION_COY/Group_RHS_ION_COY_StaticSecurityTeam.et": { + "guid": "516C8C7400DF2C4D", + "addon": "595F2BF2F44836FB", + "verification": "Exact path/type/GUID in installed owning resourceDatabase.rdb; read-only audit 2026-09-21. Faction keys and physical group slots checked in owning PAK." + }, + "Prefabs/Groups/OPFOR/RHS_AFRF/MSV/Flora/Group_RHS_RF_MSV_Flora_FireGroup.et": { + "guid": "AB905EF799E0A0AE", + "addon": "595F2BF2F44836FB", + "verification": "Exact path/type/GUID in installed owning resourceDatabase.rdb; read-only audit 2026-09-21. Faction keys and physical group slots checked in owning PAK." + }, + "Prefabs/DeployedParachutes/Core/DeployedParachuteMK4.et": { + "guid": "9ADD554959C09197", + "addon": "65930CB4CD0237B2", + "verification": "Exact installed owning resourceDatabase.rdb path/type/GUID and inspected native prefab/scripts, 2026-09-22." } } } diff --git a/docs/generated-sites.md b/docs/generated-sites.md new file mode 100644 index 0000000..ec719ad --- /dev/null +++ b/docs/generated-sites.md @@ -0,0 +1,85 @@ +# Generated starts and radio camps + +Everon now chooses a new prison, radio camp and four mortar sites at preparation time. Other caches, +outposts retain their authored positions. The extraction helicopter uses the validated +Everon airfield pad (4800.953 / 11487.412), with no off-airfield fallback. The server +resolves the prison first, then the radio, before creating any site entities. +Joining clients receive the real replicated locations; intel keeps the same +stable destination IDs. + +Site authors can use `RandomClear 1` on a prison, radio or mortar definition. `Heading` +sets the authored fallback heading; `RootHeight` offsets a composition above its +surveyed ground (the radio/table composition uses 0.8 m). Runtime placement never +rewrites the configuration file. Existing definitions default to authored sites. + +Placement samples the island bounds and rejects water, obstructed footprints, +terrain varying by more than 0.6 m, nearby roads (12 m), and unsafe guard footing. +Prisons avoid physical runway/control-tower anchors by 1,500 m. The radio stays +at least 1 km from the prison and 300 m from other sites. The extraction pickup +must remain inside the prison-relative escape boundary with 100 m clearance. +Sampling yields after an 8 ms work budget, with a 30-second overall deadline and +12,000 candidate limit per site. The budget is checked between native terrain +queries, so a single engine query can exceed it. Authored fallback positions +must pass the same checks; otherwise preparation reports failure instead of +spawning an incomplete camp or waiting forever. + +The existing prison composition, gate, backpack and guards move together. +Player slots and escape-boundary tests use the same rotation. Admission still +waits for physical guards, inventory and the client's nearby replicated scene. +Spawn clearance uses a filled standing-volume trace for scenery and actual +character positions for body spacing, avoiding false overlaps from rotated +entity bounding boxes. Pending reservations remain part of the clearance check. +The radio camp has a radio and table inside a full-size tent, with an antenna +and sandbag cover outside. Its complete guard squad permanently defends a 20 m +area around the radio and cannot be reassigned by virtual HQ. See +[radio camp security](radio-camp-security.md). +It retains the existing contact-extraction-HQ action and defence progression. + +Partial intel displays a question mark at the centre of the containing 500 m +square. Exact identification changes it to a site icon at the real location. +The prison is always hidden. Notifications use the original Intel wording. + +`/esc killais` is an administrator-only debugging command centred on the +administrator's current living prisoner. It applies lethal damage to AI +characters within 1,000 m in three dimensions, including friendly AI and seated +crews. Player-controlled and retained player-roster bodies are excluded, as are +vehicles and structures. Death callbacks run normally; the command does not +refill AI forces or manufacture HQ reports. `/esc help killais` explains it. + +Four mortar sites each have a physical gun, four crew, finite shells and +targeting through the HQ radio-report chain. Sites are at least 1 km apart and +500 m from the prison. See [mortar intel and perimeter guards](mortar-intel-and-prison-map.md). + +## Validation + +`test_generated_sites.py` executes the actual bounded-search scheduler; +`test_debug_ai.py` executes kill eligibility. Existing player-tool, admission, +prison-slot and intel-feedback tests cover the changed boundaries. +`--project core --smoke generated-sites` opts into real randomized placement and +tests admission, hidden/identified markers, authenticated killais (including a +seated AI), and the radio objective. The `opening-tools` and `airfield-markers` outcomes also use the live site +definitions. Other Workbench smoke outcomes explicitly load +`Configs/ERR/Tests/LegacySites.conf` to retain their historical deterministic +fixtures, including the retired western cache and old extraction position. +Ordinary Workbench play and dedicated servers use the live definitions. + +On 2026-09-22, the full `python3 tools/check.py` suite passed. The final native +`generated-sites` fixture passed 39 assertions at a randomized Everon start: +prison at approximately (5100, 9137), heading 89 degrees; radio at (5991, 5659). +It placed twelve physical prisoners inside the rotated prison, verified that +occupied body/wall positions still reject spawning, and checked admission with +stocked backpack, hidden partial-intel markers, exact +discovered coordinates, authenticated AI killing (including a seated passenger), +and the existing radio transmission objective. The runner confirmed unchanged +source inputs and complete process cleanup. Native diagnostics still include +external RHS/WCS localization and stock resource-keyword errors; the fixture had +no script/runtime failure. A captured camp view was inspected for radio/table, +antenna and shelter placement. + +Local scripted fixtures do not establish ordinary input, remote-client +replication, or complete multiplayer acceptance. Dedicated-server validation is +still outstanding; this change has not been published or deployed. + +The western guarded vehicle/cache site has been removed. The medical stash now +provides the second reachable source of radio intel; mortar logistics uses the +existing eastern outpost. See [airfield and map changes](airfield-extraction-and-map-icons.md). diff --git a/docs/hill-scouts.md b/docs/hill-scouts.md new file mode 100644 index 0000000..14a9ee0 --- /dev/null +++ b/docs/hill-scouts.md @@ -0,0 +1,80 @@ +# Hill scouts + +Everon's `ScoutCount 10` creates ten independent, finite observation posts before +player admission. `ScoutCount` is a scenario-definition field (0–32; default zero +for existing authored/test scenarios). It does not depend on convoy settings. + +The server samples island elevation, then surveys random dry standing points on +local crests. Posts must be at least 900 m apart, 600 m from the prison and 150 m +from other mission sites. Minimum elevation is 35 m or 18% of sampled island +maximum, whichever is higher. Eight terrain samples 150 m away must show a mean +drop of at least 6 m, with four at least 5 m lower. At least two of eight physical +350 m sight traces must be clear for 95% of their length. The character faces the +best surveyed view. Survey work is limited to 6 ms per preparation tick, 100,000 +candidates and a 45-second real-time deadline. Unsupported terrain fails with a +specific preparation error instead of silently placing scouts in water/forest +obstructions or admitting fewer than requested. + +Each post contains one native AI soldier, an indefinite defend waypoint with an +8 m radius, binoculars, a scoped sniper rifle, and a custom radio backpack with +real BaseRadioComponent/SCR_RadioComponent manpack capability. The pack uses the +Kolobok model, weighs 10 kg empty, and reserves 6 litres for ammunition. Compatible +full magazines are inserted into this exact pack until native capacity refuses +another. One operation per post can be outstanding. The deadline, callback +success, physical radio, binoculars and sniper rifle are checked before admission. +The tested SVD loadout fits 40 full spare magazines in the pack, in addition to +its native loadout ammunition. No ammunition is periodically granted afterward. + +US and RHS_USAF use the native M21 sniper base; other enemy selections use the +native SVD sharpshooter base. Both the group and character receive the selected +enemy faction. The separate ScoutEast, ScoutWest and ScoutRadioPack prefabs are +intended extension points for additional faction-specific scout equipment. The +radio pack is a field-radio loadout, not a deployable player respawn point. + +Scouts inherit the prison's initial AI freeze. On release they use native +RETURN_FIRE: hold fire while observing, engage when native group perception +considers them threatened, and return to holding fire when that threat ends. +They remain registered HQ observers but permanently reserved against tactical +reassignment, courier/resupply work, and assault/search orders. Generic startup +launcher, optic and radio fitting does not overwrite their specialist equipment. +The finite force ledger records one original soldier per post. Killed scouts are +never replaced; the mission owns and cleans up their groups and waypoints. + +Reporting uses the existing clear-visual-contact capture and physical radio +uplink. Reports include observed coordinates, visible travel mode, weapon +categories (drawn weapons only, within the existing identification limits), and +nearby visible escapist count. Road status comes from the captured coordinate +against actual road width, segments and height, not the target's later position. +A scout retains at most 64 recent identity/observation snapshots. Two credible +same-target/same-source sightings 2–30 seconds apart supply an average horizontal +velocity, km/h speed and compass/azimuth direction. Changed travel modes, +implausible speed and stale/replayed observations cannot produce a motion +estimate. All data is frozen before the existing originating call/transmission +delays; removing the physical radio or losing the carrier interrupts that route. +No target inventories, hidden occupants or live player physics are inspected. + +Validation: `tests/test_scouts.py` executes production terrain/road geometry, +observed-motion estimation and the finite admission state machine, including +pending inventory, timeout, client/stop guards and no replacement/refill after +completion. The full repository suite passed. The production `airfield-markers` +Workbench fixture additionally validates all ten physical bodies, complete +magazine counts and full native capacity, faction identity, manpack capability, +return-fire mode, permanent HQ reservation and inter-post separation. Native +results are recorded below. Dedicated multiplayer observation +and combat acceptance remain separate from these local Workbench checks. + + +The final native run on 22 September 2026 used +`/tmp/erri-scout-final/logs/logs_2026-09-22_17-04-55`. It completed 52 gameplay +assertions and 11 scout-specific checks, with zero script errors. Ten independently +randomized posts had full 40-magazine SVD packs, matching the preceding successful +layout in `/tmp/erri-scout-native3`. The full regression suite completed in +`/tmp/erri-scout-check2.log`; the final movement addition and affected HQ/intel +regressions were checked again afterward. + +The runner's strict result was `cleanup-failed`, with `smoke-completed` recorded +before cleanup: it could not classify an exiting `enfMain` with an empty command +line. A subsequent escalated host-visible `assert_prefix_idle` passed. Existing +vanilla GUI/resource and external RHS/WCS localization errors also remain in the +logs. This is not a clean strict runner result or dedicated multiplayer acceptance. +No server deployment, Workshop publication or Git push was performed. diff --git a/docs/hq-air-support.md b/docs/hq-air-support.md new file mode 100644 index 0000000..166bb33 --- /dev/null +++ b/docs/hq-air-support.md @@ -0,0 +1,93 @@ +# HQ airborne support and recovery + +HQ uses delivered sightings and friendly check-ins to decide on reinforcement. +A drop needs at least two sightings, confidence of 70% or better, uncertainty no +larger than 160 m, and a credible on-foot speed no greater than 6 m/s. Reports +older than 90 seconds are unsuitable. Sufficient nearby or already committed +infantry prevents the drop. An unmet need must persist for 45 seconds, reduced +to 20 seconds after two confirmed local losses. Committed drops have an eight +minute cooldown. Radio delivery and a clear real landing corridor are required; +a missing radio, changed memory, occupied airspace, or unavailable troops cancels +the order. No hidden player position supplies an HQ target. + +Eight physical passengers board the search helicopter at the airport in two +four-person teams. The same actors transfer into Parachute Framework canopies +and become ordinary HQ infantry after landing. Passengers are not recreated at +the destination. A drop pass must have a suitable heading, at least 15 m/s speed +and 90–180 m terrain clearance. Drops are staggered two seconds apart. + +The helicopter covers the order's area for five minutes after finishing its drop +pass, then HQ can radio an order to return. It lands at its original airfield, +services for 60 seconds, rearms mounted magazines and rocket pods, refuels and loads another +squad. The original aircraft and aircrew remain. The round has four attempted +passenger loads, 24,000 service rounds, 512 rocket-loading attempts and 12,000 fuel units; failed spawns and +losses are not refunded. An incomplete passenger drop retains surviving aboard +soldiers. The airport has a tent, supply box and service vehicle. Losing the depot +vehicle prevents subsequent service. + +A separate helicopter waits at the airport for ammunition missions. The depot and +cargo helicopter prepare independently of the search helicopter departure timer. A delivered +low-ammo check-in (at most 30 small-arms rounds per soldier) requests assistance. +HQ reserves the receiving infantry and sends separate physical radio orders to +the crew and recipient. A quiet search task is interrupted only after the replacement +radio order arrives. The aircraft loads up to twelve magazines matching the +unit's native weapons, flies to a clear nearby landing pad, and lands. Native +inventory transfer requires the original recipient to reach the stopped aircraft; +there is no remote ammunition refill. Remaining cargo stays with the original +helicopter. Unresolved native inventory operations block a subsequent trip, and an +already submitted magazine is never submitted again. There are at most twelve +delivery attempts per round. Permanent site +guards are not pulled away for these rendezvous. + +Each mortar fire order requests eight HE shells spread over an 80 m radius +(160 m diameter). Individual shells still require a current valid order, physical +ammunition, a usable crew, a ballistic solution, and clear friendly/settlement +safety checks. Thus eight is the requested salvo, not a promise to keep firing +after the crew dies or a friendly enters the impact area. Pending drops, descending +paratroopers and active helicopter supply landing zones exclude mortar impacts. + +Mission enemy bodies are retained until a search and its radio report complete. +Native cleanup of a parent wreck is also blocked while it contains retained evidence. +A delivered body sighting dispatches a separate recovery group from a friendly +site. Searchers must reach the actual body, stop fighting, spend ten seconds +nearby, and pass a line-of-sight check. They compare the remaining physical +folders against the body's issued-document record. Document contents are assigned +while their owner is alive; pickup uses those same site IDs. Missing documents +make those sites potentially compromised. The resulting radio report can order +available infantry to defend them. This is a defensive hypothesis, not a new +sighting and not authorization for mortar fire. If the team or its radio is lost, +HQ receives no stolen-intel knowledge and the body stays retained. Once HQ receives +the completed search report and records its compromised sites, the corpse is +unblacklisted and scheduled for native cleanup after 300 seconds. A search without +a delivered report cannot release it; repeated delivery cannot reset that timer. +This retention applies to registered mission enemy AI, not player corpses. + +`/esc hq heli show` includes airborne decisions, airport stocks and recovery status. +The combined addon now depends on **Parachute Framework** (`65930CB4CD0237B2`; +installed/tested input version 1.2.0). Framework code and assets remain external. + +Validation, 22 September 2026: + +- `python tools/check.py` passed, as did the updated native-context access regression. +- Native airborne fixture: eight original passengers boarded, deployed their canopies, + landed alive, and formed two original teams. The same helicopter returned, consumed + gun ammunition/fuel stock and departed with another eight passengers. The fixture + supplies a controlled drop order and advances the cover deadline after landing; + actual HQ selection and cover timing/target are tested separately as production policies. +- Native recovery fixture: a real issued document was removed from a real guard's + inventory, the guard died, and native garbage insertion was rejected. A normal + delivered body sighting spawned a separate team; that team reached and inspected + the body, then delivered the missing-document report to HQ. +- Native cargo fixture: real low-ammunition check-in, both HQ downlinks, twelve + airport magazine-loading attempts, takeoff, flight, landing, infantry approach, + and three confirmed exact-item transfers. Native Mi-8 storage access has 85 + contexts and 38 actions at the cargo door; both bounded scans now admit it. + This run did not sight its separate randomly placed forensic body, so combined + fixture completion is not claimed. Recovery is evidenced by the earlier run. + +[Native excerpts and source hashes](evidence/hq-air-support-2026-09-22/) retain the +scope of each run. Dedicated multiplayer, adverse network conditions, all faction +loadouts, spent rocket-pod reload and a complete cargo return/service/second-trip +cycle still need live acceptance. Airport stocks are finite service budgets, not +individual fuel-can/round entities. The current compromised-site response dispatches +infantry defenders; it does not construct new physical roadblocks. diff --git a/docs/migration-provenance.json b/docs/migration-provenance.json index f9acf26..1190d3b 100644 --- a/docs/migration-provenance.json +++ b/docs/migration-provenance.json @@ -123,7 +123,7 @@ "source": "escapists-intel/Scripts/Game/EscapistsIntel/EI_IntelService.c", "sha256": "0009786c543c2758dcd55d3d6936feca5641dd4c4b2ff820564a38815e4cca57", "destination": "addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelDiscoveryFeedback.c", - "changes": "Adapt team discovery notification behavior using successor location events, bounded coalescing and rough-location secrecy." + "changes": "Restore original partial, identified and exhausted-intel wording; adapt site-type nouns and retain successor reliable delivery, bounded coalescing and rough-location secrecy." }, { "source": "escapists-intel/Scripts/Game/EscapistsIntel/EI_IntelLifecycle.c", @@ -154,5 +154,65 @@ "sha256": "9fb9a76ccaaff2a3c85cb6112ef5f6d272b3570bd4216d81c723c0f0c62cef84", "destination": "addons/core/UI/layouts/ERR/AdminMenu.layout", "changes": "Author successor settings/HQ/journal pages with vanilla widget inheritance and new resource identity; use owned menu layout as design reference." + }, + { + "source": "escapists-ai/Scripts/Game/EscapistsAI/EAI_ImprovisedPrisons.c", + "sha256": "e1b7dd3b7b45bc803e61e2031030d873101947def8b6a41fe450555e90d000b9", + "destination": "addons/core/Scripts/Game/ERR/00_Core/ERR_GeneratedSites.c", + "changes": "Adapt owned clear-ground terrain sampling, rotated trace volumes, road/airfield exclusion to asynchronous bounded successor site generation. No original mission dependencies; successor prison and vanilla radio camp." + }, + { + "source": "escapists-ai/Scripts/Game/EscapistsAI/EAI_AirRecon.c", + "sha256": "7e72c9eac0fce21865f09a923cbfd638c25939c8492b3e1e5dfdda1f1394f51e", + "destination": "addons/core/Scripts/Game/ERR/00_Core/ERR_GeneratedSites.c", + "changes": "Use owned physical runway/control-tower prefab classification for prison exclusion; no original reconnaissance service or dependency." + }, + { + "source": "escapists-ai/Scripts/Game/EscapistsAI/EAI_AirRecon.c", + "sha256": "7e72c9eac0fce21865f09a923cbfd638c25939c8492b3e1e5dfdda1f1394f51e", + "destination": "addons/core/Scripts/Game/ERR/10_AI/ERR_ReconAirports.c", + "changes": "Adapt incremental airport clustering, runway classification and clear departure-pad survey into the successor HQ-owned service." + }, + { + "source": "escapists-ai/Scripts/Game/EscapistsAI/EAI_AirRecon.c", + "sha256": "7e72c9eac0fce21865f09a923cbfd638c25939c8492b3e1e5dfdda1f1394f51e", + "destination": "addons/core/Scripts/Game/ERR/10_AI/ERR_AirReconSystem.c", + "changes": "Adapt original initial/loss cadence, one active aircraft and retained wrecks to mission escape phase, settings, ownership and finite force ledger." + }, + { + "source": "escapists-ai/Scripts/Game/EscapistsAI/EAI_AirRecon.c", + "sha256": "7e72c9eac0fce21865f09a923cbfd638c25939c8492b3e1e5dfdda1f1394f51e", + "destination": "addons/core/Scripts/Game/ERR/10_AI/ERR_ReconFlightComponent.c", + "changes": "Adapt airport takeoff, terrain-following, search/orbit, lost-contact search, return and player release; successor native clear perception and physical HQ radio pipeline. Excludes assault/rearm/recovery." + }, + { + "source": "escapists-base/Scripts/Game/Components/Heli/ESCT_HeliBehaviourBaseComponent.c", + "sha256": "d363edafe59162ebbde5d84d17c9786cb60ef83ebc3e5003bfc812ab48dd0812", + "destination": "addons/core/Scripts/Game/ERR/10_AI/ERR_ReconFlightComponent.c", + "changes": "Adapt physics velocity/attitude, engine and crew-seating setup to current native APIs and unique successor resources." + }, + { + "source": "escapists-base/Scripts/Game/AI/Behavior/ESCT_AIVehicleBehavior.c", + "sha256": "32c83fefde82955670f237ed4c83a3606c3af2a97d5c6f882d15bc6477cd8bf9", + "destination": "addons/core/Scripts/Game/ERR/10_AI/ERR_ReconCrewLocks.c", + "changes": "Adapt native get-out behavior suppression to exact original flight crew and active flight phase; release on landing, loss, takeover or mission stop." + }, + { + "source": "escapists-ai/Scripts/Game/EscapistsAI/EAI_AirAssault.c", + "sha256": "a9c0c2bf59fb4f9b351913708cee54a302a9e447d30c2766faa91c7573af1b6d", + "destination": "addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultLoad.c", + "changes": "Port original physical boarding, canopy transfer/descent or aircraft weapon service; integrate finite HQ-owned support and selected factions." + }, + { + "source": "escapists-ai/Scripts/Game/EscapistsAI/EAI_AssaultParachute.c", + "sha256": "b2d2be240d184c436a5b2fd1a03d446763a1c27b6b5c825a988b05feaec7a346", + "destination": "addons/core/Scripts/Game/ERR/10_AI/ERR_AssaultParachute.c", + "changes": "Port original physical boarding, canopy transfer/descent or aircraft weapon service; integrate finite HQ-owned support and selected factions." + }, + { + "source": "escapists-ai/Scripts/Game/EscapistsAI/EAI_AircraftAmmo.c", + "sha256": "52bed2b0008762f221bed4f139cfe0a34e69e0ee455ef3dc47276d3dc3373c45", + "destination": "addons/core/Scripts/Game/ERR/10_AI/ERR_AircraftAmmo.c", + "changes": "Port original physical boarding, canopy transfer/descent or aircraft weapon service; integrate finite HQ-owned support and selected factions." } ] diff --git a/docs/mortar-intel-and-prison-map.md b/docs/mortar-intel-and-prison-map.md new file mode 100644 index 0000000..d41dc53 --- /dev/null +++ b/docs/mortar-intel-and-prison-map.md @@ -0,0 +1,68 @@ +# Mortar intel, prison visibility and perimeter guards + +The live Everon configuration contains four mortar sites (`everon-mortar-1` through +`everon-mortar-4`). Each is placed on surveyed clear, dry terrain, at least 1 km +from another mortar and 500 m from the prison. They use the existing physical gun, +four-person crew, finite initial ammunition, HQ targeting and permanent station +assignment. The gun's destruction still releases its surviving crew. + +Each site registers a normal replicated discovery location. Untargeted recovered +intel can select any undiscovered or partly discovered mortar site. A partial +clue displays the native `mark-question` icon at the centre of its 500 m map +square, with an unknown-location label. Identification changes that same marker +to `artilleryFireMission`, moves it to the actual position, and names the site. +The prison never displays a discovery marker, even when its knowledge is complete. + +The 3–5 opening prison guards are placed once, at separate random positions +12–24 m from the prison centre. Placement samples angular sectors with jitter, +checks supported dry terrain and standing clearance, and reserves all positions +before moving any guard. They remain frozen until the gate is unlocked. The +existing physical manpack-radio requirement is unchanged. + +The ammunition courier previously required exactly one battery. It now accepts +multiple batteries and selects a depleted recipient between trips. A pending +report, command or native inventory move retains its original target. The +existing outpost still has one finite reserve of 12 shells, shared by all four +batteries; adding batteries does not duplicate that stock or courier group. + +## Original intel messages + +Wording is taken from the owner's +`escapists-intel/Scripts/Game/EscapistsIntel/EI_IntelService.c`: + +- `New location marked. Type unknown.` +- `Hey! A [type] was identified in the intel!` +- `Every known location has already been identified. This intel doesn't help us.` + +Successor site types supply the appropriate noun, including `mortar site`. +Notifications retain reliable delivery and per-round duplicate protection. +Multiple discoveries in one scan may be combined into a multiline message; +rough clues never disclose the location type. A spent folder cannot give another +clue. Separate untargeted folders can concern separate locations, so two pickups +may produce two question marks rather than one fully identified site. + +## Validation + +The full repository checks passed after the mortar/courier changes. A further +production-method regression covers selection among four batteries, unavailable +recipients and target preservation during pending work. The Workbench run in +`/tmp/erri-mortar-map4/logs/logs_2026-09-22_16-11-24` passed 30 assertions, admitted +the player, checked frozen perimeter guards and their radio, unlocked the prison, +and recovered seven physical folders to identify all four mortar sites. It +completed with confirmed cleanup and unchanged source/dependency inputs. + +That run preceded the final restoration of question marks and original messages; +its additional validation is recorded below. Native reports still include the +existing RHS/WCS localization and stock UI/resource errors. These are local +Workbench tests, not dedicated multiplayer acceptance. No server deployment or +Workshop publication was performed. + +The final question-mark/message run, +`/tmp/erri-mortar-map5/logs/logs_2026-09-22_16-19-19`, completed 35 assertions with +no script errors or VM exceptions. It verified an approximate question marker +before recovering physical folders and checking the four final artillery icons. +The immediate runner cleanup check encountered an exiting process and reported +cleanup failure; a subsequent host-visible `assert_prefix_idle` confirmed the +private prefix idle. Source and external-addon inputs remained unchanged. +Production-method tests also check the original message strings, mixed discovery +batches, reliable-delivery deduplication, and exhausted-intel feedback. diff --git a/docs/mortar-stations.md b/docs/mortar-stations.md new file mode 100644 index 0000000..5ca63d6 --- /dev/null +++ b/docs/mortar-stations.md @@ -0,0 +1,46 @@ +# Permanent mortar crew assignments + +Each of the four mortar sites has a four-person crew assigned to its physical gun until +that gun is destroyed or removed. This is separate from the lifetime of an HQ +fire order: target expiry, cancellation, completed salvos, and ammunition +depletion do not release the station. + +A gunner walks to and boards the original mortar using native AI. While the gun +is intact, native AI dismount behaviors are ineligible for its own crew. The +other crew members hold their positions while retaining aiming, firing, radio, +inventory and damage behavior. If the gunner is lost, a surviving ammunition +bearer can approach and take the empty gunner position. No crew or ammunition is +created to replace losses, and occupied seats are never forcibly cleared. + +Gun destruction releases movement restrictions and the HQ support reservation, +allowing surviving crew to leave and receive ordinary assignments. Mission stop +also cleans up the station controls. Player-controlled characters, unrelated +groups, other mortars and vehicles are unaffected. The depot courier remains a +separate group and can still deliver the existing finite ammunition stock. + +`tests/test_mortar_station.py` covers authority, ownership, player exclusion and +restriction lifetime. `--project core --smoke mortar-station` exercises native +idle boarding, competing dismount/movement orders, and release after damage +destroys the original mortar. Local fixtures do not establish multiplayer +replication or ordinary player-input acceptance. + +On 2026-09-22, the station fixture passed all seven native checks: idle boarding, +refusal of a high-priority dismount, retention after fire-order cancellation, +all three support members resisting movement orders, and actual movement after +gun destruction. The runner retained its launch source snapshot. Its initial +cleanup check raced an exiting process; an independent post-run check then +confirmed that the disposable prefix was idle. + +The existing `mortar-fire` fixture also completed with the station rule enabled: +an actual radio uplink/downlink led to a native projectile, consumed exactly one +of the 27 original shells, and stopped without duplicate firing or replenishment. +Its setup now unlocks the prison before waiting for combat AI. The full repository +check suite and targeted station/activity tests passed. Native resource warnings +from external addons remain; neither fixture is a dedicated-server acceptance +test. + +The firing run passed 51 assertions with no script errors. Its independent +post-cleanup check confirmed both an idle prefix and source hashes unchanged +from launch; the original runner's cleanup-race report is preserved. + +The sites are now discoverable through intel; see [mortar intel and map behavior](mortar-intel-and-prison-map.md). diff --git a/docs/prison-patrol-and-debug-intel.md b/docs/prison-patrol-and-debug-intel.md new file mode 100644 index 0000000..67837a1 --- /dev/null +++ b/docs/prison-patrol-and-debug-intel.md @@ -0,0 +1,45 @@ +# Prison opening and intel debug command + +The prison receives a finite 3–5-person squad drawn from the selected enemy +faction's group profile. Selection happens before the native member spawn request. +An RTO slot is preserved where available, and preparation confirms an actual +large radio in the squad's inventory. For profiles without one, a soldier equips +an AN/PRC-77 (US/USAF) or R-107M; the native loadout operation can replace an +existing backpack. No further radio grants occur after initial preparation. +The opening squad is placed once at separate random clear positions 12–24 m +around the prison, while still frozen. Other sites retain their authored squad sizes. Existing prison AI freeze/release +hooks still apply. + +Administrators can use `/esc cheat intel` from their current living prisoner. +The server checks six separate nearby ground positions, then creates six normal +intel folders. If placement or creation fails, no partial batch is retained. +The command does not insert anything directly into inventory or reveal locations; +picking up a folder invokes the ordinary discovery rules. Seated characters and +positions without enough accessible ground are refused. + +## Verification, 22 September 2026 + +`python tools/check.py` passed after the production changes. The admin command +regression includes authorized dispatch, unauthorized refusal, a placement-failure +reply, and no inventory-grant side effects. + +The `opening-tools` Core Workbench fixture completed with 22 assertions and no +script/runtime failures in: +`/tmp/erri-opening-tools2/logs/logs_2026-09-22_15-15-11`. +It exercises roster sizes 3, 4 and 5; physical squad size; frozen living guards; +a physically equipped manpack; native fallback equipment on a non-RTO; unauthorized +command refusal; six fresh ground folders; actual pickup and normal discovery; +and normal prison unlock. Source was unchanged throughout the run, dependencies +were unchanged, and the runner confirmed cleanup. This is local Workbench +integration, not a dedicated multiplayer test. + +The strict native reporter still returns failure for existing RHS/WCS localization +and world `Parent`/`SlidingTrackMaterial` resource errors. The same errors are in +`/tmp/erri-mortar-station-fire2` from before these changes. They were not suppressed. + +The live RKS #2 startup log for 22 September at 13:00 UTC records its extraction +Mi-8 alive, grounded, stable and fueled around coordinates 7012/7500. The local +fixture also confirms that aircraft is initially usable. Those authored coordinates were off the airfield. The follow-up change moves +the extraction helicopter to a validated airfield pad and rejects obstructed or +off-airfield placement; see [airfield and map changes](airfield-extraction-and-map-icons.md). +The earlier log establishes initial aircraft condition, not the later crash sequence. diff --git a/docs/radio-camp-security.md b/docs/radio-camp-security.md new file mode 100644 index 0000000..8492028 --- /dev/null +++ b/docs/radio-camp-security.md @@ -0,0 +1,40 @@ +# Radio shelter and permanent guards + +Everon's generated radio camp places the radio and its table inside a full-size +vanilla Soviet tent. The tent is offset 1.5 m sideways from the radio/table centre +to leave room around the interior supports. The antenna and sandbags sit outside +the shelter. Placement retains the existing dry, clear terrain survey. + +Every radio definition must request a physical guard squad (`Guarded 1` or an +explicit guard prefab). Scenario preparation already waits for all requested +guards to be present, conscious and in the configured enemy faction. The radio +squad receives an indefinite native Defend waypoint centred on the radio, with a +20 m radius, instead of defending its offset spawn anchor. + +When HQ registers a radio squad, it permanently reserves it for site security. +The existing eligibility gate then rejects investigation, pursuit, reinforcement, +resupply and courier assignments, including orders revalidated at delivery time. +The squad remains registered for physical status and contact reporting. The +mortars' release path cannot release a radio squad, because it requires ownership +of an actual destroyed mortar. Local combat and ordinary casualty behavior remain +native; this does not freeze the defenders after the prison opens or replace +casualties. + +## Verification + +The registration regression executes the actual HQ registration method and checks +that radio squads remain reserved through repeated registration, while ordinary +outposts retain their existing eligibility. The full repository suite passed. +The Workbench fixture additionally checks a physical tent roof over the radio, +standing room within interaction distance and line of sight, the complete living +squad, refusal of courier reservations, preservation against an unrelated mortar +release, and the centred indefinite defend waypoint. + +On 22 September 2026 the native run in +`/tmp/erri-radio-tent3/logs/logs_2026-09-22_16-42-16` completed 46 gameplay +assertions without script errors or VM exceptions. Source and external addon +inputs were unchanged, and process cleanup was confirmed. The fixture also +repeated player admission, prison guards, four-mortar intel recovery and map +presentation checks. Existing vanilla/RHS/WCS resource errors remain in the +strict report; this is local Workbench integration, not dedicated multiplayer +acceptance. No server deployment or Workshop publication was performed. diff --git a/docs/search-helicopter.md b/docs/search-helicopter.md new file mode 100644 index 0000000..e7e3680 --- /dev/null +++ b/docs/search-helicopter.md @@ -0,0 +1,95 @@ +# Search helicopter + +The combined mission now owns an airport reconnaissance service adapted from the +original `EAI_AirRecon` and `ESCT_HeliBehaviourBaseComponent`. It runs on the server +through VirtualHQ's maintenance tick. The original projects are not runtime +dependencies. + +Prison escape starts a 15-minute countdown. A single armed Mi-8 then departs from +a clear pad at a discovered airfield. Losing that aircraft or its usable original +pilot starts a fresh 15-minute replacement delay. Parking or player takeover does +not create a replacement. Wrecks and surviving original crew remain in the world; +there is no cleanup-driven instant replacement. Explicit native aircraft deletion +also starts the loss delay; mission teardown does not restart the service. The round retains at most 64 +attempted aircraft and uses the shared finite force ledger. + +Airfields are discovered incrementally from physical runway/control-tower prefab +clusters, with an optional validated `ERR_ReconAirport` marker. Pads are surveyed +35–220 m from an airfield centre, with a 14 m clear footprint, terrain tolerance, +water exclusion and a 150 m vertical clearance trace. Failed departure placement +gets three attempts 30 seconds apart; batches use the configured delay. + +The aircraft uses the original server physics flight approach: vertical takeoff, +40 m/s cruise, terrain look-ahead and 120 m terrain clearance. Its four native +pilot/gunner seats are populated before departure. The original crew is locked +aboard during flight and reserved against ground HQ reassignment. The engine's +native gunners retain their weapons and combat behavior. The current stock armed +Mi-8 replaces the old custom left-gun mount that is absent from the installed game. +US/RHS_USAF select the US pilot loadout; other enemy choices select the Soviet +loadout. Aircraft, group and crew all receive the selected enemy faction. + +Without a contact, it searches random dry island sectors or delivered HQ sighting +coordinates. Actual native clear visual sightings of players or their occupied +vehicles produce a 300 m shadow orbit, with an additional 180 m horizontal +standoff. Two observation snapshots estimate motion; no live hidden player +positions or velocities drive the search. After losing sight it searches around +the last observation for two minutes, then resumes sector searches. Crew reports +use the normal physical-radio call, transmission and delivery pipeline to HQ. +A missing or incapacitated radio carrier can interrupt that route. + +Low fuel (less than 50 native fuel units) or an administrator return request sends +the same aircraft back to its departure pad. It lands and stops its engines, retaining the surviving crew for airport servicing. +HQ-controlled passenger drops, post-drop cover, reloading and a separate ammunition +helicopter are described in [HQ air support](hq-air-support.md). A player boarding, taking the crew group or +changing the aircraft's faction releases scripted flight control. + +Administrator commands: + +- `/esc hq heli show` — phase, launch delay and pending countdown. +- `/esc hq heli timer set 15` — save the launch/replacement delay in minutes + (1–1440). Resets a pending countdown; an existing aircraft remains active. +- `/esc hq heli return` — return the current searching helicopter to its airfield. + +The persisted key is `heliTimerMinutes`, default 15. Older settings files acquire +that default; the original settings importer preserves the original key. Normal +configuration validation and save-failure handling apply. + +`tests/test_air_recon.py` executes the production launch/loss schedule and flight +geometry. `tools/headless_workbench.py --project core --smoke air-recon` is an +opt-in native fixture: it shortens the test countdown, arranges a runway encounter, +requests return, destroys the first aircraft and transfers its replacement to the +escapists faction. These +fixture actions do not change production timing or give the helicopter artificial +perception. A focused `--recon-takeover-only` variant exercises native faction handover, +control release and explicit native entity deletion without +repeating the full flight. + +The full repository suite passed in `/tmp/erri-recon-check5.log`. The final +removal-notification regression and affected native lifecycle/runner checks passed +afterward. The 22 September native run +`/tmp/erri-recon-native4/logs/logs_2026-09-22_17-39-45` passed actual airfield +launch, conscious original crew, native player detection, shadow orbit, radio +sighting delivery, more than 300 m of flight, same-aircraft landing, no replacement +while parked, destruction-triggered full delay and retained-wreck replacement. +An earlier run flew more than 650 m. Both full-flight runs ended at a failing +fixture boarding call; forced player boarding also failed in `/tmp/erri-recon-takeover1`. The accessible +seat was found and the owner-routed request accepted, but no actual occupancy +followed. Player entry and its control-release branch remain unverified; the +final focused fixture instead tests the separate native faction-handover branch. +These Workbench runs are not dedicated multiplayer acceptance. No Workshop +publication or server deployment is included in this change. + + +The final focused native run was +`/tmp/erri-recon-ownership-final/logs/logs_2026-09-22_17-54-34`. +All 13 recon assertions completed with zero script errors: native original crew, +airfield-only departure (including a refused pad and subsequent retry), actual +faction handover/control release, and explicit entity deletion starting one full +900-second replacement delay. The final addon files were separately compared to +the launch snapshot and matched. + +The strict runner result remains `cleanup-failed`, with `smoke-completed` recorded +before cleanup: an exiting `enfMain` had an empty command line. A subsequent +host-visible `assert_prefix_idle` passed. The preserved runner record has not been +relabeled as a clean strict run. Existing external localization/resource warnings +remain separate from the zero script-error count. diff --git a/docs/supply-convoys.md b/docs/supply-convoys.md new file mode 100644 index 0000000..065758b --- /dev/null +++ b/docs/supply-convoys.md @@ -0,0 +1,43 @@ +# Supply convoys + +The production HQ now uses `ERR_SupplyConvoySystem`, `ERR_SupplyConvoyService` and `ERR_SupplyConvoyTactics`, adapting the owned original `EAI_SupplyConvoys.c` to the combined mod's native AI and force ledger. + +## Configuration + +Esc → Mission settings → HQ → **Convoys next round** accepts **0–20** or **Random** (uniformly 0–20). The original default is **3**. The server resolves and freezes this count when the round is created. `/esc convoys show`, `/esc convoys set 3` and `/esc convoys set random` use the same authenticated command service. `/esc hq convoy count ...` is an alias. Changes do not create or delete an active convoy. + +The existing `automaticConvoyEnabled` switch remains supported. A saved zero continues to disable automatic startup. `/esc hq convoy start` can consume the next unused slot in the current round's configured allowance; it cannot replace a consumed slot. The settings importer preserves the original exact `convoyCount`, including zero, rather than reducing it to one convoy. + +## Vehicles, soldiers and cargo + +Each USSR convoy uses the original order: PKM UAZ, ammunition Ural, troop Ural, PKM UAZ, BMP-1, PKM UAZ. There are **25 original soldiers**: eleven vehicle crew and all fourteen Ural passenger seats, alternating between two independent seven-person infantry teams. Drivers and gunners stay in separate vehicle groups. The groups use the successor's memberless prefab, not the original framework's group-spawn hooks. + +One finite receipt reserves eight groups and 25 bodies before creation. Partial creation consumes that slot and preserves surviving entities. The production budget permits the existing 60-body allowance plus 25 bodies per configured convoy, within the shared 256-formation ledger. Losses never refund convoy slots. This is an admission ceiling, not a performance guarantee for twenty simultaneous convoys. + +The ammo truck receives eight random weapons from the convoy faction's native arsenal and six matching magazines/projectiles per muzzle of each successfully inserted weapon. The batch has a 96-ammunition-request ceiling and is issued once. Storage failures reduce the actual load; there is no refill after looting. Vehicle contents, casualties and surviving soldiers remain physical after controller release. The earlier infantry rendezvous service still uses the first convoy's compatible AK magazine stock; this change does not distribute that separate service across all trucks. + +## Travel and combat + +After prison escape and existing force admission, automatic deployment begins after a 30-second grace period. Every five seconds it tests up to eight road formations, for at most 120 startup passes. It uses friendly military outpost, depot and radio-site road approaches within 500 metres, deduplicated at 300 metres. Starting formations are separated by up to one kilometre and retain all existing road, terrain and obstruction checks. + +The initial road route feeds an ongoing circuit between different friendly bases. Arrival accepts surviving vehicles within 250 metres; a ten-minute travel deadline advances a stuck trip. Convoys pause 45–90 seconds before selecting their next destination. The original carried supplies between bases but did not unload or regenerate them; no artificial unloading is added here. + +Combat starts from fresh identified native group perception within 400 metres of a surviving convoy vehicle or a delivered HQ sighting. Mounted group origins are not used for this distance, because native groups can retain their original spawn position. Infantry dismounts, approaches the left and right flanks, then searches those sectors. Armed escorts provide cover while the ammo truck withdraws; the troop truck waits until the passengers have dismounted. Orders refresh at a bounded thirty-second interval. Still-mounted infantry receives at most six dismount retries, ten seconds apart, to recover from busy doors or a braking truck. Native boarding waypoints require all surviving members and use a 100-metre approach radius; actual compartment membership determines readiness. Crew retention suppresses both group and individual abandonment priorities only for the original mounted vehicle crew and releases on critical damage or drivetrain failure, allowing native emergency abandonment. + +After sixty seconds without a fresh contact, the teams reboard under escort cover. A new contact interrupts boarding. After a 120-second boarding deadline, stranded survivors are released and the remaining vehicles continue. A lost troop truck releases the infantry. Player capture stops convoy control permanently, preserving the actors and loot. + +## Validation + +`tests/test_supply_convoys.py` executes production startup, base selection, arrival, circuit timing, contact freshness and perception boundaries, scoped crew-abandonment priorities, combat quiet time, boarding interruption, timeout and truck-loss policies. The receipt tests cover all eight groups and configured fleets through twenty convoys, including no replacement after losses. Existing dispatch, native seat allocation, cargo, HQ radio and force-ledger regressions remain in the main check suite. + +`tools/headless_workbench.py --project chat --smoke supply-convoys --rounds 1 --timeout 900` exercises production automatic startup and physical AI in an isolated Workbench profile. Its final controlled contact explicitly tests native dismount/reboarding rather than claiming that an injected report proves perception. Remote-client replication and twenty-convoy performance require dedicated-server testing. Earlier one-convoy evidence in `convoy-migration.md` and `convoy-startup.md` remains historical evidence for those earlier sources. + +The legacy `convoy` and `hq-memory` Workbench smoke fixtures explicitly select their previous single-convoy controller under `WORKBENCH` and `errSmoke=1`, preserving those fixtures' existing assertions. Normal play, ordinary Workbench use, and the `supply-convoys` fixture use the full configured fleet. + +### Local native result — 2026-09-21 + +`/tmp/erri-supply-native10/logs/logs_2026-09-21_16-50-45` completed one `supply-convoys` smoke round with 36 common smoke assertions, no script/runtime failures, unchanged launch sources, and confirmed runner cleanup. The supply fixture additionally verified three automatically started convoys (18 vehicles, 75 original soldiers), exact ledger admission, 174 successful cargo insertions matched to physical inventory, all 18 vehicles driving at least 20 metres, both seven-person teams dismounting and reboarding, and the original troop truck driving the next base leg after the normal pause. The contact and expiry of the preceding trip deadline were explicitly injected test inputs; startup, native AI, seating, cargo, the pause and subsequent driving remained production-owned. RHS content was disabled in this isolated fixture, while its installed dependencies remained loaded. + +Launch source fingerprint: `380aec8a078825b165593fc4445b603f80b5d1ec51360c018d34973d65302b62`. Raw logs remain outside the repository. Strict engine qualification still fails on nine pre-existing dependency errors: four RHS/WCS localization GUID errors and five inherited vehicle-catalog keyword errors (`SlidingTrackMaterial`/`Parent`). This is a successful convoy smoke scenario, not a clean-engine or multiplayer qualification. + +`python3 tools/check.py` passed for this production source; the additional bounded-retry regression also passed afterward. Earlier native iterations exposed partial dismounts, premature healthy-crew abandonment and a crowded boarding approach; the final source contains the bounded exit retries, scoped group/individual priority guards and widened native boarding approach validated above. diff --git a/docs/vanilla-resources.json b/docs/vanilla-resources.json index cc162e6..6351e3f 100644 --- a/docs/vanilla-resources.json +++ b/docs/vanilla-resources.json @@ -1,126 +1,149 @@ { - "Prefabs/Structures/Walls/Concrete/ConcreteWall_01/ConcreteWall_01_3m.et": "60E5729BEA18F875", - "Prefabs/Structures/BuildingParts/Doors/Door_Metal_A_RIGHT_EXT_COV_B.et": "67F17696FF278AE0", - "Prefabs/Items/Equipment/Radios/Radio_R107M.et": "54C68E438DD34265", - "Prefabs/MP/Modes/GameMode_Base.et": "0F307326459A1395", - "worlds/Eden/Eden.ent": "853E92315D1D9EFE", - "Prefabs/AI/SCR_AIWorld.et": "E0A05C76552E7F58", - "Prefabs/World/Game/PerceptionManager.et": "028DAEAD63E056BE", - "Prefabs/MP/Managers/Factions/FactionManager_USxUSSR.et": "589540B35EBA8CDC", - "Prefabs/MP/Managers/Loadouts/LoadoutManager_USxUSSR.et": "97EF4C1A41F63DDB", - "Configs/Systems/MissionSystems.conf": "C50579A2EC48E8EF", - "Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_Unarmed.et": "2F912ED6E399FF47", - "Prefabs/Groups/OPFOR/Group_USSR_RifleSquad.et": "E552DABF3636C2AD", - "Prefabs/Vehicles/Helicopters/Mi8MT/Mi8MT_unarmed_transport.et": "DF5CCB7C0FF049F4", - "Prefabs/AI/Waypoints/AIWaypoint_SearchAndDestroy.et": "B3E7B8DC2BAB8ACC", - "Prefabs/AI/Waypoints/AIWaypoint_Defend.et": "93291E72AC23930F", - "Prefabs/Items/Misc/IntelligenceFolder_E_01/IntelligenceFolder_E_01.et": "6D56FED1E55A8F84", - "Configs/Map/MapPlain.conf": "A786DD4868598F15", - "Prefabs/Items/Core/Item_Base.et": "C95E11C60810F432", + "Assets/Items/Equipment/Maps/Map_Paper_01/Map_Paper_01_folded.xob": "E443663E5241622A", + "Assets/Items/Equipment/Radios/Radio_R107M/Radio_R107M_01_item.xob": "77C9FCF3FB1246E9", "Assets/Items/Equipment/workspaces/paper_map.agr": "83675D0B0AB1B2C3", "Assets/Items/Equipment/workspaces/player_map.asi": "56794534F6B8C3D7", - "Assets/Items/Equipment/Maps/Map_Paper_01/Map_Paper_01_folded.xob": "E443663E5241622A", - "Sounds/Items/_SharedData/PickUp/Items_PickUp_Map.acp": "13F8D0DE67678118", - "Prefabs/Weapons/Handguns/PM/Handgun_PM.et": "C0F7DD85A86B2900", - "Prefabs/Weapons/Magazines/Magazine_9x18_PM_8rnd_Ball.et": "8B853CDD11BA916E", - "Prefabs/Items/Equipment/Maps/Map_Paper_01/PaperMap_01_folded.et": "13772C903CB5E4F7", - "system/textures/defWhite.edds": "A64A8C63E0FBAE87", - "Configs/Navmesh/Navmesh_GM_Eden_Soldier.conf": "8BBC73472092902E", - "Configs/Navmesh/Navmesh_GM_Eden_Vehicle.conf": "0228A79846FD8A97", - "worlds/MP/Navmeshes/LowResEden.nmn": "5E19E60F26327356", - "Assets/Items/Equipment/Radios/Radio_R107M/Radio_R107M_01_item.xob": "77C9FCF3FB1246E9", - "Prefabs/Systems/Radio/RadioManager.et": "B8E09FAB91C4ECCD", - "Prefabs/MP/ScriptedChatEntity.et": "F69BC912AC8236F9", - "Prefabs/Structures/BuildingParts/Doors/Door_MetalGate_01_Short_LEFT_EXT_ST_B.et": "34B08027DBDE322A", - "Prefabs/Props/Furniture/Table_01.et": "53506DCB398C7EBE", - "Assets/Structures/Walls/Concrete/ConcreteWall_01_3m.xob": "0B96D139FEBCA006", - "Configs/GameOverScreen/BaseGameOverScreensConfig.conf": "651583FFFB7FC765", - "UI/layouts/HUD/GameOver/EndScreen/EndScreenContent_NoImage.layout": "C4507D07F3C54831", - "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_SL.et": "5436629450D8387A", - "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_RTO.et": "612F43A4D5AE765F", - "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_AR.et": "23ADBBC31B6A3DC6", - "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_AT.et": "1C78331E156A3D65", - "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_SR.et": "333DA6244C7DA34C", - "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_LAT.et": "BF643BE4ADBDFDD3", - "Prefabs/Weapons/Mortars/2B14/Mortar_2B14.et": "D1FFE458E8AC4BDB", - "Prefabs/Weapons/Ammo/Ammo_Shell_82mm_HE_O832DU.et": "98EC9C526AFBA282", - "Prefabs/Characters/Campaign/Final/OPFOR/USSR_Army/Regular/Campaign_USSR_Player_MortarCrew.et": "86E565387A634103", - "Prefabs/Items/Equipment/Radios/Radio_R148.et": "E1A5D4B878AA8980", - "Prefabs/Groups/OPFOR/Group_USSR_Base.et": "8DE0C0830FE0C33D", - "Prefabs/Tasks/BaseTask.et": "1D0F815858EE24AD", - "Prefabs/Props/EmptyStorages/USSR_AmmoBox_empty.et": "EBBC859CCD94D96D", - "Prefabs/Weapons/Rifles/AK74/Rifle_AK74.et": "FA5C25BF66A53DCF", - "Prefabs/Weapons/Magazines/Magazine_545x39_AK_30rnd_Ball.et": "BBB50A815A2F916B", - "Prefabs/Items/Medicine/FieldDressing_01/FieldDressing_USSR_01.et": "C3F1FA1E2EC2B345", - "Assets/Props/Military/AmmoBox/EquipmentBoxWooden_01/EquipmentBoxWooden_Weapon_01.xob": "819ED3266EE5113B", + "Assets/Props/Fabric/Flags/Data/Flag_1_2_SovietArmy.emat": "B47B52D9DA68905D", + "Assets/Props/Fabric/Flags/Data/Flag_1_2_USA.emat": "610803CC3F3D6649", "Assets/Props/Military/AmmoBox/EquipmentBoxWooden_01/Data/EquipmentBoxWooden_Weapon_01_USSR.emat": "7D2E831AFA5CF50D", + "Assets/Props/Military/AmmoBox/EquipmentBoxWooden_01/EquipmentBoxWooden_Weapon_01.xob": "819ED3266EE5113B", "Assets/Props/Military/Sandbags/Data/Sandbags_01_Burlap_MLOD.emat": "2169E8EB73E1C127", "Assets/Props/Military/Sandbags/Data/Sandbags_01_short_Burlap.emat": "31529E064AA7C917", "Assets/Props/Military/Sandbags/Sandbag_01_short.xob": "268DBDABB3C94AD7", - "Prefabs/Items/Equipment/Binoculars/Binoculars_B8/Binoculars_B8.et": "243948B23D90BECB", - "Prefabs/Items/Medicine/MorphineInjection_01/MorphineInjection_01.et": "0D9A5DCF89AE7AA9", - "Prefabs/Items/Medicine/SalineBag_01/SalineBag_USSR_01.et": "527D7C5D2E476BDC", - "Prefabs/Vehicles/Wheeled/UAZ469/UAZ469.et": "259EE7B78C51B624", - "UI/layouts/Task/TaskListEntry.layout": "B71975032150BAFB", - "UI/layouts/Task/TaskListEntryDescription.layout": "552EB1921B5C548C", - "Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_Base.et": "836E7E39AAC5888B", - "Prefabs/Items/Equipment/Watches/Watch_SandY184A.et": "78ED4FEF62BBA728", - "Prefabs/Characters/HeadGear/Hat_Patrol_US_01/Hat_Patrol_US_01.et": "F545C1BE7C99954A", - "Prefabs/Characters/Uniforms/Jacket_US_BDU_rolledup.et": "3CCA7A9BB4FD3197", - "Prefabs/Characters/Uniforms/Pants_US_BDU.et": "604BB72BE8E023C2", - "Prefabs/Characters/Footwear/CombatBoots_US_01.et": "DAAFD15478BDE1C3", - "Prefabs/Items/Medicine/FieldDressing_01/FieldDressing_US_01.et": "A81F501D3EF6F38E", - "Prefabs/Items/Medicine/Tourniquet_01/Tourniquet_US_01.et": "D70216B1B2889129", - "Configs/Factions/BLUFOR.conf": "E7B7EA4823DD81F0", - "UI/Textures/Editor/EditableEntities/Factions/EditableEntity_Faction_USA.edds": "EB7F65DBC9392557", - "Configs/Identities/FactionIdentity_US.conf": "B600946E9ACC17C2", - "UI/Textures/Flags/US-flag-base-3it.edds": "46FB2FB2C2125A3C", - "Assets/Props/Fabric/Flags/Data/Flag_1_2_USA.emat": "610803CC3F3D6649", + "Assets/Structures/BuildingsParts/Doors/DoorFrame_door_metal_A_RIGHT_EXT_B.xob": "E3BC73BFBC7AA497", + "Assets/Structures/Walls/Concrete/ConcreteWall_01_3m.xob": "0B96D139FEBCA006", "Configs/Callsigns/Callsigns_US.conf": "CB7824D572DD0D5C", + "Configs/Callsigns/Callsigns_USSR.conf": "958043CE1648EC43", "Configs/EntityCatalog/US/Characters_EntityCatalog_US.conf": "B56E8B4D4B195559", "Configs/EntityCatalog/US/Groups_EntityCatalog_US.conf": "4E70E7C4C2C17D4A", - "Configs/EntityCatalog/US/WeaponTripod_EntityCatalog_US.conf": "0E4B62F186F97C43", "Configs/EntityCatalog/US/InventoryItems_EntityCatalog_US.conf": "5F7EC52FC40A03E2", "Configs/EntityCatalog/US/SupplyContainerItems_EntityCatalog_US.conf": "0BF36EFF9FFAF92B", - "Configs/Identities/Territories/FactionHomeTerritory_US.conf": "B548A5B264939A98", - "Configs/Factions/OPFOR.conf": "B122E3B10BB3404B", - "UI/Textures/Editor/EditableEntities/Factions/EditableEntity_Faction_USSR.edds": "40B12B0DF911B856", - "Configs/Identities/FactionIdentity_USSR.conf": "FBBCD73926989580", - "UI/Textures/Flags/USSR-flag-base-3it.edds": "50824EB9F2C215AB", - "Assets/Props/Fabric/Flags/Data/Flag_1_2_SovietArmy.emat": "B47B52D9DA68905D", - "Configs/Callsigns/Callsigns_USSR.conf": "958043CE1648EC43", + "Configs/EntityCatalog/US/WeaponTripod_EntityCatalog_US.conf": "0E4B62F186F97C43", "Configs/EntityCatalog/USSR/Characters_EntityCatalog_USSR.conf": "3B679F1799D55F6F", "Configs/EntityCatalog/USSR/Groups_EntityCatalog_USSR.conf": "37D5B75AAC3E72EA", - "Configs/EntityCatalog/USSR/WeaponTripod_EntityCatalog_USSR.conf": "DDF17C8C140BD1F2", "Configs/EntityCatalog/USSR/InventoryItems_EntityCatalog_USSR.conf": "C53421647C3D0D2E", "Configs/EntityCatalog/USSR/SupplyContainerItems_EntityCatalog_USSR.conf": "B4CA6761AC935808", + "Configs/EntityCatalog/USSR/WeaponTripod_EntityCatalog_USSR.conf": "DDF17C8C140BD1F2", + "Configs/Factions/BLUFOR.conf": "E7B7EA4823DD81F0", + "Configs/Factions/FIA.conf": "8053DB656DD18B14", + "Configs/Factions/OPFOR.conf": "B122E3B10BB3404B", + "Configs/GameOverScreen/BaseGameOverScreensConfig.conf": "651583FFFB7FC765", + "Configs/Identities/FactionIdentity_US.conf": "B600946E9ACC17C2", + "Configs/Identities/FactionIdentity_USSR.conf": "FBBCD73926989580", + "Configs/Identities/Territories/FactionHomeTerritory_US.conf": "B548A5B264939A98", "Configs/Identities/Territories/FactionHomeTerritory_USSR.conf": "31FC78AF00523A99", - "Prefabs/MP/Managers/Factions/FactionManager_Base.et": "A242612865F2A48E", - "UI/Textures/DeployMenu/Loadout-BG/Loadout_US_BG.imageset": "F8AC305446D5D480", - "UI/Textures/DeployMenu/Loadout-BG/Loadout_BG_USSR.imageset": "21533F2089005056", - "Prefabs/Items/Medicine/SalineBag_01/SalineBag_US_01.et": "00E36F41CA310E2A", - "Prefabs/Items/Equipment/Radios/Radio_ANPRC77.et": "9B6B61BB3FE3DFB0", - "Prefabs/AI/Waypoints/AIWaypoint_Move.et": "750A8D1695BD6998", + "Configs/Map/MapPlain.conf": "A786DD4868598F15", + "Configs/Navmesh/Navmesh_GM_Eden_Soldier.conf": "8BBC73472092902E", + "Configs/Navmesh/Navmesh_GM_Eden_Vehicle.conf": "0228A79846FD8A97", + "Configs/System/chimeraMenus.conf": "C747AFB6B750CE9A", + "Configs/Systems/MissionSystems.conf": "C50579A2EC48E8EF", + "Prefabs/AI/Groups/Group_Base.et": "000CD338713F2B5A", + "Prefabs/AI/SCR_AIWorld.et": "E0A05C76552E7F58", "Prefabs/AI/Waypoints/AIWaypoint_Cycle.et": "35BD6541CBB8AC08", + "Prefabs/AI/Waypoints/AIWaypoint_Defend.et": "93291E72AC23930F", + "Prefabs/AI/Waypoints/AIWaypoint_GetIn.et": "712F4795CF8B91C7", + "Prefabs/AI/Waypoints/AIWaypoint_GetOut.et": "C40316EE26846CAB", + "Prefabs/AI/Waypoints/AIWaypoint_Move.et": "750A8D1695BD6998", + "Prefabs/AI/Waypoints/AIWaypoint_SearchAndDestroy.et": "B3E7B8DC2BAB8ACC", + "Prefabs/Characters/Campaign/Final/OPFOR/USSR_Army/Regular/Campaign_USSR_Player_MortarCrew.et": "86E565387A634103", + "Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_Base.et": "836E7E39AAC5888B", + "Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_HeliPilot.et": "42A502E3BB727CEB", + "Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_Sniper.et": "0F6689B491641155", + "Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_Unarmed.et": "2F912ED6E399FF47", + "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_AR.et": "23ADBBC31B6A3DC6", + "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_AT.et": "1C78331E156A3D65", + "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_HeliPilot.et": "A62FA97C4EC64F14", + "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_LAT.et": "BF643BE4ADBDFDD3", + "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_RTO.et": "612F43A4D5AE765F", + "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_Rifleman.et": "DCB41B3746FDD1BE", + "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_SL.et": "5436629450D8387A", + "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_SR.et": "333DA6244C7DA34C", + "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_Sharpshooter.et": "976AC400219898FA", + "Prefabs/Characters/Footwear/CombatBoots_US_01.et": "DAAFD15478BDE1C3", + "Prefabs/Characters/HeadGear/Hat_Patrol_US_01/Hat_Patrol_US_01.et": "F545C1BE7C99954A", + "Prefabs/Characters/HeadGear/Helmet_PASGT_01/Helmet_PASGT_01_base.et": "8A2B2A544F3ED831", + "Prefabs/Characters/Uniforms/Jacket_US_BDU_rolledup.et": "3CCA7A9BB4FD3197", + "Prefabs/Characters/Uniforms/Pants_US_BDU.et": "604BB72BE8E023C2", + "Prefabs/Decals/Dirt/Decal_Dirt_Patch_03.et": "4AD6EB80A8B1FA3A", + "Prefabs/Groups/BLUFOR/Group_US_FireTeam.et": "84E5BBAB25EA23E5", + "Prefabs/Groups/INDFOR/Group_FIA_FireTeam.et": "5BEA04939D148B1D", + "Prefabs/Groups/OPFOR/Group_USSR_Base.et": "8DE0C0830FE0C33D", + "Prefabs/Groups/OPFOR/Group_USSR_RifleSquad.et": "E552DABF3636C2AD", + "Prefabs/Items/Core/Item_Base.et": "C95E11C60810F432", + "Prefabs/Items/Equipment/Backpacks/Backpack_Kolobok.et": "41A9C55B61F375F0", + "Prefabs/Items/Equipment/Binoculars/Binoculars_B8/Binoculars_B8.et": "243948B23D90BECB", "Prefabs/Items/Equipment/Kits/MedicalKit_01/MedicalKit_01_US.et": "AE578EEA4244D41F", "Prefabs/Items/Equipment/Kits/MedicalKit_01/MedicalKit_01_USSR.et": "21EF98BFC1EB3793", - "Prefabs/AI/Groups/Group_Base.et": "000CD338713F2B5A", - "Prefabs/Decals/Dirt/Decal_Dirt_Patch_03.et": "4AD6EB80A8B1FA3A", - "UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout": "75C912A1C89BE6C2", - "UI/layouts/WidgetLibrary/EditBox/WLib_EditBox.layout": "0022F0B45ADBC5AC", - "UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt": "3E7733BAC8C831F6", - "Prefabs/Characters/HeadGear/Helmet_PASGT_01/Helmet_PASGT_01_base.et": "8A2B2A544F3ED831", - "Configs/System/chimeraMenus.conf": "C747AFB6B750CE9A", + "Prefabs/Items/Equipment/Kits/RepairKit_01/RepairKit_01_wrench.et": "33B2DFDCD0EBA3DB", + "Prefabs/Items/Equipment/Maps/Map_Paper_01/PaperMap_01_folded.et": "13772C903CB5E4F7", + "Prefabs/Items/Equipment/Radios/Radio_ANPRC77.et": "9B6B61BB3FE3DFB0", + "Prefabs/Items/Equipment/Radios/Radio_R107M.et": "54C68E438DD34265", + "Prefabs/Items/Equipment/Radios/Radio_R148.et": "E1A5D4B878AA8980", + "Prefabs/Items/Equipment/Watches/Watch_SandY184A.et": "78ED4FEF62BBA728", + "Prefabs/Items/Fuel/Jerrycan_01/Jerrycan_01_item.et": "12D5AD21E383B768", + "Prefabs/Items/Medicine/FieldDressing_01/FieldDressing_USSR_01.et": "C3F1FA1E2EC2B345", + "Prefabs/Items/Medicine/FieldDressing_01/FieldDressing_US_01.et": "A81F501D3EF6F38E", + "Prefabs/Items/Medicine/MorphineInjection_01/MorphineInjection_01.et": "0D9A5DCF89AE7AA9", + "Prefabs/Items/Medicine/SalineBag_01/SalineBag_USSR_01.et": "527D7C5D2E476BDC", + "Prefabs/Items/Medicine/SalineBag_01/SalineBag_US_01.et": "00E36F41CA310E2A", + "Prefabs/Items/Medicine/Tourniquet_01/Tourniquet_US_01.et": "D70216B1B2889129", + "Prefabs/Items/Misc/IntelligenceFolder_E_01/IntelligenceFolder_E_01.et": "6D56FED1E55A8F84", + "Prefabs/MP/Managers/Factions/FactionManager_Base.et": "A242612865F2A48E", + "Prefabs/MP/Managers/Factions/FactionManager_USxUSSR.et": "589540B35EBA8CDC", + "Prefabs/MP/Managers/Loadouts/LoadoutManager_USxUSSR.et": "97EF4C1A41F63DDB", + "Prefabs/MP/Modes/GameMode_Base.et": "0F307326459A1395", + "Prefabs/MP/ScriptedChatEntity.et": "F69BC912AC8236F9", + "Prefabs/Props/EmptyStorages/USSR_AmmoBox_empty.et": "EBBC859CCD94D96D", + "Prefabs/Props/Furniture/Table_01.et": "53506DCB398C7EBE", + "Prefabs/Props/Military/Antennas/Antenna_FIA_02/Antenna_FIA_02_base.et": "B3949F6D215A9F78", + "Prefabs/Props/Military/Sandbags/Sandbag_01_short_burlap.et": "CFFEB23FBEA00322", + "Prefabs/Structures/BuildingParts/Doors/DoorSet_Base.et": "87CADB5ADC989993", + "Prefabs/Structures/BuildingParts/Doors/Door_MetalGate_01_Short_LEFT_EXT_ST_B.et": "34B08027DBDE322A", + "Prefabs/Structures/BuildingParts/Doors/Door_Metal_A_RIGHT_EXT_COV_B.et": "67F17696FF278AE0", + "Prefabs/Structures/Military/Camps/TentSmallUS_01/TentSmallUS_01.et": "01AE5FD77A9A4C21", + "Prefabs/Structures/Military/Camps/TentUSSR_01/TentUSSR_01_base.et": "BE5D5CFA90224646", + "Prefabs/Structures/Walls/Concrete/ConcreteWall_01/ConcreteWall_01_3m.et": "60E5729BEA18F875", + "Prefabs/Systems/Radio/RadioManager.et": "B8E09FAB91C4ECCD", + "Prefabs/Tasks/BaseTask.et": "1D0F815858EE24AD", + "Prefabs/Vehicles/Helicopters/Mi8MT/Mi8MT_armed.et": "7BD282AF716ED639", + "Prefabs/Vehicles/Helicopters/Mi8MT/Mi8MT_unarmed_transport.et": "DF5CCB7C0FF049F4", + "Prefabs/Vehicles/Wheeled/UAZ469/UAZ469.et": "259EE7B78C51B624", + "Prefabs/Vehicles/Wheeled/UAZ469/UAZ469_PKM.et": "0B4DEA8078B78A9B", + "Prefabs/Vehicles/Wheeled/UAZ469/VehParts/Seats/UAZ469_seats_rear.et": "780AAF0A51AEE61A", "Prefabs/Vehicles/Wheeled/Ural4320/Ural4320_ammo.et": "3336BE330C4F355B", "Prefabs/Vehicles/Wheeled/Ural4320/Ural4320_transport.et": "16C1F16C9B053801", - "Prefabs/Vehicles/Wheeled/UAZ469/UAZ469_PKM.et": "0B4DEA8078B78A9B", - "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_Rifleman.et": "DCB41B3746FDD1BE", - "Prefabs/Weapons/Attachments/Optics/Optic_PSO1/Optic_PSO1.et": "C850A33226B8F9C1", - "Prefabs/Weapons/Attachments/Optics/Optic_ARTII/Optic_ARTII.et": "D2018EDB1BBF4C88", + "Prefabs/Weapons/Ammo/Ammo_Shell_82mm_HE_O832DU.et": "98EC9C526AFBA282", "Prefabs/Weapons/Attachments/Optics/Optic_4x20/Optic_4x20.et": "BD496EE1B40DC510", - "Prefabs/Vehicles/Wheeled/UAZ469/VehParts/Seats/UAZ469_seats_rear.et": "780AAF0A51AEE61A", - "Prefabs/Items/Equipment/Kits/RepairKit_01/RepairKit_01_wrench.et": "33B2DFDCD0EBA3DB", - "Prefabs/Items/Fuel/Jerrycan_01/Jerrycan_01_item.et": "12D5AD21E383B768", - "Prefabs/Structures/BuildingParts/Doors/DoorSet_Base.et": "87CADB5ADC989993", - "Assets/Structures/BuildingsParts/Doors/DoorFrame_door_metal_A_RIGHT_EXT_B.xob": "E3BC73BFBC7AA497" + "Prefabs/Weapons/Attachments/Optics/Optic_ARTII/Optic_ARTII.et": "D2018EDB1BBF4C88", + "Prefabs/Weapons/Attachments/Optics/Optic_PSO1/Optic_PSO1.et": "C850A33226B8F9C1", + "Prefabs/Weapons/Handguns/PM/Handgun_PM.et": "C0F7DD85A86B2900", + "Prefabs/Weapons/Magazines/Magazine_545x39_AK_30rnd_Ball.et": "BBB50A815A2F916B", + "Prefabs/Weapons/Magazines/Magazine_762x51_M14_20rnd_SpecialBall.et": "627255315038152A", + "Prefabs/Weapons/Magazines/Magazine_762x54_SVD_10rnd_Sniper.et": "9CCB46C6EE632C1A", + "Prefabs/Weapons/Magazines/Magazine_9x18_PM_8rnd_Ball.et": "8B853CDD11BA916E", + "Prefabs/Weapons/Mortars/2B14/Mortar_2B14.et": "D1FFE458E8AC4BDB", + "Prefabs/Weapons/Rifles/AK74/Rifle_AK74.et": "FA5C25BF66A53DCF", + "Prefabs/World/Game/PerceptionManager.et": "028DAEAD63E056BE", + "Sounds/Items/_SharedData/PickUp/Items_PickUp_Map.acp": "13F8D0DE67678118", + "UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt": "3E7733BAC8C831F6", + "UI/Textures/DeployMenu/Loadout-BG/Loadout_BG_USSR.imageset": "21533F2089005056", + "UI/Textures/DeployMenu/Loadout-BG/Loadout_US_BG.imageset": "F8AC305446D5D480", + "UI/Textures/Editor/EditableEntities/Factions/EditableEntity_Faction_USA.edds": "EB7F65DBC9392557", + "UI/Textures/Editor/EditableEntities/Factions/EditableEntity_Faction_USSR.edds": "40B12B0DF911B856", + "UI/Textures/Flags/US-flag-base-3it.edds": "46FB2FB2C2125A3C", + "UI/Textures/Flags/USSR-flag-base-3it.edds": "50824EB9F2C215AB", + "UI/Textures/Icons/icons_mapMarkersUI-glow.imageset": "67B3A6DC2D712B52", + "UI/Textures/Icons/icons_mapMarkersUI.imageset": "E23427CAC80DA8B7", + "UI/Textures/Icons/icons_wrapperUI-glow.imageset": "00FE3DBDFD15227B", + "UI/Textures/Icons/icons_wrapperUI.imageset": "3262679C50EF4F01", + "UI/layouts/HUD/GameOver/EndScreen/EndScreenContent_NoImage.layout": "C4507D07F3C54831", + "UI/layouts/Menus/PauseMenu/PauseMenuButton.layout": "9ECCD201BCF07E95", + "UI/layouts/Task/TaskListEntry.layout": "B71975032150BAFB", + "UI/layouts/Task/TaskListEntryDescription.layout": "552EB1921B5C548C", + "UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout": "75C912A1C89BE6C2", + "UI/layouts/WidgetLibrary/ComboBox/WLib_ComboBox.layout": "4B5AE6E64037FFB4", + "UI/layouts/WidgetLibrary/EditBox/WLib_EditBox.layout": "0022F0B45ADBC5AC", + "system/textures/defWhite.edds": "A64A8C63E0FBAE87", + "worlds/Eden/Eden.ent": "853E92315D1D9EFE", + "worlds/MP/Navmeshes/LowResEden.nmn": "5E19E60F26327356" } diff --git a/docs/vehicle-maintenance.md b/docs/vehicle-maintenance.md index 4c92e99..afe5ca8 100644 --- a/docs/vehicle-maintenance.md +++ b/docs/vehicle-maintenance.md @@ -1,6 +1,12 @@ -# Finite player vehicle maintenance +# Finite player vehicle maintenance (historical fixture) -The Western supply cache now contains one emergency repair kit and two vanilla fuel cans, alongside the existing finite UAZ. Its discovered map label identifies the maintenance supplies. Take the item into your inventory, equip it, then use the vanilla action at a damaged vehicle part or fuel cap. Park the vehicle before using either action. +The western guarded vehicle/cache site was removed from the live Everon scenario +on 22 September 2026. Its UAZ, repair kit and fuel cans are no longer spawned. +The prefab and historical test fixture remain for regression coverage; the +following stock and native evidence describe that fixture, not current live stock. +See [current airfield and site changes](airfield-extraction-and-map-icons.md). + +The historical Western supply cache contains one emergency repair kit and two vanilla fuel cans, alongside the existing finite UAZ. Its discovered map label identifies the maintenance supplies. Take the item into your inventory, equip it, then use the vanilla action at a damaged vehicle part or fuel cap. Park the vehicle before using either action. The kit includes 20 native supply units and inherits the vanilla wrench's emergency repair ceiling and damage eligibility. It does not fully restore every damaged part, resurrect destroyed vehicles or create replacements. The held wrench uses the normal native resource consumer; eligible nearby finite supplies may also be consumed according to vanilla resource rights. The item has no resource generator or replenishment callback. Each can starts with 5 litres, with a 20-litre capacity; the pair supplies 10 litres initially. The native fuel station transfers fuel out of the held can into an eligible tank and stops when the can is empty or the target is full. Cans may be refilled only through ordinary existing native sources. diff --git a/tests/mortar_salvo_regression.py b/tests/mortar_salvo_regression.py index e2839f0..6ef7378 100644 --- a/tests/mortar_salvo_regression.py +++ b/tests/mortar_salvo_regression.py @@ -1,20 +1,22 @@ from pathlib import Path from policy_harness import body,run ROOT=Path(__file__).resolve().parents[1] +import re +production=(ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_MortarSalvo.c').read_text() +constants='\n'.join(re.findall(r'static const (?:int|float) [^;]+;',production)).replace('static const','constexpr') s=body(ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_MortarSalvo.c','static vector Offset(').replace('Math.','Math::') run('''#include #include struct vector { float x,y,z;}; vector Vector(float x,float y,float z){return {x,y,z};} namespace Math {constexpr float DEG2RAD=0.01745329252;float Sqrt(float n){return std::sqrt(n);}float Sin(float n){return std::sin(n);}float Cos(float n){return std::cos(n);}} -constexpr int ROUNDS=8;constexpr float RADIUS_METRES=40; -'''+s+''' -int main(){for(int site=0;site<4;site++)for(int order=0;order<10;order++){ +'''+constants+s+''' +int main(){assert(ROUNDS>=8&&RADIUS_METRES>=80);for(int site=0;site<4;site++)for(int order=0;order<10;order++){ float previous=0; for(int shot=0;shot<8;shot++){ auto a=Offset(site,order,shot),b=Offset(site,order,shot); assert(a.x==b.x&&a.z==b.z&&a.y==0); - float radius=std::hypot(a.x,a.z);assert(radius>previous&&radius<=40.0001);previous=radius; + float radius=std::hypot(a.x,a.z);assert(radius>previous&&radius<=80.0001);previous=radius; } }} ''') diff --git a/tests/test_admin_menu.py b/tests/test_admin_menu.py index 3a637bd..7d82093 100644 --- a/tests/test_admin_menu.py +++ b/tests/test_admin_menu.py @@ -10,7 +10,7 @@ source = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c' layout = ROOT / 'addons/core/UI/layouts/ERR/AdminMenu.layout' keys = re.findall(r'"([A-Za-z]+)"', body(source, 'static array Keys(')) settings = re.findall(r'"([A-Za-z]+)"', body(BASE / 'addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c', 'static array Keys(')) -assert len(keys) == 10 and set(settings) - set(keys) == {"stingerChanceOneIn", "automaticConvoyEnabled"} +assert len(keys) == 11 and set(settings) - set(keys) == {"stingerChanceOneIn", "automaticConvoyEnabled", "heliTimerMinutes"} assert set(keys) <= set(settings) # New text-command setting does not alter the fixed native menu. # Native layouts place child widget declarations inside an anonymous Children # block. A missing brace can preserve most widget names yet prevent menu load. @@ -44,9 +44,10 @@ except AssertionError: else: raise AssertionError('Malformed child structure must be rejected') names = re.findall(r'\bName "([^"]+)"', layout.read_text()) +assert 'RandomDay_startTimeMinutes' in names assert len(names) == len(set(names)), 'Menu widget names must be unique' for key in keys: - assert all(name in names for name in (key, 'Apply_' + key, 'Saved_' + key)) + assert all(name in names for name in (key, 'Apply_' + key, 'Saved_' + key, 'Random_' + key)) for name in ('Close', 'SettingsPage', 'HQPage', 'JournalPage', 'JournalText', 'JournalContent', 'JournalScroll', 'PauseJournal', 'LatestJournal', 'Status'): assert name in names for name in re.findall(r'"([A-Za-z]+)"', body(source, 'override void OnMenuOpen(').split('foreach')[0]): @@ -90,9 +91,10 @@ int main() { {"intelRevealChancePercent","intel reveal"},{"startTimeMinutes","mission time"}, {"timeMultiplier","mission speed"},{"startDateCode","mission date"}, {"weatherProfile","mission weather"},{"reportIntervalSeconds","hq interval"}, - {"supportSquadLimit","hq squads"},{"responseRadiusMetres","hq radius"}}; + {"supportSquadLimit","hq squads"},{"responseRadiusMetres","hq radius"},{"convoyCount","hq convoy count"}}; for(auto pair:cases){ assert(Fields::Command(pair.first," 42 ")==pair.second+" set 42"); + assert(Fields::Command(pair.first,"Random")==pair.second+" set Random"); assert(Fields::Command(pair.first,"").empty()); assert(Fields::Command(pair.first," \t ").empty()); assert(Fields::Command(pair.first,string(33,'x')).empty()); @@ -101,10 +103,10 @@ int main() { assert(Fields::Command("startTimeMinutes","14:25")=="mission time set 14:25"); assert(Fields::Command("startDateCode","2026-09-19")=="mission date set 2026-09-19"); assert(Fields::Command("weatherProfile","authored")=="mission weather set authored"); - for(string key:{"heliTimer","convoyCount","prisonDistance","stingerChance","mission abandon","","bad"}){ + for(string key:{"heliTimer","prisonDistance","stingerChance","mission abandon","","bad"}){ assert(Fields::Command(key,"1").empty());assert(!Fields::Available(key,true,true)); } - for(string key:{"reportIntervalSeconds","supportSquadLimit","responseRadiusMetres"}){ + for(string key:{"reportIntervalSeconds","supportSquadLimit","responseRadiusMetres","convoyCount"}){ assert(!Fields::Available(key,false,true));assert(Fields::Available(key,true,false)); } assert(!Fields::Available("aiIntelChancePercent",true,false)); @@ -112,4 +114,4 @@ int main() { for(string key:{"intelRevealChancePercent","radioDefenceSeconds","startTimeMinutes","timeMultiplier","startDateCode","weatherProfile"}) assert(Fields::Available(key,false,false)); } ''') -print('PASS: actual menu command routing covers all ten settings, rejects unsupported keys, gates companions, and matches unique authored widgets') +print('PASS: actual menu command routing covers all eleven settings, rejects unsupported keys, gates companions, and matches unique authored widgets') diff --git a/tests/test_admin_player_tools.py b/tests/test_admin_player_tools.py index 39326b2..0b2c194 100644 --- a/tests/test_admin_player_tools.py +++ b/tests/test_admin_player_tools.py @@ -15,7 +15,7 @@ def adapt(s): s=s.replace('FindComponent(DamageManagerComponent)', 'FindDamage()').replace('FindComponent(SCR_InventoryStorageManagerComponent)', 'FindInventory()') s=s.replace('DamageManagerComponent.Cast(', 'Damage::Cast(').replace('SCR_InventoryStorageManagerComponent.Cast(', 'Inventory::Cast(') s=s.replace('purpose: EStoragePurpose.PURPOSE_DEPOSIT, cb: m_Grants->Operation', '1, m_Grants->Operation') - s=s.replace('String item;', 'String item;') + s=s.replace('String item;', 'String item;').replace('string.Format(', 'String::Format(').replace('ERR_DebugAI.', 'DebugAI::').replace('ERR_DebugIntel.', 'DebugIntel::') return s binding=adapt(body(source,'void Bind(')) constructor=adapt(body(source,'void ERR_AdminPlayerTools(')).replace('void ERR_AdminPlayerTools(ERR_AdminGrantState grants = nullptr)', 'Tools(GrantState* grants = nullptr)') @@ -30,6 +30,7 @@ run(r''' #include struct String:std::string { using std::string::string;String()=default;String(const std::string& s):std::string(s){} + static String Format(String text,int n){text.replace(text.find("%1"),2,std::to_string(n));return text;} String Trim(){auto a=find_first_not_of(" \t\n");return a==npos?String():String(substr(a,find_last_not_of(" \t\n")-a+1));} void ToLower(){std::transform(begin(),end(),begin(),[](unsigned char c){return std::tolower(c);});} bool StartsWith(const char* p){return rfind(p,0)==0;}bool IsEmpty(){return empty();} @@ -51,6 +52,8 @@ struct Game{float now=0;Manager manager;Manager* GetPlayerManager(){return &mana namespace Replication{bool server=true;bool IsServer(){return server;}} namespace Global{bool admin=true;bool IsAdmin(int){return admin;}} namespace Actors{bool Alive(Entity* a){return a&&a->alive;}} +namespace DebugAI {int calls=0;int KillNearby(Mode* m,Entity* e){assert(m&&e);calls++;return 3;}} +namespace DebugIntel {int calls=0;bool success=true;bool SpawnSix(Mode* m,Entity* e){assert(m&&e);calls++;return success;}} struct GrantState{IEntity Actor=nullptr;ERR_AdminItemCallback* Operation=nullptr;String Item;float Deadline=0;bool TimedOut=false; '''+binding+r''' }; @@ -65,6 +68,11 @@ struct Fixture{ bool command(String args){replies.clear();return tools.Execute(&mode,&controller,args,replies);}void tick(){replies.clear();tools.Tick(replies);} }; int main(){ + {Fixture f;int before=DebugIntel::calls;f.command("cheat intel");assert(DebugIntel::calls==before+1&&f.replies[0].find("Spawned 6")!=String::npos&&f.actor.inventory.submissions==0);Global::admin=false;f.command("cheat intel");assert(DebugIntel::calls==before+1);} + {Fixture f;DebugIntel::success=false;f.command("cheat intel");assert(f.replies[0].find("Could not place")!=String::npos);DebugIntel::success=true;} + + {Fixture f;int before=DebugAI::calls;f.command("killais");assert(DebugAI::calls==before+1&&f.replies[0].find("Killed 3")!=String::npos);Global::admin=false;f.command("killais");assert(DebugAI::calls==before+1);} + {Fixture f;assert(!f.command("mission time show")&&f.replies.empty());assert(f.command(" GOD SET ON ")&&!f.actor.damage.enabled);assert(f.tools.m_God);f.command("god");assert(f.actor.damage.changes==1);f.command("god show");assert(f.replies[0].find("on")!=String::npos);f.command("ungod");assert(f.actor.damage.enabled&&f.actor.damage.changes==2);f.tools.Stop();assert(f.actor.damage.changes==2);} {Fixture f;f.actor.damage.enabled=false;f.command("god");f.command("god set off");assert(!f.actor.damage.enabled);} {Fixture f;f.command("god");Global::admin=false;f.tick();assert(f.actor.damage.enabled&&f.tools.m_Stopped&&f.replies.empty());Global::admin=true;f.command("god");assert(f.actor.damage.enabled);} diff --git a/tests/test_admin_providers.py b/tests/test_admin_providers.py index 60bef87..5598653 100644 --- a/tests/test_admin_providers.py +++ b/tests/test_admin_providers.py @@ -65,7 +65,7 @@ int main(){ assert(ha.Setting("intel","carrying").empty()&&ha.Setting("hq","stinger")=="stingerChanceOneIn"); assert(ia.Setting("intel","chance")=="aiIntelChancePercent"&&ia.Setting("intel","carrying")=="aiIntelChancePercent"); assert(ia.Setting("hq","chance").empty()&&ia.Setting("intel","spawn").empty()); - HQHelp hh;IntelHelp ih;lines.clear();assert(hh.Help("hq",lines)&&lines.size()<=4);lines.clear();assert(ih.Help("intel",lines)&&lines.size()<=4); + HQHelp hh;IntelHelp ih;lines.clear();assert(hh.Help("hq",lines)&&lines.size()<=5);lines.clear();assert(ih.Help("intel",lines)&&lines.size()<=4); lines.clear();assert(!hh.Help("unknown",lines)&&!ih.Help("unknown",lines)&&lines.empty()); } ''') diff --git a/tests/test_air_logistics_orders.py b/tests/test_air_logistics_orders.py new file mode 100644 index 0000000..e7c5cb9 --- /dev/null +++ b/tests/test_air_logistics_orders.py @@ -0,0 +1,55 @@ +"""Execute production air-resupply reservation and native-operation quarantine rules.""" +from policy_harness import ROOT, body, run + +reserve = body(ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_AirfieldSupport.c', 'bool ReserveAirAmmo(') +reserve = reserve.replace('ERR_HQUnit unit', 'ERR_HQUnit* unit').replace('unit.', 'unit->').replace('Replication.', 'Replication::') +pending = body(ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_AirAmmoMission.c', 'bool PendingNative(') +pending = pending.replace('foreach (', 'for (').replace('ERR_ConvoyCargoCallback loading', 'ERR_ConvoyCargoCallback* loading').replace('ERR_InfantryAmmoTransfer operation', 'ERR_InfantryAmmoTransfer* operation').replace('loading.', 'loading->').replace('operation.', 'operation->') +run(r''' +#include +#include +struct Replication {inline static bool server=true;static bool IsServer(){return server;}}; +struct ERR_HQUnit { + bool eligible=true,HomeGuard=false,Radio=true,PendingOrder=false,Downlink=false,fighting=false; + int LogisticsReservation=0,Assignment=42; + bool Eligible(int){return eligible&&LogisticsReservation==0;} +}; +struct HQ { + int m_Mode=0;bool active=true; + bool MissionActive(){return active;} + bool Fighting(ERR_HQUnit* unit){return unit->fighting;} +'''+reserve+r''' +}; +struct ERR_ConvoyCargoCallback {bool Finished=false;}; +struct ERR_InfantryAmmoTransfer {bool unresolved=false;bool Unresolved(){return unresolved;}}; +struct Mission { + std::vector m_Loads; + std::vector m_Operations; +'''+pending+r''' +}; +int main(){ + HQ hq;ERR_HQUnit unit; + // A quiet unit may be reserved while retaining its existing delivered search. + assert(hq.ReserveAirAmmo(&unit,200001));assert(unit.LogisticsReservation==200001&&unit.Assignment==42); + assert(!hq.ReserveAirAmmo(&unit,200002)); + for(int invalid=0;invalid<9;invalid++){ + unit={};hq.active=true;Replication::server=true; + if(invalid==0)Replication::server=false; + if(invalid==1)hq.active=false; + if(invalid==2)unit.eligible=false; + if(invalid==3)unit.HomeGuard=true; + if(invalid==4)unit.Radio=false; + if(invalid==5)unit.PendingOrder=true; + if(invalid==6)unit.Downlink=true; + if(invalid==7)unit.fighting=true; + int token=invalid==8?0:200001; + assert(!hq.ReserveAirAmmo(&unit,token)&&unit.Assignment==42&&unit.LogisticsReservation==0); + } + assert(!hq.ReserveAirAmmo(nullptr,200001)); + Mission mission;ERR_ConvoyCargoCallback loading;ERR_InfantryAmmoTransfer move; + mission.m_Loads={&loading};mission.m_Operations={&move}; + assert(mission.PendingNative());loading.Finished=true;assert(!mission.PendingNative()); + move.unresolved=true;assert(mission.PendingNative());move.unresolved=false;assert(!mission.PendingNative()); +} +''') +print('PASS: air logistics reservation preserves delivered search until radio handoff, excludes guards/combat/in-flight orders, and quarantines unresolved native loads/moves') diff --git a/tests/test_air_recon.py b/tests/test_air_recon.py new file mode 100644 index 0000000..772e796 --- /dev/null +++ b/tests/test_air_recon.py @@ -0,0 +1,64 @@ +"""Production recon timing, orbit and standoff geometry; no native flight claim.""" +import re +from policy_harness import ROOT,body,run +path=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_AirReconSystem.c' +schedule=path.read_text().split('class ERR_AirReconSystem')[0] +schedule=schedule[schedule.index('class ERR_ReconSchedule'):].replace('class ERR_ReconSchedule','struct ERR_ReconSchedule').replace('\n}\n','\n};\n') +flight=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_ReconFlightComponent.c' +rules=body(flight,'static vector Cruise(')+'\n'+body(flight,'static vector StandOff(') +rules=rules.replace('Math.','Math::').replace('vector.','vector::') +run(r''' +#include +#include +#include +#include +struct Math{static float Min(float a,float b){return std::min(a,b);}static float Max(float a,float b){return std::max(a,b);}static float Clamp(float a,float lo,float hi){return std::clamp(a,lo,hi);}}; +struct vector{float x=0,y=0,z=0;vector()=default;vector(float a,float b,float c):x(a),y(b),z(c){}vector(const char*s){sscanf(s,"%f %f %f",&x,&y,&z);}float&operator[](int i){return i==0?x:i==1?y:z;}vector operator+(vector b)const{return{x+b.x,y+b.y,z+b.z};}vector operator-(vector b)const{return{x-b.x,y-b.y,z-b.z};}vector operator*(float f)const{return{x*f,y*f,z*f};}vector&operator-=(vector b){x-=b.x;y-=b.y;z-=b.z;return*this;}float Length()const{return std::sqrt(x*x+y*y+z*z);}vector Normalized()const{float f=Length();if(f==0)return{};return{x/f,y/f,z/f};}void Normalize(){*this=Normalized();}static float Dot(vector a,vector b){return a.x*b.x+a.y*b.y+a.z*b.z;}}; +vector Vector(float x,float y,float z){return{x,y,z};} +'''+schedule+rules+r''' +int main(){ + ERR_ReconSchedule s{};assert(!s.Due(1000000,false));s.Begin(1000);assert(s.Deadline==901000); + s.Begin(2000);assert(s.Deadline==901000);assert(!s.Due(900999,false));assert(s.Due(901000,false));assert(!s.Due(901000,true)); + s.Launched();assert(s.Deadline<0&&!s.Due(2000000,false));s.Change(2,2000,true);assert(s.Deadline<0&&s.Minutes==2); + s.Lost(3000);assert(s.Deadline==123000&&!s.Due(122999,false));s.Change(15,4000,false);assert(s.Deadline==904000); + s.Stop();s.Begin(100);s.Lost(200);s.Change(1,300,false);assert(s.Deadline<0&&!s.Due(1e8,false)); + vector orbit=Cruise({300,120,0},{0,0,0},false);assert(std::abs(orbit.x)<.001&&std::abs(orbit.z+28)<.001); + vector far=Cruise({1000,120,0},{0,0,0},false);assert(far.x==-40&&far.y==0); + vector near=Cruise({100,120,0},{0,0,0},false);assert(near.x>0); // push back out toward 300m orbit + vector home=Cruise({20,120,0},{0,0,0},true);assert(home.x==-8); // return approaches pad instead of orbiting + vector away=StandOff({100,120,0},{0,0,0},{-40,0,0});assert(away.x>0); + vector clear=StandOff({300,120,0},{0,0,0},{-40,0,0});assert(clear.x==-40); + vector coincident=StandOff({0,120,0},{0,0,0},{0,0,0});assert(std::isfinite(coincident.x)&&coincident.x>0); +} +''') +print('PASS: original 15-minute launch/loss cadence, active/captured timer disarm, saved delay reset, stop, 300m orbit and close-contact standoff') + +# Explicit entity deletion must not leave an inactive launch deadline behind. +removed=body(path,'void Removed(') +for old,new in [('ERR_ReconFlightComponent flight','Flight* flight'),('Replication.','Replication::'),('ERR_HQ.','ERR_HQ::'),('m_Mode.','m_Mode->'),('null','nullptr'),('GetGame().GetWorld().GetWorldTime()','clockNow')]: + removed=removed.replace(old,new) +run(r''' +#include +#include +using string=std::string; +struct Flight{};struct Mode{bool active=true;bool Active(){return active;}}; +struct Replication{inline static bool server=true;static bool IsServer(){return server;}}; +struct ERR_HQ{inline static int events=0;static void Event(string,string){events++;}}; +float clockNow=5000; +'''+schedule+r''' +struct System{ + ERR_ReconSchedule m_Schedule;Flight* m_Current=nullptr;Mode* m_Mode=nullptr;int m_PadAttempts=3;string m_Reason; + string Status(){return m_Reason;} +'''+removed+r''' +}; +int main(){ + Mode mode;Flight owned,other;System s{};s.m_Mode=&mode;s.m_Current=&owned;s.m_Schedule.Begin(0);s.m_Schedule.Launched(); + s.Removed(nullptr);s.Removed(&other);assert(s.m_Current==&owned&&s.m_Schedule.Deadline<0&&ERR_HQ::events==0); + Replication::server=false;s.Removed(&owned);assert(s.m_Current==&owned);Replication::server=true; + mode.active=false;s.Removed(&owned);assert(s.m_Current==&owned);mode.active=true; + s.Removed(&owned);assert(!s.m_Current&&s.m_PadAttempts==0&&s.m_Schedule.Deadline==905000&&ERR_HQ::events==1); + clockNow=9000;s.Removed(&owned);assert(s.m_Schedule.Deadline==905000&&ERR_HQ::events==1); + s.m_Current=&other;s.m_Schedule.Stop();s.Removed(&other);assert(s.m_Current==&other&&s.m_Schedule.Deadline<0); +} +''') +print('PASS: actual aircraft-removal notification guards owner/server/active/stop and schedules one full delay without replay extension') diff --git a/tests/test_air_support_policy.py b/tests/test_air_support_policy.py new file mode 100644 index 0000000..c8acf5d --- /dev/null +++ b/tests/test_air_support_policy.py @@ -0,0 +1,57 @@ +"""Execute production HQ reinforcement policy against confidence and force scenarios.""" +from policy_harness import ROOT, run +import re +source=(ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_AirSupportPolicy.c').read_text().split('// One immutable')[0] +source=source.replace('class ', 'struct ').replace('\n}', '\n};').replace('ERR_AirSupportSituation s','ERR_AirSupportSituation* s').replace('s.', 's->').replace('ERR_HQMemoryRules.','ERR_HQMemoryRules::').replace('Math.','Math::') +source=source.replace('ERR_HQMemoryRules->', 'ERR_HQMemoryRules::') +source=re.sub(r'\b(bool|int|float) (\w+);',r'\1 \2{};',source) +run(r''' +#include +#include +#include +#include +#include +struct string:std::string{using std::string::string;bool IsEmpty(){return empty();}}; +struct Math {static int Clamp(int x,int a,int b){return std::clamp(x,a,b);}static float Max(float a,float b){return std::max(a,b);}}; +struct ERR_HQMemoryRules{static bool Finite(float v){return std::isfinite(v);}}; +'''+source+r''' +int main(){ + ERR_AirSupportSituation s{};s.AircraftReady=true;s.BeliefId=1;s.Samples=3;s.Spotted=3;s.Age=5;s.Confidence=.9;s.Radius=20;s.MotionKnown=true;s.Infantry=true;s.Speed=1; + ERR_AirSupportPolicy p;assert(!p.Consider(&s,0));assert(!p.Consider(&s,44999));assert(p.Consider(&s,45000)); + p.Committed(45000);assert(!p.Consider(&s,50000));assert(!p.Consider(&s,525000));assert(p.Consider(&s,570000)); + s.CommittedFit=6;assert(!p.Consider(&s,600000));s.CommittedFit=0;s.GroundFit=6;assert(!p.Consider(&s,600000)); + s.ConfirmedLosses=2;assert(!p.Consider(&s,600000));assert(p.Consider(&s,620000)); + s.ArtilleryBusy=true;assert(!p.Consider(&s,630000));s.ArtilleryBusy=false;assert(!p.Consider(&s,640000));assert(p.Consider(&s,660000)); + s.BeliefId=2;assert(!p.Consider(&s,670000));assert(!p.Consider(&s,689999));assert(p.Consider(&s,690000)); + auto good=s; + s.Age=91;assert(!p.Consider(&s,700000));s=good;s.Confidence=.69;assert(!p.Consider(&s,700000)); + s=good;s.Radius=161;assert(!p.Consider(&s,700000));s=good;s.Samples=1;assert(!p.Consider(&s,700000)); + s=good;s.Infantry=false;assert(!p.Consider(&s,700000));s=good;s.Speed=7;assert(!p.Consider(&s,700000)); + s=good;s.MotionKnown=false;assert(!p.Consider(&s,700000));s=good;s.AircraftReady=false;assert(!p.Consider(&s,700000)); + s=good;s.Age=std::numeric_limits::quiet_NaN();assert(!p.Consider(&s,700000)); + assert(!p.Consider(nullptr,700000));assert(!p.Consider(&good,std::numeric_limits::infinity())); +} +''') +print('PASS: production airborne memory confidence, persistence, ground coverage, confirmed losses, artillery veto, cooldown and invalid-input rules') + +from policy_harness import body +load=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultLoad.c' +cover=body(load,'bool Cover(').replace('out vector center','int& center').replace('m_Order.Target','m_Order->Target') +service=body(load,'bool NeedsService(') +run(r''' +#include +struct Order {int Target=730;}; +struct Load { + Order* m_Order=nullptr;bool m_Used=false,m_Approaching=false;float m_CoverUntil=300000; +'''+cover+service+r''' +}; +int main(){ + Load load;Order order;int center=-1; + assert(!load.Cover(0,center)&¢er==-1&&!load.NeedsService(0)); + load.m_Order=ℴload.m_Used=true;load.m_Approaching=true; + assert(!load.Cover(1,center)&&!load.NeedsService(300001)); + load.m_Approaching=false;assert(load.Cover(299999,center)&¢er==730&&!load.NeedsService(299999)); + center=-1;assert(!load.Cover(300000,center)&¢er==-1&&load.NeedsService(300000)); +} +''') +print('PASS: production cover retains the carried HQ target until its deadline; airport reload waits until the drop pass and cover have finished') diff --git a/tests/test_content_policy.py b/tests/test_content_policy.py new file mode 100644 index 0000000..0c16ee4 --- /dev/null +++ b/tests/test_content_policy.py @@ -0,0 +1,48 @@ +"""Exercise production content selection bodies with small engine boundary doubles. +Native preference I/O, group membership and UI have separate Workbench probes. +""" +from policy_harness import ROOT, body, run +import re +p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ContentPolicy.c' +def cpp(sig): + s=body(p,sig).replace('ResourceName ', 'string ').replace('Faction faction','Faction* faction') + s=s.replace('ERR_ContentRegistry.Definition().Find(pack)','registry.Find(pack)') + s=s.replace('ERR_ContentRegistry.Definition().Options','registry.Options') + s=s.replace('ERR_ContentOption option', 'Option* option').replace('foreach (','for (') + s=s.replace('array groups', 'Array groups') + s=s.replace('option.', 'option->').replace('faction.', 'faction->') + return s +run(r''' +#include +#include +#include +#include +struct string:std::string {using std::string::string;string(std::string s):std::string(s){} bool IsEmpty(){return empty();}}; +templatestruct Array:std::vector{using std::vector::vector;void Insert(T v){this->push_back(v);}bool IsEmpty(){return this->empty();}int Count(){return this->size();}}; +struct Flags:std::map{using std::map::operator=;bool Contains(string k){return count(k);}bool Get(string k){return (*this)[k];}}; +struct Option{string Id,Parent,NativeFactionKey,GuardGroup;}; +struct Registry{std::vector Options;Option* Find(string id){for(auto*o:Options)if(o->Id==id)return o;return nullptr;}}; +struct Faction{string key;string GetFactionKey(){return key;}}; +struct Policy{int m_GuardOffset=0;Flags m_Active;Registry registry; +'''+'\n'.join(cpp(sig) for sig in ('bool Enabled(', 'ResourceName GuardGroup(', 'bool Enemy('))+r''' +}; +int main(){ + Option pack{"rhs","","",""}, a{"usmc","rhs","RHS_USAF","usmc-group"}, b{"afrf","rhs","RHS_AFRF","afrf-group"}; + Policy p;p.registry.Options={&pack,&a,&b};p.m_Active={{"rhs",true},{"usmc",true},{"afrf",true}}; + Faction usa{"RHS_USAF"},ru{"RHS_AFRF"},other{"US"}; + assert(p.Enabled("")&&!p.Enabled("unknown"));assert(p.Enemy(&usa)&&p.Enemy(&ru)&&!p.Enemy(&other)&&!p.Enemy(nullptr)); + assert(p.GuardGroup(0)=="usmc-group"&&p.GuardGroup(1)=="afrf-group"&&p.GuardGroup(2)=="usmc-group"); + p.m_GuardOffset=1;assert(p.GuardGroup(0)=="afrf-group"&&p.GuardGroup(1)=="usmc-group"); + p.m_Active["rhs"]=false;assert(!p.Enabled("rhs")&&!p.Enabled("usmc")&&!p.Enabled("afrf"));assert(p.GuardGroup(0).empty()&&!p.Enemy(&usa)); + p.m_Active["rhs"]=true;p.m_Active["usmc"]=false;assert(!p.Enabled("usmc")&&p.Enabled("afrf")); + assert(p.GuardGroup(0)=="afrf-group"&&p.GuardGroup(10)=="afrf-group"&&!p.Enemy(&usa)&&p.Enemy(&ru)); + p.m_Active["afrf"]=false;assert(p.GuardGroup(0).empty()); +} +''') +# Config-driven labels, IDs and exact source resources; no fixed RHS row in UI. +registry=(ROOT/'addons/core/Configs/ERR/Content/Registry.conf').read_text() +assert re.findall(r'\bId "([^"]+)"',registry)==['rhs','rhs_usmc','rhs_afrf','rhs_ion'] +assert len(re.findall(r'\bGuardGroup ',registry))==3 +assert 'ERR_ContentRegistry.Ids()' in (ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ContentMenu.c').read_text() +assert 'PrisonBackpack.et' in (ROOT/'addons/core/Configs/ERR/EveronSites.conf').read_text() +print('PASS: actual content policy gates parent packs, individual factions, faction identity and finite group rotation with vanilla fallback') diff --git a/tests/test_convoy_auto_settings.py b/tests/test_convoy_auto_settings.py index ee38954..86a0b6c 100644 --- a/tests/test_convoy_auto_settings.py +++ b/tests/test_convoy_auto_settings.py @@ -5,7 +5,7 @@ from policy_harness import ROOT,body,run BASE=Path(os.environ.get('ERR_CONVOY_SETTINGS_ROOT',ROOT)) P=BASE/'addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c' keys=body(P,'static array Keys(').replace('array','std::vector') -valid=body(P,'static bool Valid(') +valid='\n'.join(body(P,sig) for sig in ('static bool Valid(', 'static bool Randomizable(', 'static bool ValidSelection(')) default=body(P,'static int AddedDefault(') read=body(P,'protected bool Read(').replace('protected ','').replace('out map values','Map*& values').replace('new map()','new Map()') read=read.replace('JsonLoadContext json','JsonLoadContext* json').replace('json.','json->').replace('values.','values->').replace('foreach (string key : Keys())','for (string key : Keys())').replace('Math.Round','std::round') @@ -53,5 +53,5 @@ for forbidden in ('StartConvoy','StartScenarioConvoy','Spawn','m_HQ.'): # Do not advertise this command-only setting as an editable field absent from the actual fixed menu. menu=body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c','static array Keys(') assert 'automaticConvoyEnabled' not in menu -assert body(provider,'override bool Help(').count('replies.Insert(')<=4 +assert body(provider,'override bool Help(').count('replies.Insert(')<=5 print('PASS: actual read migrates old valid settings, preserves explicit disabled state, rejects invalid data; next-round feedback and fixed menu remain honest') diff --git a/tests/test_convoy_budget.py b/tests/test_convoy_budget.py index 01b884f..c84ba31 100644 --- a/tests/test_convoy_budget.py +++ b/tests/test_convoy_budget.py @@ -44,6 +44,20 @@ int main(){ invalid.Strength=1;assert(!limited.BindRecord(reserved,0,&invalid));invalid.Strength=2;invalid.Physical=false;assert(!limited.BindRecord(reserved,0,&invalid));invalid.Physical=true;assert(limited.BindRecord(reserved,0,&invalid)); Replication::server=false;assert(!limited.Reserve("client"));Replication::server=true; ERR_GameMode formations;ERR_GameMode::current=&formations;for(int i=0;i<123;i++){auto* r=new ERR_ForceRecord();r->Strength=0;r->Physical=false;formations.ledger.records.Insert(r);}ERR_ConvoyPool capped(&formations,8);assert(!capped.Reserve("formation-cap")); + // Production configuration reserves every full troop truck: 14 passengers + 11 crew. + for(int target:{0,1,3,20}){ + ERR_GameMode supply;ERR_GameMode::current=&supply;ERR_ForceRecord baseline;baseline.Strength=60;supply.ledger.records.Insert(&baseline);ERR_ConvoyPool full(&supply,target,true); + for(int i=0;iOutstandingBodies==25&&ticket->OutstandingGroups==8&&ticket->Records.Count()==8);assert(full.Consume(ticket)); + for(int group=0;group<8;group++){ + auto r=new ERR_ForceRecord();r->Id=1+supply.ledger.records.Count();r->SourceId=ticket->SourceId+"-vehicle-"+std::to_string(group);r->InitialStrength=full.PlannedCount(group);r->Strength=r->InitialStrength;supply.ledger.records.Insert(r);assert(full.BindRecord(ticket,group,r)); + } + assert(ticket->OutstandingBodies==0&&ticket->OutstandingGroups==0&&full.CommittedBodies()==60+25*(i+1)); + } + assert(!full.Reserve("excess")); + for(auto r:supply.ledger.records)r->Strength=0; + assert(!full.Reserve("replacement")); + } ERR_GameMode finite;ERR_GameMode::current=&finite;ERR_ConvoyPool one(&finite,1);assert(one.Reserve("first"));assert(!one.Reserve("second")); } ''') diff --git a/tests/test_convoy_dispatch.py b/tests/test_convoy_dispatch.py index 0adb009..2cdb9ef 100644 --- a/tests/test_convoy_dispatch.py +++ b/tests/test_convoy_dispatch.py @@ -41,6 +41,7 @@ namespace ERR_ConvoyResources{bool available=true;bool Available(){return availa namespace ERR_HQ{void Event(string,string){}} namespace ERR_Actors{int calls=0,fail=0;IEntity* Spawn(string prefab,vector,vector=vector{}){calls++;if(calls==fail)return nullptr;if(prefab=="group")return new SCR_AIGroup();return new Vehicle();}} struct Service{ERR_GameMode* m_Mode;ERR_ConvoyPool* m_Pool;Arraym_Tasks;bool current=true;int crews=0,failCrew=-1; + ERR_ConvoyTask* NewTask(){return new ERR_ConvoyTask();} ERR_ConvoyVehicle* NewVehicle(){return new ERR_ConvoyVehicle();} bool Current(){return current;}bool FillCrew(ERR_ConvoyVehicle* unit){crews++;return unit->Index!=failCrew;} ''' + code + r''' }; @@ -49,7 +50,7 @@ int main(){ ERR_ConvoyRules::valid=false;assert(!service.Dispatch(&receipt,positions,angles,&route,100));assert(!receipt.used&&ERR_Actors::calls==0);ERR_ConvoyRules::valid=true; ERR_ConvoyRules::clear=false;assert(!service.Dispatch(&receipt,positions,angles,&route,100));assert(!receipt.used&&ERR_Actors::calls==0);ERR_ConvoyRules::clear=true; ERR_ConvoyResources::available=false;assert(!service.Dispatch(&receipt,positions,angles,&route,100));assert(!receipt.used&&ERR_Actors::calls==0);ERR_ConvoyResources::available=true; - enemy.key="US";assert(!service.Dispatch(&receipt,positions,angles,&route,100));assert(!receipt.used&&ERR_Actors::calls==0);enemy.key="USSR"; + enemy.key="US"; // A selected non-USSR enemy uses the same finite dispatch transaction. auto task=service.Dispatch(&receipt,positions,angles,&route,100);assert(task&&task->Vehicles.Count()==6&&!task->stopped&&receipt.used&&pool.consumes==1&&ERR_Actors::calls==12&&mode.owned.Count()==12&&service.crews==6); assert(!service.Dispatch(&receipt,positions,angles,&route,100));assert(ERR_Actors::calls==12); for(int failed=1;failed<=12;failed++){ @@ -103,7 +104,7 @@ int main(){ assert(!service.FillCrew(&unit)&&unit.Members.Count()==4&&mode.owned==4);assert(seats[4].spawns==0); } ''') -print('PASS: actual convoy dispatch validates before receipt consumption, consumes once before native mutation, retains all12 partial-failure paths, refuses implicit faction replacement, and allocates exact19 bodies without capacity shrinking') +print('PASS: actual convoy dispatch validates before receipt consumption, consumes once before native mutation, retains all12 partial-failure paths, accepts the selected non-USSR enemy, and allocates exact19 bodies without capacity shrinking') # The resource must suppress authored allocation before native EOnInit, not try # to purge a queued default squad after custom seat occupants have been added. diff --git a/tests/test_convoy_startup.py b/tests/test_convoy_startup.py index 32d8faa..cc2e79c 100644 --- a/tests/test_convoy_startup.py +++ b/tests/test_convoy_startup.py @@ -8,7 +8,7 @@ s=s.replace('bool m_Enabled;', 'bool m_Enabled=false;').replace('bool m_Finished s=s.replace('class ERR_ConvoyStartup', 'struct ERR_ConvoyStartup').rstrip()+';' h=body(ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c','protected void AutomaticConvoy(') h=h.replace('protected ','').replace('null','nullptr').replace('Replication.','Replication::').replace('ERR_GameMode.','ERR_GameMode::') -for name in ('m_Mode','m_ConvoyStartup'):h=h.replace(name+'.',name+'->') +for name in ('m_Mode','m_ConvoyStartup','m_SupplyConvoys','m_Convoys'):h=h.replace(name+'.',name+'->') run(r''' #include #include @@ -20,7 +20,8 @@ struct ERR_ForceRules {static bool Finite(float n){return std::isfinite(n);}}; struct Replication {inline static bool server=true;static bool IsServer(){return server;}}; struct ERR_GameMode {inline static ERR_GameMode* current;bool active=true;static ERR_GameMode* Get(){return current;}bool Active(){return active;}}; struct Journal {int count=0;void Write(string,string){count++;}}; -struct HQ {ERR_GameMode* m_Mode;ERR_ConvoyStartup* m_ConvoyStartup;void* m_ConvoyTask=nullptr;string m_ConvoyFailure;Journal m_Journal;bool ready=true,accepted=true;int calls=0;bool ConvoyAdmissionReady(){return ready;}bool StartScenarioConvoy(){calls++;return accepted;} +struct Supply {void Tick(float,bool){}}; struct Service {int Count(){return 0;}void* At(int){return nullptr;}}; +struct HQ {Supply* m_SupplyConvoys=nullptr; Service* m_Convoys=nullptr;ERR_GameMode* m_Mode;ERR_ConvoyStartup* m_ConvoyStartup;void* m_ConvoyTask=nullptr;string m_ConvoyFailure;Journal m_Journal;bool ready=true,accepted=true;int calls=0;bool ConvoyAdmissionReady(){return ready;}bool StartScenarioConvoy(){calls++;return accepted;} '''+h+r''' }; int main(){ @@ -31,7 +32,7 @@ int main(){ ERR_ConvoyStartup manual;manual.Initialize(true);assert(!manual.Decide(0,true,true)&&manual.Finished());assert(!manual.Decide(60000,false,true)); ERR_ConvoyStartup invalid;invalid.Initialize(true);assert(!invalid.Decide(std::numeric_limits::quiet_NaN(),false,true));assert(!invalid.Decide(-1,false,true));invalid.Decide(10000,false,false);assert(!invalid.Decide(0,false,true));assert(invalid.Decide(40000,false,true)); ERR_ConvoyStartup stopped;stopped.Initialize(true);stopped.Decide(0,false,false);stopped.Stop();assert(!stopped.Decide(30000,false,true)); - ERR_GameMode mode;ERR_GameMode::current=&mode;ERR_ConvoyStartup startup;startup.Initialize(true);HQ hq{&mode,&startup}; + ERR_GameMode mode;ERR_GameMode::current=&mode;ERR_ConvoyStartup startup;startup.Initialize(true);HQ hq;hq.m_Mode=&mode;hq.m_ConvoyStartup=&startup; Replication::server=false;hq.AutomaticConvoy(0);assert(hq.calls==0&&hq.m_Journal.count==0);Replication::server=true; ERR_GameMode::current=nullptr;hq.AutomaticConvoy(0);assert(hq.m_Journal.count==0);ERR_GameMode::current=&mode; mode.active=false;hq.AutomaticConvoy(0);assert(hq.m_Journal.count==0);mode.active=true; diff --git a/tests/test_debug_ai.py b/tests/test_debug_ai.py new file mode 100644 index 0000000..818cb90 --- /dev/null +++ b/tests/test_debug_ai.py @@ -0,0 +1,18 @@ +"""Execute production kill eligibility, including players, retained bodies and radius.""" +from policy_harness import ROOT,body,run +s=body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_DebugAI.c','static bool Eligible(') +s=s.replace('IEntity target','IEntity* target').replace('IEntity center','IEntity* center').replace('array protectedBodies','Array protectedBodies').replace('target.','target->').replace('center.','center->').replace('ChimeraCharacter.Cast','ChimeraCharacter::Cast').replace('ERR_Actors.','ERR_Actors::').replace('vector.','vector::') +run(r''' +#include +#include +#include +#include +struct vector{float x=0,y=0,z=0;static float Distance(vector a,vector b){return std::sqrt((a.x-b.x)*(a.x-b.x)+(a.y-b.y)*(a.y-b.y)+(a.z-b.z)*(a.z-b.z));}}; +struct IEntity{vector position;bool alive=true,player=false,character=true;vector GetOrigin(){return position;}}; +struct Array:std::vector{using std::vector::vector;bool Contains(IEntity* p){return std::find(begin(),end(),p)!=end();}}; +namespace ChimeraCharacter{bool Cast(IEntity* e){return e&&e->character;}} +namespace ERR_Actors{bool Alive(IEntity* e){return e->alive;}bool Player(IEntity* e){return e->player;}} +'''+s+r''' +int main(){IEntity center,target;Array empty;target.position.x=1000;assert(Eligible(&target,¢er,empty));target.position.x=1000.1;assert(!Eligible(&target,¢er,empty));target.position={0,1001,0};assert(!Eligible(&target,¢er,empty));target.position={10,0,0};assert(Eligible(&target,¢er,empty));target.player=true;assert(!Eligible(&target,¢er,empty));target.player=false;assert(!Eligible(&target,¢er,Array{&target}));target.character=false;assert(!Eligible(&target,¢er,empty));target.character=true;target.alive=false;assert(!Eligible(&target,¢er,empty));assert(!Eligible(nullptr,¢er,empty));assert(!Eligible(¢er,¢er,empty));} +''') +print('PASS: actual killais eligibility includes the 1000m boundary, excludes players/retained bodies/dead/non-character entities and checks full 3D distance') diff --git a/tests/test_dedicated_native.py b/tests/test_dedicated_native.py index 6ef77e1..7cdfef8 100644 --- a/tests/test_dedicated_native.py +++ b/tests/test_dedicated_native.py @@ -76,7 +76,7 @@ class DedicatedNativeTests(unittest.TestCase): self.assertEqual(args[args.index('-worldSystemsConfig')+1], '{C50579A2EC48E8EF}Configs/Systems/MissionSystems.conf') definition = (root/'addons/core/addon.gproj').read_text() dependencies = set(re.findall(r'"([A-F0-9]{16})"', re.search(r'Dependencies\s*\{([^}]+)', definition)[1])) - self.assertEqual(dependencies, {'58D0FB3206B6F859', '595F2BF2F44836FB', '629B2BA37EFFD577', '5E524E4FEECCA92B'}) + self.assertEqual(dependencies, {'58D0FB3206B6F859', '595F2BF2F44836FB', '629B2BA37EFFD577', '5E524E4FEECCA92B', '65930CB4CD0237B2'}) def test_invalid_project_route_combinations_fail_before_files_or_processes(self): for route, project in [('vanilla-everon', 'chat'), ('unknown', 'core'), ('header', 'unknown')]: diff --git a/tests/test_enemy_awareness.py b/tests/test_enemy_awareness.py new file mode 100644 index 0000000..16081ef --- /dev/null +++ b/tests/test_enemy_awareness.py @@ -0,0 +1,41 @@ +"""Execute production aim/perception overrides with native API doubles.""" +from policy_harness import ROOT, body, run +path=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_EnemySkillAwareness.c' +def native(signature): + s=body(path,signature).replace('override ','').replace('super.', 'Native::').replace('EAISkill.', 'EAISkill::') + s=s.replace('PerceptionComponent perceptionComp','PerceptionComponent* perceptionComp').replace('SCR_AIThreatSystem threatSystem','SCR_AIThreatSystem* threatSystem') + return s.replace('perceptionComp.', 'perceptionComp->').replace('m_CombatComponent.', 'm_CombatComponent->') +run(r''' +#include +enum class EAISkill{REGULAR,CYLON}; +struct SCR_AIThreatSystem{}; +struct PerceptionComponent{float factor=0;void SetPerceptionFactor(float f){factor=f;}}; +struct Native{ + int calls=0;EAISkill sampled=EAISkill::CYLON; + float GetRandomFactor(EAISkill s,float mu){calls++;sampled=s;return mu+1;} + void UpdatePerceptionFactor(PerceptionComponent* p,SCR_AIThreatSystem*){calls++;p->factor=.4f;} +}; +struct Combat:Native{ + bool bound=false;float m_fEquipmentPerceptionFactor=1,m_fPerceptionFactor=1; + static constexpr float PERCEPTION_FACTOR_ALERTED=2.5f; + bool ERR_EnhancedEnemy(){return bound;} +'''+native('override void UpdatePerceptionFactor(')+r''' +}; +struct Aim:Native{ + Combat* m_CombatComponent=nullptr; +'''+native('override float GetRandomFactor(')+r''' +}; +int main(){ + Aim aim;Combat combat;PerceptionComponent perception;SCR_AIThreatSystem threat; + aim.GetRandomFactor(EAISkill::CYLON,0);assert(aim.sampled==EAISkill::CYLON); + aim.m_CombatComponent=&combat;aim.GetRandomFactor(EAISkill::CYLON,0);assert(aim.sampled==EAISkill::CYLON); + combat.bound=true;aim.GetRandomFactor(EAISkill::CYLON,0);assert(aim.sampled==EAISkill::REGULAR); + combat.UpdatePerceptionFactor(&perception,&threat);assert(perception.factor==2.5f&&combat.calls==0); + combat.m_fEquipmentPerceptionFactor=3;combat.m_fPerceptionFactor=.5f; + combat.UpdatePerceptionFactor(&perception,&threat);assert(perception.factor==3.75f); + combat.UpdatePerceptionFactor(nullptr,&threat);assert(combat.calls==0); + combat.bound=false;combat.UpdatePerceptionFactor(&perception,&threat);assert(perception.factor==.4f&&combat.calls==1); + aim.GetRandomFactor(EAISkill::CYLON,0);assert(aim.sampled==EAISkill::CYLON); +} +''') +print('PASS: actual scoped REGULAR aim error and alert perception; preserves equipment multiplier and restores vanilla behavior when unbound') diff --git a/tests/test_enemy_equipment.py b/tests/test_enemy_equipment.py index 8725380..45a3b0a 100644 --- a/tests/test_enemy_equipment.py +++ b/tests/test_enemy_equipment.py @@ -44,7 +44,7 @@ struct SCR_AIGroup { struct Settings {int oneIn=15;int Get(std::string key){assert(key=="stingerChanceOneIn");return oneIn;}}; struct ERR_GameMode { static inline ERR_GameMode* current=nullptr;bool active=true;struct Settings values; - static ERR_GameMode* Get(){return current;}bool Active(){return active;}int EnemyFaction(){return 1;} + static ERR_GameMode* Get(){return current;}bool Active(){return active;}int EnemyFaction(){return 1;}bool IsMissionEnemy(int f){return f==1;} struct Settings* Settings(){return &values;} }; namespace Math {int draw=0,calls=0;int RandomInt(int lo,int hi){assert(lo==0&&hi>0);calls++;return draw;}} @@ -70,7 +70,7 @@ struct NativeCombat { namespace ERR_EnemyGunnerSkill { bool CapturedGroup(ERR_GameMode*,SCR_AIGroup*){return false;} bool OriginalGunner(ERR_GameMode*,SCR_AIGroup*,IEntity*){return false;} - EAISkill Profile(EAISkill requested,bool){return requestedSkillChecks==5); auto incompleteOptics=incomplete.m_Entries[0]->Optics;int checksBeforeStop=incompleteOptics->checks; @@ -199,4 +199,4 @@ int main(){ capturedBody.eligible=true;capture.Tick(9000);assert(capturedOptics->checks==1); } ''') -print('PASS: actual equipment coordinator registration dedupe, startup delay, five member scans, finite component retries, unregister/stop terminal behavior and 128-group bound') +print('PASS: actual equipment coordinator registration dedupe, startup delay, five member scans, finite component retries, unregister/stop terminal behavior and 256-group bound') diff --git a/tests/test_enemy_gunner_skill.py b/tests/test_enemy_gunner_skill.py index 13bdca5..a9436d1 100644 --- a/tests/test_enemy_gunner_skill.py +++ b/tests/test_enemy_gunner_skill.py @@ -9,11 +9,11 @@ def convert(s): s=s.replace('ERR_HQ.Cast(mode.Command())','mode->Command()').replace('TurretCompartmentSlot.Cast(slot)','dynamic_cast(slot)') for cls in ('ERR_GameMode','SCR_AIGroupUtilityComponent','SCR_AIGroup','IEntity','ERR_HQ','ERR_ConvoyTask','ERR_ConvoyVehicle','ERR_ForceRecord','BaseCompartmentSlot'): s=re.sub(r'\b'+cls+r' ([a-zA-Z_]\w*)',cls+r'* \1',s) - for obj in ('mode','group','actor','utility','hq','task','unit','record','slot'): + for obj in ('mode','group','actor','utility','hq','task','unit','vehicle','record','slot'): s=s.replace(obj+'.',obj+'->') for field in ('Receipt','Entity'): s=s.replace(field+'.',field+'->') - s=s.replace('Forces().','Forces()->') + s=s.replace('Forces().','Forces()->').replace('Convoys().','Convoys()->') for cls in ('ERR_EnemyEquipmentRules','ERR_EnemyGunnerSkill','ERR_ConvoyRules','ERR_Actors','Replication','SCR_Global','ERR_GameMode','EAISkill','ERR_ForceKind','ECompartmentType','CompartmentAccessComponent'): s=s.replace(cls+'.',cls+'::') s=s.replace('super.','NativeCombat::').replace('null','nullptr').replace('foreach (','for (') @@ -22,8 +22,8 @@ def convert(s): rules=equipment[equipment.index('class ERR_EnemyEquipmentRules'):equipment.index('class ERR_EnemyEquipmentCallback')] rules=convert(rules.replace('class ERR_EnemyEquipmentRules','struct ERR_EnemyEquipmentRules').replace('modded class SCR_AICombatComponent','struct SCR_AICombatComponent : NativeCombat')).replace('\n}\n','\n};\n') # Declare helper before rules, then define its real method bodies after rules. -helper=helper[helper.index(' static bool CapturedGroup'):helper.rfind('}')] -helper=convert(helper).replace('static bool CapturedGroup','bool ERR_EnemyGunnerSkill::CapturedGroup').replace('static bool OriginalGunner','bool ERR_EnemyGunnerSkill::OriginalGunner').replace('static EAISkill Profile','EAISkill ERR_EnemyGunnerSkill::Profile') +helper=helper[helper.index(' static ERR_ConvoyTask TaskForGroup'):helper.rfind('}')] +helper=convert(helper).replace('static ERR_ConvoyTask* TaskForGroup','ERR_ConvoyTask* ERR_EnemyGunnerSkill::TaskForGroup').replace('static bool CapturedGroup','bool ERR_EnemyGunnerSkill::CapturedGroup').replace('static bool OriginalGunner','bool ERR_EnemyGunnerSkill::OriginalGunner').replace('static EAISkill Profile','EAISkill ERR_EnemyGunnerSkill::Profile') run(r''' #include #include @@ -44,12 +44,12 @@ struct Ledger{ERR_ForceRecord* record=nullptr;auto FindGroup(SCR_AIGroup*){retur struct ERR_ConvoyReceipt{bool Consumed=true;Array Records={8};}; struct ERR_ConvoyVehicle{SCR_AIGroup* Group=nullptr;Vehicle* Entity=nullptr;bool Registered=true,Released=false;int Index=0;Array Members;Array Seats;}; struct ERR_ConvoyTask{bool Ready=true,Stopped=false;ERR_ConvoyReceipt* Receipt=nullptr;Array Vehicles;}; -struct ERR_HQ{ERR_ConvoyTask* task=nullptr;auto ConvoyTask(){return task;}}; -struct ERR_GameMode{static inline ERR_GameMode* current=nullptr;bool active=true;ERR_HQ* hq=nullptr;Ledger* ledger=nullptr;static auto Get(){return current;}bool Active(){return active;}int EnemyFaction(){return 1;}auto Command(){return hq;}auto Forces(){return ledger;}}; +struct ERR_HQ{ERR_ConvoyTask* task=nullptr;ERR_ConvoyTask* extra=nullptr;auto Convoys(){return this;}int Count(){return extra?2:1;}auto At(int index){return index==0?task:extra;}auto ConvoyTask(){return task;}}; +struct ERR_GameMode{static inline ERR_GameMode* current=nullptr;bool active=true;ERR_HQ* hq=nullptr;Ledger* ledger=nullptr;static auto Get(){return current;}bool Active(){return active;}int EnemyFaction(){return 1;}bool IsMissionEnemy(int f){return f==1;}auto Command(){return hq;}auto Forces(){return ledger;}}; namespace ERR_Actors{bool CanFight(IEntity* a){return a->alive;}bool Alive(IEntity* a){return a->alive;}bool Player(IEntity* a){return a->player;}auto Group(IEntity* a){return a->group;}} namespace ERR_ConvoyRules{bool capture=false;bool Captured(Vehicle*,SCR_AIGroup* group){return capture||group->players>0;}bool PartOf(IEntity* a,IEntity* b){return a==b;}} namespace CompartmentAccessComponent{auto GetVehicleIn(IEntity* actor){return actor->vehicle;}} -struct ERR_EnemyGunnerSkill{static bool CapturedGroup(ERR_GameMode*,SCR_AIGroup*);static bool OriginalGunner(ERR_GameMode*,SCR_AIGroup*,IEntity*);static EAISkill Profile(EAISkill,bool);}; +struct ERR_EnemyGunnerSkill{static ERR_ConvoyTask* TaskForGroup(ERR_HQ*,SCR_AIGroup*);static bool CapturedGroup(ERR_GameMode*,SCR_AIGroup*);static bool OriginalGunner(ERR_GameMode*,SCR_AIGroup*,IEntity*);static EAISkill Profile(EAISkill,bool);}; struct NativeCombat{IEntity* owner=nullptr;EAISkill skill=EAISkill::REGULAR;int writes=0;auto GetOwner(){return owner;}auto GetAISkill(){return skill;}virtual void SetAISkill(EAISkill value){writes++;skill=value;}virtual void ResetAISkill(){skill=EAISkill::ROOKIE;}}; ''' + rules + helper + r''' int main(){ @@ -59,6 +59,7 @@ int main(){ ERR_ConvoyReceipt receipt;ERR_ConvoyTask task;task.Receipt=&receipt;task.Vehicles={&unit};ERR_HQ hq;hq.task=&task;mode.hq=&hq; ERR_ForceRecord record;Ledger ledger;ledger.record=&record;mode.ledger=&ledger; assert(ERR_EnemyGunnerSkill::OriginalGunner(&mode,&group,&actor)); + ERR_ConvoyTask empty;hq.task=∅hq.extra=&task;assert(ERR_EnemyGunnerSkill::OriginalGunner(&mode,&group,&actor));hq.task=&task;hq.extra=nullptr; // Every native/canonical ownership fact independently matters. for(int i=0;i<25;i++){ auto savedActor=actor;auto savedVehicle=vehicle;auto savedGroup=group;auto savedUnit=unit;auto savedTask=task;auto savedReceipt=receipt;auto savedRecord=record;auto savedSlot=slot; @@ -96,9 +97,9 @@ int main(){ SCR_AICombatComponent combat;combat.owner=&actor;combat.ERR_BindEnemySkill(&mode,&group);assert(combat.skill==EAISkill::CYLON); combat.ResetAISkill();assert(combat.skill==EAISkill::CYLON); combat.SetAISkill(static_cast(90));assert(combat.skill==EAISkill::CYLON); - actor.vehicle=nullptr;slot.occupant=nullptr;combat.ERR_RefreshEnemySkill();assert(static_cast(combat.skill)==90); + actor.vehicle=nullptr;slot.occupant=nullptr;combat.ERR_RefreshEnemySkill();assert(combat.skill==EAISkill::CYLON); actor.vehicle=&vehicle;slot.occupant=&actor;combat.ERR_RefreshEnemySkill();assert(combat.skill==EAISkill::CYLON); - combat.SetAISkill(EAISkill::ROOKIE);slot.occupant=nullptr;combat.ERR_RefreshEnemySkill();assert(combat.skill==EAISkill::EXPERT); + combat.SetAISkill(EAISkill::ROOKIE);slot.occupant=nullptr;combat.ERR_RefreshEnemySkill();assert(combat.skill==EAISkill::CYLON); slot.occupant=&actor;combat.ERR_RefreshEnemySkill();assert(combat.skill==EAISkill::CYLON); // Captured group refresh cannot mutate any soldier. ERR_ConvoyRules::capture=true;int writes=combat.writes;combat.ERR_RefreshEnemySkill();assert(combat.writes==writes);ERR_ConvoyRules::capture=false; @@ -106,10 +107,10 @@ int main(){ // A legitimately high underlying setting stays high after leaving a gunner seat. combat.SetAISkill(EAISkill::CYLON);combat.ERR_BindEnemySkill(&mode,&group);slot.occupant=nullptr;combat.ERR_RefreshEnemySkill();assert(combat.skill==EAISkill::CYLON); combat.ERR_ReleaseEnemySkill(&mode);assert(combat.skill==EAISkill::CYLON); - // Ordinary original infantry retain the existing floor without convoy identity. - hq.task=nullptr;combat.SetAISkill(EAISkill::REGULAR);combat.ERR_BindEnemySkill(&mode,&group);assert(combat.skill==EAISkill::EXPERT); - combat.SetAISkill(static_cast(90));assert(static_cast(combat.skill)==90); + // Ordinary original infantry also receive maximum skill without convoy identity. + hq.task=nullptr;combat.SetAISkill(EAISkill::REGULAR);combat.ERR_BindEnemySkill(&mode,&group);assert(combat.skill==EAISkill::CYLON); + combat.SetAISkill(static_cast(90));assert(combat.skill==EAISkill::CYLON); combat.ERR_ReleaseEnemySkill(&mode);assert(combat.skill==EAISkill::REGULAR); } ''') -print('PASS: actual original turret identity, native/canonical guards, scoped >=80/>=100 profile, reset, dismount/reboard, requested-skill preservation and restoration') +print('PASS: actual original turret identity, native/canonical guards, scoped maximum-skill profile, reset, dismount/reboard, requested-skill preservation and restoration') diff --git a/tests/test_enemy_optics.py b/tests/test_enemy_optics.py index ed6592c..71c25b7 100644 --- a/tests/test_enemy_optics.py +++ b/tests/test_enemy_optics.py @@ -27,6 +27,7 @@ code=code.replace('EWeaponType type','int type').replace('foreach (','for (').re for name in ('ERR_EnemyEquipmentRules','ERR_HQ','Math','EStoragePurpose','EWeaponType'):code=code.replace(name+'.',name+'::') for name in ('entity','child','weapon','sights','slot','job','manager','inventory','storage','inspection'): code=re.sub(r'\b'+name+r'\.',name+'->',code) +code=code.replace('mode.Content().Allows(prefab)', 'mode->Content()->Allows(prefab)').replace('mode.Content()', 'mode->Content()') code=code.replace('job->Operation.','job->Operation->') code=re.sub(r'\b(bool|int|IEntity|SCR_AIGroup) (\w+);',r'\1 \2{};',code) code=re.sub(r'\b(ERR_EnemyEquipmentCallback\*) (\w+);',r'\1 \2{};',code) @@ -38,7 +39,7 @@ preamble=r''' #include using string=std::string;using ResourceName=std::string; templatestruct Array:std::vector{using std::vector::vector;int Count(){return this->size();}bool IsEmpty(){return this->empty();}void Insert(T t){this->push_back(t);}void Remove(int index){this->erase(this->begin()+index);}bool Contains(T t){return std::find(this->begin(),this->end(),t)!=this->end();}}; -struct Entity;struct Group;struct Weapon;struct Manager;struct Inventory;struct Storage;struct Slot;struct Sights;struct ERR_GameMode{}; +struct Entity;struct Group;struct Weapon;struct Manager;struct Inventory;struct Storage;struct Slot;struct Sights;struct ContentPolicy{bool rhs=true;bool Allows(string p){return rhs||p.find("{C850A33226B8F9C1}")!=string::npos||p.find("{D2018EDB1BBF4C88}")!=string::npos||p.find("{BD496EE1B40DC510}")!=string::npos;}};struct ERR_GameMode{ContentPolicy content;ContentPolicy* Content(){return &content;}}; using IEntity=Entity*;using SCR_AIGroup=Group*;using BaseWeaponComponent=Weapon*;using BaseWeaponManagerComponent=Manager*; using InventoryStorageManagerComponent=Inventory*;using WeaponAttachmentsStorageComponent=Storage*;using InventoryStorageSlot=Slot*;using AttachmentSlotComponent=Slot*;using BaseSightsComponent=Sights*; struct ScriptedInventoryOperationCallback{virtual void OnComplete(){}virtual void OnFailed(){}virtual void OnAllComplete(){}virtual void OnAllFailed(){}virtual ~ScriptedInventoryOperationCallback()=default;}; @@ -68,6 +69,8 @@ struct Fixture{ERR_GameMode mode;Group group,other;Entity actor,rifle;Weapon wea Fixture(){actor.group=&group;actor.manager=&manager;actor.inventory=&inv;manager.weapons={&rifle};rifle.weapon=&weapon;rifle.storage=&storage;weapon.owner=&rifle;storage.slots={&slot};} }; int main(){ + {Fixture f;f.mode.content.rhs=false;f.inv.fit=false;ERR_EnemyOptics s(&f.actor,&f.group);for(int i=0;i<5;i++)s.Check(&f.mode);assert(f.inv.submits==0&&f.inv.checks==12);} + // Existing mounted/nested/sights magnified optics are preserved. Occupied red dots aren't overwritten. for(int style=0;style<3;style++){Fixture f;Entity optic,adapter;Sights sights;Slot attach; optic.scope2D=style==0;optic.scope2DPip=style==1;optic.scopePip=style==2;if(style==0){f.rifle.child=&adapter;adapter.child=&optic;} diff --git a/tests/test_external_addons.py b/tests/test_external_addons.py index cbcd421..a759214 100644 --- a/tests/test_external_addons.py +++ b/tests/test_external_addons.py @@ -38,7 +38,7 @@ class ExternalAddonsTests(unittest.TestCase): value=copy.deepcopy(self.value);value['addons'][ext.DIRECT[0]]['dependencies'].append('5F16D7E4A1CBE075') (root/'docs/external-addons.json').write_text(json.dumps(value)) with self.assertRaisesRegex(ValueError,'transitive'):ext.policy(root) - def test_only_exact_six_are_exposed_and_hashed(self): + def test_only_exact_authorized_closure_is_exposed_and_hashed(self): unrelated=self.source/'original';unrelated.mkdir();(unrelated/'addon.gproj').write_text('GameProject { GUID "5F16D7E4A1CBE075" }') records=ext.prepare_profile(self.addons,self.source,self.value,self.cache) self.assertEqual(set(records),set(ext.ALLOWED)) diff --git a/tests/test_faction_selection.py b/tests/test_faction_selection.py new file mode 100644 index 0000000..2c86f8d --- /dev/null +++ b/tests/test_faction_selection.py @@ -0,0 +1,50 @@ +"""Execute role resolution: reserve explicit choices, then sample without replacement.""" +import re +from policy_harness import ROOT, body, run +path = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_FactionSelection.c' +code = '\n'.join(body(path, signature) for signature in ['static bool Resolve(', 'static bool Distinct(']) +code = code.replace('array available', 'Array available').replace('array fixed', 'Array fixed') +code = code.replace('map', 'Map&').replace('foreach (', 'for (').replace('Math.RandomInt', 'RandomInt') +run(r''' +#include +#include +#include +#include +#include +using string=std::string; +template struct Array:std::vector { + using std::vector::vector; + int Count()const{return this->size();} bool IsEmpty(){return this->empty();} + bool Contains(T v){return std::find(this->begin(),this->end(),v)!=this->end();} + void Insert(T v){this->push_back(v);} void Remove(int i){this->erase(this->begin()+i);} + void RemoveItem(T v){auto i=std::find(this->begin(),this->end(),v);if(i!=this->end())this->erase(i);} +}; +struct Map:std::map{bool Contains(string k){return count(k);}string Get(string k){return at(k);}void Set(string k,string v){(*this)[k]=v;}void Clear(){clear();}}; +struct Rules { + static Array Roles(){return {"escapists","enemy","independent"};} + static bool Find(string id){return id=="us"||id=="ussr"||id=="fia"||id=="rhs";} + static int RandomInt(int,int end){static unsigned x=17;x=x*1664525u+1013904223u;return x%end;} +''' + code + r''' +}; +int main(){ + Map saved,resolved; + for(auto role:Rules::Roles())saved.Set(role,"random"); + for(int n=0;n<1000;n++){ + assert(Rules::Resolve({"us","ussr","fia","rhs"},saved,resolved)); + assert(Rules::Distinct(resolved)&&resolved.size()==3); + } + saved.Set("enemy","us");saved.Set("independent","fia"); + for(int n=0;n<100;n++){ + assert(Rules::Resolve({"us","ussr","fia"},saved,resolved)); + assert(resolved.Get("escapists")=="ussr"&&resolved.Get("enemy")=="us"&&resolved.Get("independent")=="fia"); + } + saved.Set("enemy","rhs"); + assert(Rules::Resolve({"us","ussr","fia"},saved,resolved)); + assert(resolved.Get("enemy")!="rhs"&&resolved.Get("independent")=="fia"); + saved.Set("enemy","fia");assert(!Rules::Resolve({"us","ussr","fia"},saved,resolved));assert(resolved.empty()); + saved.Set("enemy","unknown");assert(!Rules::Resolve({"us","ussr","fia"},saved,resolved)); + saved.Set("enemy","us");assert(!Rules::Resolve({"us","fia"},saved,resolved)); + saved.erase("escapists");assert(!Rules::Resolve({"us","ussr","fia"},saved,resolved)); +} +''') +print('PASS: actual faction resolution reserves explicit roles, samples without replacement, substitutes disabled selections, and rejects duplicate/unknown/missing choices and insufficient pools') diff --git a/tests/test_force_ledger.py b/tests/test_force_ledger.py index 4458cab..6ac8ee9 100644 --- a/tests/test_force_ledger.py +++ b/tests/test_force_ledger.py @@ -190,14 +190,14 @@ int main(){ assert(ledger.Unregister(physical->Id)&&!ledger.FindGroup(&group)); assert(!ledger.Register("physical",K::GARRISON,{0,0,0},4)); ledger.Clear();assert(ledger.Count()==0&&ledger.m_Sources.Count()==0&&ledger.m_ById.Count()==0); - // All 128 formations remain bounded through a simulated hour; stagger only affects snapshot age. + // All 256 formations remain bounded through a simulated hour; stagger only affects snapshot age. ERR_ForceLedger hour;ERR_ForceSimulation hourSim(&hour);auto circuit=square(true); - for(int i=0;i<128;i++)assert(hour.Register(std::to_string(i),K::PATROL,{0,10,0},4,circuit)); + for(int i=0;i<256;i++)assert(hour.Register(std::to_string(i),K::PATROL,{0,10,0},4,circuit)); assert(!hour.Register("overflow",K::PATROL,{0,0,0},4)); assert(!hour.Register("overflow2",K::PATROL,{0,0,0},4)&&logs==1); for(int t=1000;t<=3600000;t+=1000)hourSim.Tick(t); - assert(hour.Count()==128&&hour.m_Sources.Count()==128&&hour.m_ById.Count()==128); - for(int i=0;i<128;i++){ + assert(hour.Count()==256&&hour.m_Sources.Count()==256&&hour.m_ById.Count()==256); + for(int i=0;i<256;i++){ auto r=hour.At(i);assert(!r->Eliminated&&r->Strength==4&&r->Casualties==0); assert(3600000-r->LastSimulatedAt<=1000); float distance=std::fmod(1.4*r->LastSimulatedAt/1000,56); @@ -208,7 +208,7 @@ int main(){ else actual=56-r->Position.z; assert(std::min(std::abs(actual-distance),56-std::abs(actual-distance))<.1); } - assert(hour.Status()=="128,0,128,0,512,0"); + assert(hour.Status()=="256,0,256,0,1024,0"); assert(hour.Unregister(hour.At(0)->Id)); assert(!hour.Register("replacement",K::PATROL,{0,0,0},4)); // retired identity consumes finite pool } @@ -221,4 +221,4 @@ for path in (L,S): assert all(len(line)<120 for line in text.splitlines()),path for signature in ('protected void Route(', 'void Tick('): assert 'new ' not in body(S,signature) -print('PASS: actual force ledger, finite casualties/identity/cap, routes/arrival/elevation/overshoot, server and physical guards, staggered 128-record simulated hour without world queries') +print('PASS: actual force ledger, finite casualties/identity/cap, routes/arrival/elevation/overshoot, server and physical guards, staggered 256-record simulated hour without world queries') diff --git a/tests/test_generated_sites.py b/tests/test_generated_sites.py new file mode 100644 index 0000000..a46ded2 --- /dev/null +++ b/tests/test_generated_sites.py @@ -0,0 +1,36 @@ +"""Run the actual bounded placement scheduler with terrain and clock adapters.""" +import re +from policy_harness import ROOT,body,run +p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_GeneratedSites.c' +s=body(p,'ERR_PrepareResult Tick(') +s=s.replace('ERR_SiteDefinition site','Site* site').replace('site.','site->').replace('m_Terrain.Site','m_Terrain.SiteAt') +s=s.replace('System.GetTickCount','Clock::Now').replace('Replication.IsServer','Server').replace('Math.RandomFloat','RandomFloat').replace('ERR_PrepareResult.','Result::') +s=s.replace('float floor;','float floor=0;') +run(r''' +#include +#include +#include +using string=std::string; +struct vector{float a[3];float& operator[](int i){return a[i];}};vector Vector(float x,float y,float z){return {{x,y,z}};} +namespace Result{enum Value{FAILED=-1,PENDING,READY};}using ERR_PrepareResult=Result::Value; +namespace Clock {int now=0,step=1;int Now(){int t=now;now+=step;return t;}} +bool Server(){return true;}float RandomFloat(float a,float b){return (a+b)/2;}templatevoid PrintFormat(T...){} +struct Site{vector Position=Vector(10,0,20);float Heading=0;bool PlacementResolved=false;string Id="site";}; +templatestruct Array:std::vector{using std::vector::operator=;int Count(){return this->size();}}; +struct Terrain{bool clear=true;int queries=0;bool SiteAt(Site*,vector,float,float& height){queries++;height=42;return clear;}}; +struct Scheduler{ + bool m_Started=true;ERR_PrepareResult m_Result=Result::PENDING;int m_Index=0,m_Attempts=0,m_Began=0; + vector m_Low=Vector(0,0,0),m_High=Vector(10000,0,10000);Arraym_Pending;Terrain m_Terrain;string m_Error; + bool Separated(Site*,vector,bool){return true;} +'''+s+r''' +}; +int main(){ + {Scheduler s;s.m_Started=false;assert(s.Tick()==Result::FAILED);} + {Scheduler s;assert(s.Tick()==Result::READY);assert(s.m_Terrain.queries==0);} + {Clock::now=0;Scheduler s;Site a,b;s.m_Pending={&a,&b};while(s.Tick()==Result::PENDING){}assert(s.m_Result==Result::READY&&a.PlacementResolved&&b.PlacementResolved);assert(a.Position[1]>42&&a.Position[1]<42.1);int calls=s.m_Terrain.queries;assert(s.Tick()==Result::READY&&s.m_Terrain.queries==calls);} + {Clock::now=0;Scheduler s;Site a;s.m_Pending={&a};s.m_Terrain.clear=false;assert(s.Tick()==Result::PENDING);assert(s.m_Terrain.queries<10&&!a.PlacementResolved);Clock::now=30001;assert(s.Tick()==Result::FAILED&&!s.m_Error.empty()&&!a.PlacementResolved);int calls=s.m_Terrain.queries;assert(s.Tick()==Result::FAILED&&s.m_Terrain.queries==calls);} + {Clock::now=0;Scheduler s;Site a;s.m_Pending={&a};s.m_Attempts=12000;assert(s.Tick()==Result::READY&&a.Position[0]==10&&a.Position[2]==20&&a.PlacementResolved);} + {Clock::now=30001;Scheduler s;Site a;s.m_Pending={&a};assert(s.Tick()==Result::READY&&a.Position[0]==10&&a.Position[2]==20);} +} +''') +print('PASS: actual generation scheduler bounds each tick, enforces deadline/candidate ceiling, validates fallback, commits only accepted positions, and terminates without repeated searches') diff --git a/tests/test_hq_ammo_dispatch.py b/tests/test_hq_ammo_dispatch.py index 446bc0f..53eb1f4 100644 --- a/tests/test_hq_ammo_dispatch.py +++ b/tests/test_hq_ammo_dispatch.py @@ -7,14 +7,14 @@ for method in ('protected void Investigate(', 'protected void Retask(', 'protect # Execute the production delivery admission before unrelated native waypoint creation. s=body(H,'protected void DeliverOrder(') s=s[:s.index(' if (task.Investigation())')]+' admitted++;\n }' -s=s.replace('protected ','').replace('null','nullptr').replace('ERR_HQUnit unit','ERR_HQUnit* unit') +s=s.replace('protected ','').replace('null','nullptr').replace('.IsEmpty()', '.empty()').replace('ERR_HQUnit unit','ERR_HQUnit* unit') s=s.replace('ERR_HQReport order','ERR_HQReport* order').replace('ERR_HQTask task','ERR_HQTask* task') for name in ('unit','task','order'):s=s.replace(name+'.',name+'->') run(r''' #include #include struct ERR_HQReport{}; -struct ERR_HQTask{float ExpiresAt=100000;bool ReleaseOnly=false;ERR_HQTask* ReplacesTask=nullptr;}; +struct ERR_HQTask{std::string SecuritySite;float ExpiresAt=100000;bool ReleaseOnly=false;ERR_HQTask* ReplacesTask=nullptr;}; struct ERR_HQUnit{ERR_HQReport* PendingOrder=nullptr;ERR_HQTask* PendingTask=nullptr;ERR_HQTask* Task=nullptr; std::string ForceSourceId="original-1";bool eligible=true;int cancellations=0; bool Eligible(void*){return eligible;}void CancelPending(){cancellations++;PendingOrder=nullptr;PendingTask=nullptr;}}; diff --git a/tests/test_hq_assignment_lifecycle.py b/tests/test_hq_assignment_lifecycle.py index 5761bd1..9b54477 100644 --- a/tests/test_hq_assignment_lifecycle.py +++ b/tests/test_hq_assignment_lifecycle.py @@ -5,6 +5,7 @@ p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c' unit='\n'.join(body(p,s) for s in ('void CancelPending(', 'ERR_HQTask PlannedTask(', 'void ReleaseAssignment(', 'void Release()')) lost='\n'.join(body(p,s) for s in ('protected void FinishWatch(', 'protected void LostOrder(')).replace('protected ', '') def adapt(code): + code=code.replace('.IsEmpty()', '.empty()') code=code.replace('null','nullptr').replace('Math.','Math::').replace('ERR_GameMode.Get()', 'ERR_GameMode::Get()') code=code.replace('SCR_EntityHelper.DeleteEntityAndChildren', 'SCR_EntityHelper::DeleteEntityAndChildren') code=code.replace('ERR_HQUnit unit', 'ERR_HQUnit* unit') @@ -21,7 +22,7 @@ run(r''' #include #include struct Math {static float Min(float a,float b){return std::min(a,b);}}; -struct ERR_HQTask {bool ReleaseOnly=false;int BeliefId=0;std::string WatchSourceId;int WatchMessageId=0,WatchRequestId=0;bool Investigation(){return !WatchSourceId.empty();}}; +struct ERR_HQTask {bool ReleaseOnly=false;int BeliefId=0;std::string WatchSourceId,SecuritySite;int WatchMessageId=0,WatchRequestId=0;bool Investigation(){return !WatchSourceId.empty();}}; struct ERR_HQReport {}; struct ERR_HQRoute {}; struct AIWaypoint {int deleted=0;}; @@ -98,6 +99,9 @@ int main(){ ERR_HQTask recall;recall.ReleaseOnly=true;u.PendingTask=&recall;u.PendingOrder=&newReport;u.Downlink=&route; hq.LostOrder(&u,5000);assert(!u.PendingTask&&u.Task==&activeWatch&&u.Waypoint==&oldWaypoint&&hq.m_Watch.calls==1); u.Task=nullptr;u.Waypoint=nullptr;u.Assignment=nullptr; + ERR_HQTask security;security.SecuritySite="radio-1";u.PendingTask=&security;u.PendingOrder=&newReport;u.Downlink=&route; + hq.window.NextHunt=300000;hq.LostOrder(&u,5000); + assert(!u.PendingTask&&!u.Downlink&&hq.window.NextHunt==300000&&hq.m_Watch.calls==1); // Unrelated belief cannot suppress bounded retry, and existing sooner retry remains sooner. peer.PendingTask=&oldTask;u.PendingTask=&newTask;hq.window.NextHunt=5000; hq.LostOrder(&u,2000);assert(hq.window.NextHunt==5000); diff --git a/tests/test_hq_body_observer.py b/tests/test_hq_body_observer.py index ec245bc..5e10cde 100644 --- a/tests/test_hq_body_observer.py +++ b/tests/test_hq_body_observer.py @@ -157,11 +157,11 @@ int main(){ assert(!service.Observe(&contenders[1],1000,report)); service.Observe(&contenders[1],6000,report);assert(service.m_Groups[1]->NextScan==21000); // Registry cap is finite, logs once, and deleted identities free future slots. - service.Stop();logs=0;Entity actors[160];Agent agents[160];Group groups[5]; - for(int g=0;g<5;g++){for(int i=0;i<32;i++){int n=g*32+i;agents[n].actor=&actors[n];groups[g].agents.Insert(&agents[n]);}service.RegisterGroup(&groups[g],Roster(&groups[g]));} - assert(service.m_Roster.Count()==128&&logs==1);service.RegisterGroup(&groups[4],Roster(&groups[4]));assert(logs==1); + service.Stop();logs=0;Entity actors[4128];Agent agents[4128];Group groups[129]; + for(int g=0;g<129;g++){for(int i=0;i<32;i++){int n=g*32+i;agents[n].actor=&actors[n];groups[g].agents.Insert(&agents[n]);}service.RegisterGroup(&groups[g],Roster(&groups[g]));} + assert(service.m_Roster.Count()==4096&&logs==1);service.RegisterGroup(&groups[128],Roster(&groups[128]));assert(logs==1); for(int i=0;i<32;i++)service.m_Roster[i]->Corpse=nullptr; - service.RegisterGroup(&groups[4],Roster(&groups[4]));assert(service.m_Roster.Count()==128); + service.RegisterGroup(&groups[128],Roster(&groups[128]));assert(service.m_Roster.Count()==4096); service.Stop();assert(service.m_Roster.IsEmpty()); } ''') diff --git a/tests/test_hq_convoy_combat.py b/tests/test_hq_convoy_combat.py index de79387..5397c43 100644 --- a/tests/test_hq_convoy_combat.py +++ b/tests/test_hq_convoy_combat.py @@ -6,9 +6,9 @@ H = ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c' def cpp(s): s=s.replace('protected ', '').replace('null','nullptr').replace('foreach (','for (') for x in ('ERR_HQPursuitRules','vector','string'): s=s.replace(x+'.',x+'::') - for typ in ('ERR_HQUnit','ERR_HQReport','ERR_HQWatchEntry','ERR_HQRoute','ERR_ConvoyCombatOrder','ERR_ConvoyVehicle'): + for typ in ('ERR_HQUnit','ERR_HQReport','ERR_HQWatchEntry','ERR_HQRoute','ERR_ConvoyCombatOrder','ERR_ConvoyVehicle','ERR_ConvoyTask'): s=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',s) - for x in ('entry','unit','best','report','vehicle','contact','route','order','m_ConvoyTask'): + for x in ('entry','unit','best','report','vehicle','contact','route','order','task','m_ConvoyTask'): s=s.replace(x+'.',x+'->') s=s.replace('->Combat.', '->Combat->').replace('->Receipt.', '->Receipt->') s=s.replace('unit->Downlink.', 'unit->Downlink->') @@ -16,7 +16,7 @@ def cpp(s): s=s.replace('bool accepted;', 'bool accepted = false;') return s methods='\n'.join(cpp(body(H,s)) for s in ('protected bool ConvoyPositionKnown(', - 'protected ERR_HQReport ConvoyContact(', 'protected void PlanConvoy(', 'protected void DeliverConvoyOrder(')) + 'protected ERR_HQReport ConvoyContact(', 'protected void PlanConvoy(', 'protected void PlanConvoyTask(', 'protected void DeliverConvoyOrder(')) downlink=cpp(body(H,'if (unit.Downlink)\n')) cancel=cpp(body(H,'void CancelPending(')) src=H.read_text() @@ -40,20 +40,21 @@ struct Watch {std::map entries;ERR_HQWatchEntry* Find( struct Journal {int seq=100;int Message(){return ++seq;}void Write(string,string,int){}}; struct ERR_HQRoute {int MessageId=0;string Payload;int result=0;int Advance(float,Journal&){return result;}}; struct Network {bool available=true;int calls=0;ERR_HQRoute* Route(void*,bool down){assert(down);calls++;return available?new ERR_HQRoute:nullptr;}}; -struct ERR_ConvoyCombatOrder {int CommandId=0,ReportId=0,VehicleIndex=0;string SourceId;SCR_AIGroup* Group=nullptr;bool Hold=false;float ObservedAt=-1,IssuedAt=0,ExpiresAt=0;}; +struct ERR_ConvoyCombatOrder {int CommandId=0,ReportId=0,VehicleIndex=0;string SourceId;SCR_AIGroup* Group=nullptr;vector Position;bool Hold=false;float ObservedAt=-1,IssuedAt=0,ExpiresAt=0;}; struct CombatState {bool Held=false;int LastCommandId=0,ReportId=0;float ObservedAt=-1;}; struct ERR_ConvoyVehicle {int SupplyToken=0,Index=0;SCR_AIGroup* Group=nullptr;bool Released=false,Arrived=false,Registered=true;CombatState state;CombatState* Combat=&state;}; struct Receipt {string SourceId="convoy-1";}; -struct ConvoyTask {bool Stopped=false,Ready=true;struct Receipt receipt;struct Receipt* Receipt=&receipt;std::vector Vehicles;}; -struct Service {int calls=0;bool valid=true;ERR_ConvoyCombatOrder* last=nullptr; - bool ApplyCombatOrder(ConvoyTask* task,ERR_ConvoyCombatOrder* o,float){calls++;last=o;if(!task||!valid)return false; +struct ERR_ConvoyTask {bool Stopped=false,Ready=true;struct Receipt receipt;struct Receipt* Receipt=&receipt;std::vector Vehicles;}; +struct Service {ERR_ConvoyTask* task=nullptr;int Count(){return task?1:0;}ERR_ConvoyTask* At(int){return task;}ERR_ConvoyTask* Find(string id){return task&&task->Receipt->SourceId==id?task:nullptr;} + int calls=0;bool valid=true;ERR_ConvoyCombatOrder* last=nullptr; + bool ApplyCombatOrder(ERR_ConvoyTask* task,ERR_ConvoyCombatOrder* o,float){calls++;last=o;if(!task||!valid)return false; auto s=task->Vehicles.at(o->VehicleIndex)->Combat;s->Held=o->Hold;s->LastCommandId=o->CommandId;s->ReportId=o->ReportId;s->ObservedAt=o->ObservedAt;return true;}}; struct ERR_HQUnit {SCR_AIGroup* Group=nullptr;bool ReservedForSupport=true;void* Radio=this;string ForceSourceId="vehicle-0"; ERR_HQRoute* Downlink=nullptr;ERR_ConvoyCombatOrder* PendingConvoyOrder=nullptr;void* PendingOrder=nullptr;void* PendingTask=nullptr; bool PendingCheckin=false;float NextStatus=0; '''+cancel+r''' }; -struct HQ {Watch m_Watch;Network m_Network;Journal m_Journal;Service m_Convoys;ConvoyTask* m_ConvoyTask=nullptr; +struct HQ {Watch m_Watch;Network m_Network;Journal m_Journal;Service m_Convoys;ERR_ConvoyTask* m_ConvoyTask=nullptr; std::vector m_Units;std::vector m_Contacts;int ordinary=0,lost=0; void DeliverOrder(ERR_HQUnit*,float){ordinary++;}void LostOrder(ERR_HQUnit* u,float){lost++;u->CancelPending();} '''+methods+r''' @@ -62,7 +63,7 @@ struct HQ {Watch m_Watch;Network m_Network;Journal m_Journal;Service m_Convoys;C } }; int main(){ - HQ h;SCR_AIGroup g;ERR_HQUnit u;u.Group=&g;h.m_Units={&u};ConvoyTask t;h.m_ConvoyTask=&t; + HQ h;SCR_AIGroup g;ERR_HQUnit u;u.Group=&g;h.m_Units={&u};ERR_ConvoyTask t;h.m_ConvoyTask=&t;h.m_Convoys.task=&t; ERR_ConvoyVehicle v;v.Group=&g;t.Vehicles={&v};ERR_HQWatchEntry e;e.ObservedAt=1000;e.Position={0,0}; ERR_HQReport r;r.MessageId=1;r.ObservedAt=2000;r.Position={200,0}; h.PlanConvoy(3000);assert(!u.Downlink); // no delivered own status or target report diff --git a/tests/test_hq_convoy_dispatch.py b/tests/test_hq_convoy_dispatch.py index ec318f0..d03e870 100644 --- a/tests/test_hq_convoy_dispatch.py +++ b/tests/test_hq_convoy_dispatch.py @@ -12,23 +12,24 @@ def cpp(s): ('ERR_Settings.','ERR_Settings::'),('Math.','Math::'),('array','Array&')]:s=s.replace(old,new) for typ in ('ERR_GameMode','ERR_ConvoyReceipt','ERR_ConvoyTask','ERR_SiteRuntime','ERR_HQUnit','ERR_AdminProvider'): s=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',s) - for var in ('m_ConvoyStartup','m_Mode','m_Convoys','m_ConvoyPool','m_ConvoyTask','m_HQ','site','unit','receipt','mode','provider'): + for var in ('m_SupplyConvoys','m_ConvoyStartup','m_Mode','m_Convoys','m_ConvoyPool','m_ConvoyTask','m_HQ','site','unit','receipt','mode','provider'): s=s.replace(var+'.',var+'->') s=s.replace('site->Definition.', 'site->Definition->') - for name in ('GetGame()','GetWorld()','Forces()','Sites()','Settings()'):s=s.replace(name+'.',name+'->') + for name in ('GetGame()','GetWorld()','Forces()','Sites()','Settings()','Recon()','AirAssault()','Airfield()','Recovery()'):s=s.replace(name+'.',name+'->') s=s.replace('foreach (ERR_HQUnit* unit : m_Units)','for (ERR_HQUnit* unit : m_Units)') s=s.replace('foreach (ERR_AdminProvider* provider : m_Providers)','for (ERR_AdminProvider* provider : m_Providers)') s=s.replace('foreach (string key : ERR_Settings::Keys())','for (string key : ERR_Settings::Keys())') s=s.replace('mode->Settings()->Get(key).ToString()','string(std::to_string(mode->Settings()->Get(key)))') s=s.replace('bool handled;','bool handled=false;') - s=s.replace('Array& parts = {};','Array parts = {};') + s=s.replace('Array& parts = {};','Array parts = {};').replace('Array& countParts = {};','Array countParts = {};').replace('ERR_CommandArguments.Selection','ERR_CommandArguments::Selection') + s=s.replace('Array& timerParts = {};','Array timerParts = {};') s=s.replace('out int ', 'int& ').replace('ERR_CommandArguments.Integer', 'ERR_CommandArguments::Integer') s=s.replace('value.ToString()', 'string(std::to_string(value))') s=s.replace('"0123456789".IndexOf(', 'string("0123456789").IndexOf(') return s hq='\n'.join(cpp(body(H,s)) for s in ('static bool MissionActive(', 'bool PrepareConvoy(', 'bool StartConvoy(', 'bool StartScenarioConvoy(', 'string ConvoyStatus(')) -provider=cpp(body(P,'protected void ConvoyAutoSetting('))+cpp(body(P,'protected void StingerSetting('))+cpp(body(P,'override bool Execute(')) +provider=cpp(body(P,'protected void CountSetting('))+cpp(body(P,'protected void ConvoyAutoSetting('))+cpp(body(P,'protected void StingerSetting('))+cpp(body(P,'override bool Execute(')) integer=cpp(body(A,'static bool Integer(')) validator=cpp(body(BASE/'addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c','static bool Valid(')) extension=cpp(body(A,'protected bool Extension(')) @@ -65,6 +66,7 @@ void string::Split(const char* separator,Array& parts,bool skipEmpty)con } } struct ERR_CommandArguments{ + static bool Selection(string key,string text,int& value){if(key!="convoyCount")return false;if(text=="random"){value=-1;return true;}return Integer(text,value)&&value>=0&&value<=20;} '''+integer+r''' }; struct vector{float x=0,y=0,z=0;}; @@ -78,10 +80,12 @@ struct ERR_SiteRuntime{struct Definition* Definition=nullptr;}; struct Sites{ERR_SiteRuntime* site=nullptr;int finds=0;string last; ERR_SiteRuntime* Find(string id){finds++;last=id;return site;}}; struct ERR_Settings{ - int automatic=1,stinger=15,writes=0;bool saveFails=false; - static Array Keys(){return {}; }int Get(string key){return key=="stingerChanceOneIn"?stinger:automatic;} + int automatic=1,stinger=15,convoy=3,writes=0;bool saveFails=false; + static Array Keys(){return {}; }int Get(string key){if(key=="convoyCount")return convoy;return key=="stingerChanceOneIn"?stinger:automatic;} '''+validator+r''' - bool Set(string key,int value){writes++;if(saveFails||!Valid(key,value))return false; + static string SelectionText(int value){return value==-1?string("random"):string(std::to_string(value));} + bool Set(string key,int value){writes++;if(saveFails||(!Valid(key,value)&&!(key=="convoyCount"&&value==-1)))return false; + if(key=="convoyCount"){convoy=value;return true;} if(key=="automaticConvoyEnabled"){automatic=value;return true;} assert(key=="stingerChanceOneIn");stinger=value;return true;} }; @@ -118,10 +122,15 @@ struct Service{ if(!preflight)return nullptr; receipt->Consumed=true;task.Stopped=partial;task.retained=partial?3:6;return &task; } + int Count(){return 0;}ERR_ConvoyTask* At(int){return &task;} string Status(){statusCalls++;return task.Stopped?"stopped; retained vehicles=3":"convoy service status";} }; +struct Supply{bool StartOne(){return false;}string Status(){return "supply";}}; struct ERR_ConvoyStartup{string Status(){return "startup snapshot";}}; +struct ReconService{string Status(){return "recon";}void Return(){}bool SetDelay(int n){return n>=1&&n<=1440;}}; struct HQ{ + ReconService recon;ReconService* Recon(){return &recon;}ReconService* AirAssault(){return &recon;}ReconService* Airfield(){return &recon;}ReconService* Recovery(){return &recon;} + Supply* m_SupplyConvoys=nullptr;bool ConvoyAdmissionReady(){return true;} ERR_ConvoyStartup* m_ConvoyStartup=nullptr; ERR_GameMode* m_Mode;Pool* m_ConvoyPool;Service* m_Convoys;ERR_ConvoyTask* m_ConvoyTask=nullptr; Arraym_ConvoyPositions,m_ConvoyAngles;ERR_ForceRoute* m_ConvoyRoute=nullptr;string m_ConvoyFailure; @@ -246,6 +255,14 @@ int main(){ Service consumedService;HQ consumed(&mode,&consumedPool,&consumedService); assert(!consumed.StartScenarioConvoy()&&!consumed.m_ConvoyTask&&consumedService.calls==0); assert(consumed.ConvoyStatus().find("finite convoy allowance")!=string::npos); + for(string command:{"convoys set 0","hq convoy count set 20","convoys set random"}){ + int calls=service.calls;replies.Clear();admin.Execute(&mode,true,command,replies);assert(replies.Count()==1&&replies[0].find("saved for next round")!=std::string::npos&&service.calls==calls); + } + assert(mode.settings.convoy==-1);writes=mode.settings.writes;admin.Execute(&mode,false,"convoys set 5",replies);assert(mode.settings.convoy==-1&&mode.settings.writes==writes); + for(string invalid:{"convoys set 21","convoys set -2","convoys set 1 extra"}){admin.Execute(&mode,true,invalid,replies);assert(mode.settings.convoy==-1);} + mode.settings.saveFails=true;admin.Execute(&mode,true,"convoys set 4",replies);assert(mode.settings.convoy==-1);mode.settings.saveFails=false; + admin.Execute(&mode,true,"convoys show",replies);assert(replies.Count()==1&&replies[0].find("random")!=std::string::npos); + } ''') for sig in ('bool PrepareConvoy(', 'bool StartConvoy(', 'bool StartScenarioConvoy('): diff --git a/tests/test_hq_coverage.py b/tests/test_hq_coverage.py index 4157819..baae0f2 100644 --- a/tests/test_hq_coverage.py +++ b/tests/test_hq_coverage.py @@ -131,14 +131,14 @@ int main(){ assert(!bounded.Recent({250,12,250},2000)&&bounded.Recent(item.Position,2000)); // Replay watermarks do not grow or evict an old source's identity under pressure. Coverage sources; - for(int i=1;i<=128;i++){ + for(int i=1;i<=256;i++){ item=ERR_HQSearchReport{};item.SourceId=i;item.ObservedAt=i; assert(sources.Ingest(&item,1000)); } - item.SourceId=129;item.ObservedAt=129; - assert(!sources.Ingest(&item,1000)&&sources.m_Messages.Count()==128); - item.SourceId=1;item.MessageId=2;item.ObservedAt=130; - assert(sources.Ingest(&item,1000)&&sources.m_Messages.Count()==128); + item.SourceId=257;item.ObservedAt=257; + assert(!sources.Ingest(&item,1000)&&sources.m_Messages.Count()==256); + item.SourceId=1;item.MessageId=2;item.ObservedAt=258; + assert(sources.Ingest(&item,1000)&&sources.m_Messages.Count()==256); // Nearest unswept candidates remain inside uncertainty, including a small circle. Coverage search;vector center{250,12,250},destination; assert(search.Select(center,25,{1000,0,250},1000,destination)); diff --git a/tests/test_hq_delivered_task.py b/tests/test_hq_delivered_task.py index 6788dd8..605f8cf 100644 --- a/tests/test_hq_delivered_task.py +++ b/tests/test_hq_delivered_task.py @@ -13,7 +13,7 @@ def cpp(s): s=re.sub(r'foreach \((ERR_HQUnit\*) (\w+) : ([^)]+)\)',r'for (\1 \2 : \3)',s) for name in ('unit','other','task','planned','order','belief','record','mode','Group','hunt','m_Mode'): s=s.replace(name+'.',name+'->') - s=s.replace('ResourceName ', 'int ') + s=s.replace('ResourceName ', 'int ').replace('.IsEmpty()', '.empty()') s=s.replace('PendingTask.', 'PendingTask->').replace('Task.', 'Task->') s=s.replace('Downlink.', 'Downlink->').replace('Forces().','Forces()->').replace('ERR_ForceRecord* record;','ERR_ForceRecord* record=nullptr;') return s @@ -39,7 +39,7 @@ struct ERR_HQPursuitRules{ '''+rules+r''' }; struct Math{static float Min(float a,float b){return std::min(a,b);}}; -struct ERR_HQTask{bool RoadIntercept=false,ReleaseOnly=false;ERR_HQTask* ReplacesTask=nullptr;string WatchSourceId;int WatchMessageId=0,WatchRequestId=0; +struct ERR_HQTask{string SecuritySite;bool RoadIntercept=false,ReleaseOnly=false;ERR_HQTask* ReplacesTask=nullptr;string WatchSourceId;int WatchMessageId=0,WatchRequestId=0; int BeliefId=1,BeliefRevision=2,Role=0;vector Destination{40,0,50};float QueuedAt=0,ExpiresAt=60000;bool Investigation(){return !WatchSourceId.empty();}string Name(){return "search";}}; struct ERR_HQReport{int MessageId=10;float ObservedAt=0;}; struct ERR_HQRoute{int MessageId=20;}; @@ -116,6 +116,11 @@ int main(){ assert(ERR_Actors::spawns==1&&f.unit.Waypoint&&f.unit.Waypoint->resource==2&&f.unit.Task==&f.next&&f.previous.deletes==1); f.hq.DeliverOrder(&f.unit,45001);assert(ERR_Actors::spawns==1&&f.group.additions==1); f.hq.Cleanup(60000,1,4);assert(!f.unit.Task&&!f.unit.Waypoint&&f.mode.ledger.record.Duty==f.mode.ledger.record.HomeDuty);} + // Security orders carry an inspected-site hypothesis, not a fabricated enemy track. + {Fixture f;f.next.SecuritySite="mortar-1";f.next.BeliefId=999; + f.hq.DeliverOrder(&f.unit,1000);assert(f.unit.Task==&f.next&&f.unit.Waypoint->resource==2); + f.hq.Cleanup(1001,1,4);assert(f.unit.Task==&f.next); + f.hq.Cleanup(60000,1,4);assert(!f.unit.Task&&!f.unit.Waypoint);} // Cancelling a mismatched recall never releases a replacement task which arrived first. {Fixture f;f.next.ReleaseOnly=true;f.next.ReplacesTask=nullptr;f.hq.DeliverOrder(&f.unit,1000);f.Retained();assert(ERR_Actors::spawns==0);} {Fixture f;f.next.ReleaseOnly=true;f.next.ReplacesTask=&f.active;f.hq.DeliverOrder(&f.unit,1000);assert(!f.unit.Task&&!f.unit.Assignment&&!f.unit.Waypoint&&f.previous.deletes==1&&ERR_Actors::spawns==0);} diff --git a/tests/test_hq_local_handoff_intake.py b/tests/test_hq_local_handoff_intake.py index b89e649..fd4eaff 100644 --- a/tests/test_hq_local_handoff_intake.py +++ b/tests/test_hq_local_handoff_intake.py @@ -11,7 +11,7 @@ for name in ('unit','other','route','own','seen','report'): s=s.replace(name+'.',name+'->') for name in ('Uplink','Task'): s=s.replace(name+'.',name+'->') -s=s.replace('Nodes[0].','Nodes[0]->').replace('m_Mode.Settings().Get','m_Mode->Settings()->Get').replace('ERR_HQObservation.','ERR_HQObservation::').replace('null','nullptr') +s=s.replace('Nodes[0].','Nodes[0]->').replace('m_Mode.Settings().Runtime','m_Mode->Settings()->Runtime').replace('ERR_HQObservation.','ERR_HQObservation::').replace('null','nullptr') s=s.replace('ERR_HQRoute* own;','ERR_HQRoute* own=nullptr;').replace('ERR_HQRoute* route;','ERR_HQRoute* route=nullptr;') run(r''' #include @@ -30,7 +30,7 @@ struct ERR_HQObservation{static void Gather(Utility*u,float,Arr&s){return s[0];}void Queued(IEntity){queues++;}}; struct History{IEntity target=nullptr;float time=-1;bool CanQueue(IEntity t,float at,float){return t&&!(t==target&&at<=time);}void Queued(IEntity t,float at,float){target=t;time=at;}}; struct ERR_HQReport{int MessageId=0,Position=0;IEntity Target=nullptr;Group*Source=nullptr;float ObservedAt=0;string Description(float){return "unaltered source sighting";}}; -struct Settings{int Get(string){return 5;}}; +struct Settings{int Runtime(string){return 5;}}; struct Mode{struct Settings values;struct Settings*Settings(){return &values;}}; struct Task{float ArrivedAt=0;}; struct ERR_HQUnit{struct Group*Group=nullptr;struct Utility*Utility=nullptr;IEntity Radio=nullptr;float NextReport=0;ERR_HQRoute*Uplink=nullptr;ERR_HQReport*PendingReport=nullptr;Selector ReportSelection;History SentObservations;int StatusSampler=1;struct Task*Task=nullptr;}; diff --git a/tests/test_hq_uplink_fairness.py b/tests/test_hq_uplink_fairness.py index af4afa3..c27020e 100644 --- a/tests/test_hq_uplink_fairness.py +++ b/tests/test_hq_uplink_fairness.py @@ -19,10 +19,10 @@ def cpp(s): s=s.replace(v+'.',v+'->') for v in ('Utility','ReportSelection','StatusSampler','Group','Task'): s=s.replace('->'+v+'.','->'+v+'->') - s=s.replace('m_Mode.Settings().Get(', 'm_Mode->Settings()->Get(') + s=s.replace('m_Mode.Settings().Runtime(', 'm_Mode->Settings()->Runtime(') s=s.replace('ERR_HQPursuitRules.', 'ERR_HQPursuitRules::') s=s.replace('ERR_HQObservation.','ERR_HQObservation::').replace('vector.DistanceXZ','vector::DistanceXZ').replace('string.Format','Format') - return s.replace('AmmoState.', 'AmmoState->') + return s.replace('AmmoState.', 'AmmoState->').replace('SecuritySite.IsEmpty()', 'SecuritySite.empty()') policy_path=Path(os.environ.get('ERR_AMMO_REPORTING_ROOT', ROOT))/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQAmmoReporting.c' policy=cpp(policy_path.read_text()).replace('class ERR_HQAmmoReporting','struct ERR_HQAmmoReporting').rstrip()+';' methods='\n'.join(cpp(body(P,s)) for s in ('protected void Observe(', 'protected void ObserveBody(', 'protected void StatusReport(', 'protected void Sweep(', 'protected void QueueReports(')) @@ -36,7 +36,7 @@ templatestring Format(T... args){return "diagnostic";} struct vector{float x=0,z=0;static float DistanceXZ(vector a,vector b){return std::hypot(a.x-b.x,a.z-b.z);}}; templatestruct Array:std::vector{bool IsEmpty(){return this->empty();}}; struct Group{vector position;vector GetOrigin(){return position;}}; -struct Settings{int interval=5;int Get(string){return interval;}}; +struct Settings{int interval=5;int Runtime(string){return interval;}}; struct Mode{struct Settings settings;struct Settings* Settings(){return &settings;}}; struct ERR_HQVisualContact{void* Entity=this;vector Position;float ObservedAt=0;}; struct Utility{bool visible=true;}; @@ -55,7 +55,7 @@ struct Sampler{int observations=0,samples=0;bool valid=true;void ObserveDeaths() ERR_HQStatusReport* Sample(float now,int id,void*){samples++;if(!valid)return nullptr;auto r=new ERR_HQStatusReport;r->MessageId=id;r->ObservedAt=now;return r;}}; struct ERR_HQSearchReport{int SourceId=0,MessageId=0,WatchMessageId=0,WatchRequestId=0,BeliefId=0,BeliefRevision=0; string WatchSourceId;vector Position;float ObservedAt=0;bool SweepCompleted=false,ContactFound=false;}; -struct ERR_HQTask{bool RoadIntercept=false,SweepQueued=false;float ArrivedAt=-1;vector Destination; +struct ERR_HQTask{std::string SecuritySite;bool RoadIntercept=false,SweepQueued=false;float ArrivedAt=-1;vector Destination; string WatchSourceId;int WatchMessageId=0,WatchRequestId=0,BeliefId=1,BeliefRevision=2;}; struct ERR_HQRoute{Group* LocalSource=nullptr;int MessageId=0;string Payload;}; struct Network{bool available=true;int routes=0;ERR_HQRoute* Route(void*){routes++;return available?new ERR_HQRoute:nullptr;}}; diff --git a/tests/test_hq_watch.py b/tests/test_hq_watch.py index b29d8fd..954b394 100644 --- a/tests/test_hq_watch.py +++ b/tests/test_hq_watch.py @@ -30,9 +30,9 @@ ammo=ammo.replace('class ','struct ').replace('ERR_HQAmmoSnapshot Snapshot(', 'E ammo=ammo.replace('ERR_HQAmmoSnapshot copy', 'ERR_HQAmmoSnapshot* copy').replace('copy.', 'copy->') ammo=re.sub(r'\b(bool|int) (\w+);',r'\1 \2=0;',ammo).replace('\n}\n','\n};\n') rules='\n'.join(cpp(body(P,s)) for s in ('static bool Valid(', 'static bool RequestDue(', - 'static bool InvestigationDue(', 'static int SquadLimit(')) + 'static bool InvestigationDue(', 'static int SquadLimit(', 'static int Aggression(', 'static int Hunters(')) methods='\n'.join(cpp(body(P,s)) for s in ('int Count()', 'ERR_HQWatchEntry Find(', 'ERR_HQWatchEntry At(', - 'int ConfirmedLosses()', 'int SquadLimit(int baseline)', 'bool ArmedSearchBlocked(', 'protected ERR_HQWatchEntry Create(', + 'int IdentifiedBodies()', 'int Aggression()', 'int HunterTarget()', 'int ConfirmedLosses()', 'int SquadLimit(int baseline)', 'bool ArmedSearchBlocked(', 'protected ERR_HQWatchEntry Create(', 'protected void ApplyLosses(', 'bool Ingest(', 'bool IngestBody(', 'bool RequestDue(string', 'bool MarkRequested(', 'bool InvestigationDue(string', 'bool MarkInvestigating(', 'bool FinishInvestigation(', 'void Clear(')) common='\n'.join(cpp(body(U,s)) for s in ('static bool Finite(', 'static bool PositionValid(')) @@ -72,7 +72,7 @@ struct ERR_HQPursuitRules{ }; '''+helper+ammo+r''' struct ERR_HQReport{ - string TravelMode="body",CasualtySourceId="patrol-1";bool Distress=false;int MessageId=99,CasualtySlot=0,CasualtyStrength=4; + string RoadStatus="road unknown"; string TravelMode="body",CasualtySourceId="patrol-1";bool Distress=false;int MessageId=99,CasualtySlot=0,CasualtyStrength=4; vector Position{900,30,900},Velocity;float ObservedAt=1000,DetailsObservedAt=-1; void* Target=nullptr;void* Source=nullptr;bool HasVelocity=false,Engaged=false; int SeenEscapists=-1;string SeenWeapons="unknown"; @@ -86,7 +86,7 @@ struct ERR_HQStatusReport{ struct ERR_HQWatchEntry{ string SourceId;int MessageId=0,RequestId=0;vector Position; ERR_HQAmmoSnapshot* AmmoState=nullptr; - int Strength=0,Count=-1,Fit=-1,ConfirmedLosses=0; + int Strength=0,Count=-1,Fit=-1,ConfirmedLosses=0;Array IdentifiedSlots; ERR_HQDeliveredCasualties* Casualties=new ERR_HQDeliveredCasualties();float LastBodyDeliveredAt=-1; float ObservedAt=-1,LastDeliveredAt=-1,AskedAt=-1,NextAttempt=0; bool Investigating=false,Closed=false; @@ -96,7 +96,7 @@ struct ERR_HQWatchRules{ }; struct Watch{ Arraym_Entries;Mapm_BySource; - int m_ConfirmedLosses=0;bool m_CapLogged=false; + int m_ConfirmedLosses=0,m_IdentifiedBodies=0;bool m_CapLogged=false; '''+methods+r''' }; struct ERR_MissionRules{static bool ContactFresh(float t,float now){return std::isfinite(t)&&std::isfinite(now)&&t>=0&&now>=t&&now-t<=120000;}}; @@ -106,9 +106,9 @@ struct MemoryAdapter{int attempts=0;bool Ingest(ERR_HQReport* r,float){attempts+ struct BodyAdapter{int confirmations=0;void ConfirmDelivered(void*){confirmations++;}}; struct CoverageAdapter{int forgotten=0;void Forget(vector){forgotten++;}}; struct JournalAdapter{void Write(string,string,int){}}; -string Format(string,int,int){return "delivered";} +template string Format(string,Args...){return "delivered";} struct HQDelivery{ - Watch m_Watch;MemoryAdapter m_Memory;BodyAdapter m_Bodies;CoverageAdapter m_Coverage;JournalAdapter m_Journal; + Watch m_Watch;MemoryAdapter m_Memory;BodyAdapter m_Bodies;struct RecoveryAdapter{int calls=0;void ReportBody(ERR_HQReport*){calls++;}} m_Recovery;CoverageAdapter m_Coverage;JournalAdapter m_Journal; Arraym_Contacts; '''+accept+r''' }; @@ -212,12 +212,12 @@ int main(){ assert(!watch.Ingest(&late,120001)&&watch.Ingest(&late,120000)); assert(!watch.RequestDue("late",nan)); Watch cap;ERR_HQStatusReport item;item.Count=0;item.Fit=0;item.ConfirmedLosses=4;item.DeathSlots={0,1,2,3}; - for(int i=0;i<128;i++){item.SourceId=std::to_string(i);assert(cap.Ingest(&item,1000));} - assert(cap.Count()==128&&cap.ConfirmedLosses()==512); + for(int i=0;i<256;i++){item.SourceId=std::to_string(i);assert(cap.Ingest(&item,1000));} + assert(cap.Count()==256&&cap.ConfirmedLosses()==1024); item.SourceId="overflow";assert(!cap.Ingest(&item,1000)); item.SourceId="overflow2";assert(!cap.Ingest(&item,1000)&&logs==1); item.SourceId="0";item.MessageId=2;item.ObservedAt=2000; - assert(cap.Ingest(&item,2000)&&cap.ConfirmedLosses()==512&&cap.Count()==128); + assert(cap.Ingest(&item,2000)&&cap.ConfirmedLosses()==1024&&cap.Count()==256); cap.Clear();assert(cap.Count()==0&&cap.ConfirmedLosses()==0&&!cap.Find("0")); // Delivered bodies merge exact original roster slots, never aggregate totals or body positions. Watch merged;ERR_HQStatusReport status;status.Count=3;status.Fit=2;status.ConfirmedLosses=1;status.DeathSlots={0}; @@ -234,9 +234,16 @@ int main(){ body.CasualtySlot=3;assert(merged.IngestBody(&body,4001)); assert(known->Closed&&known->Count==0&&known->Fit==0&&merged.ConfirmedLosses()==4); assert(known->MessageId==2&&known->ObservedAt==3000&&known->Position.x==200); - assert(merged.IngestBody(&body,4002)&&merged.ConfirmedLosses()==4); // replay or different observer + assert(!merged.IngestBody(&body,4002)&&merged.ConfirmedLosses()==4); // replay or different observer status.MessageId=3;status.ObservedAt=5000;assert(merged.Ingest(&status,5000)&&known->Closed); assert(!merged.RequestDue("patrol-1",999999)); + assert(merged.IdentifiedBodies()==4&&merged.Aggression()==1&&merged.HunterTarget()==1); + for(int bodies=0;bodies<=300;bodies++){ + assert(ERR_HQWatchRules::Aggression(bodies)==std::min(30,bodies/3)); + assert(ERR_HQWatchRules::Hunters(ERR_HQWatchRules::Aggression(bodies))==std::min(15,(bodies/3+1)/2)); + } + assert(ERR_HQWatchRules::Aggression(-10)==0&&ERR_HQWatchRules::Hunters(-10)==0); + merged.Clear();assert(merged.IdentifiedBodies()==0&&merged.HunterTarget()==0); // Exercise the actual HQ delivered-intake method and actual Report.Snapshot, including corpse deletion. HQDelivery intake;ERR_HQReport local;local.CasualtySourceId="deleted-during-uplink"; local.CasualtyStrength=1;local.CasualtySlot=0;local.Target=&local;local.MessageId=501; @@ -279,4 +286,4 @@ for forbidden in ('IEntity','SCR_AIGroup','GetGame(','GetOrigin(','GetPlayers(', 'GetControlledEntity(','Spawn(','CallLater(','ERR_HQMemory'): assert forbidden not in source,forbidden assert all(len(line)<120 for line in source.splitlines()) -print('PASS: delivered-only status snapshots, two missed check-ins/reply deadline, last reported coordinates, episode/replay guards, confirmed-loss idempotence and 128-record cap') +print('PASS: delivered-only status snapshots, two missed check-ins/reply deadline, last reported coordinates, episode/replay guards, confirmed-loss idempotence and 256-record cap') diff --git a/tests/test_hunter_lifecycle.py b/tests/test_hunter_lifecycle.py new file mode 100644 index 0000000..2f500f0 --- /dev/null +++ b/tests/test_hunter_lifecycle.py @@ -0,0 +1,44 @@ +"""Production hunter release and objective-role policy, without native spawning.""" +from policy_harness import ROOT, body, run +source=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c' +s=body(source,'override void Release(ERR_GameMode mode)') +s=s.replace('override ','').replace('super.','Native::').replace('null','nullptr') +s=s.replace('ERR_GameMode mode','ERR_GameMode* mode').replace('ERR_ForceRecord record','ERR_ForceRecord* record') +s=s.replace('ERR_HQ hq = ERR_HQ.Cast(mode.Command())', 'ERR_HQ* hq = mode->Command()') +s=s.replace('array agents = {};','Array agents;').replace('foreach (AIAgent agent : agents)','for (AIAgent* agent : agents)') +for name in ('mode','record','Group','agent','hq'):s=s.replace(name+'.',name+'->') +s=s.replace('Forces().','Forces()->').replace('ERR_ConvoyRules.','ERR_ConvoyRules::') +objective=body(ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_ObjectiveDefence.c','static bool Objective(').replace('ERR_LocationKind.','ERR_LocationKind::') +run(r''' +#include +#include +templateusing Array=std::vector; +struct AIAgent{int lod=0,writes=0;void SetPermanentLOD(int x){lod=x;writes++;}}; +struct GroupType{int lod=0;AIAgent agent;void SetPermanentLOD(int x){lod=x;}void GetAgents(Array& a){a={&agent};}}; +struct ERR_ForceRecord{bool Pinned=true;}; +struct Ledger{ERR_ForceRecord record;ERR_ForceRecord* FindGroup(GroupType*){return &record;}}; +struct ERR_HQ{int releases=0;void ReleaseRecovery(GroupType*){releases++;}}; +struct ERR_GameMode{bool active=true;Ledger ledger;ERR_HQ hq;bool Active(){return active;}Ledger* Forces(){return &ledger;}ERR_HQ* Command(){return &hq;}}; +namespace ERR_ConvoyRules{bool captured=false;bool Captured(void*,GroupType*){return captured;}} +struct Native{bool Released=false;int delegated=0,clears=0;GroupType* Group=nullptr;void* Entity=nullptr;void* Downlink=nullptr; + void Release(ERR_GameMode*){delegated++;Released=true;}void ClearOrder(){clears++;} +}; +struct Hunter:Native{ +'''+s+r''' +}; +namespace ERR_LocationKind{enum{PRISON,RADIO,EXTRACTION,PATROL,DEPOT,OUTPOST,MORTAR};} +'''+objective+r''' +int main(){ + ERR_GameMode mode;GroupType group;Hunter unit;unit.Group=&group;unit.Entity=&group;unit.Downlink=&mode; + unit.Release(&mode);assert(unit.Released&&!unit.Downlink&&unit.clears==1&&unit.delegated==0); + assert(!mode.ledger.record.Pinned&&mode.hq.releases==1&&group.lod==-1&&group.agent.writes==1); + assert(unit.Entity==&group&&unit.Group==&group); // living and dead actors/vehicle are preserved + unit.Release(&mode);assert(unit.clears==1&&mode.hq.releases==1&&group.agent.writes==1); + Hunter captured;ERR_ConvoyRules::captured=true;captured.Release(&mode);assert(captured.delegated==1&&mode.hq.releases==1); + ERR_ConvoyRules::captured=false;Hunter stopped;mode.active=false;stopped.Release(&mode);assert(stopped.delegated==1); + Hunter missing;missing.Release(nullptr);assert(missing.delegated==1); + assert(Objective(ERR_LocationKind::RADIO)&&Objective(ERR_LocationKind::DEPOT)&&Objective(ERR_LocationKind::OUTPOST)&&Objective(ERR_LocationKind::EXTRACTION)); + assert(!Objective(ERR_LocationKind::PRISON)&&!Objective(ERR_LocationKind::PATROL)&&!Objective(ERR_LocationKind::MORTAR)); +} +''') +print('PASS: actual hunter release preserves survivors/HQ, releases driving ownership once, delegates capture/teardown, and excludes prison/patrol/mortar from generic objective defence') diff --git a/tests/test_infantry_ammo_access.py b/tests/test_infantry_ammo_access.py index de67cd5..4f927a1 100644 --- a/tests/test_infantry_ammo_access.py +++ b/tests/test_infantry_ammo_access.py @@ -56,15 +56,17 @@ int main(){ storage.owner=&otherTruck;assert(!access.Check(&truck,&actor));storage.owner=&truck; BaseUserAction door;door.owner=&truck;context.actions={&door};assert(!access.Check(&truck,&actor));context.actions={&storage}; manager.enabled=false;assert(!access.Check(&truck,&actor));manager.enabled=true; - // Stock Ural defines 36 root contexts and ammo adds one: a valid storage action near the end must remain discoverable. - for(int count:{37,64}){ + // Stock Ural has 37 contexts and Mi-8 has 82: a valid storage action near the end must remain discoverable. + for(int count:{37,64,82,128}){ std::vector stockContexts(count);manager.contexts.clear(); for(auto& authored:stockContexts){authored.actions={&door};manager.contexts.push_back(&authored);} stockContexts.back().actions={&storage}; assert(ERR_InfantryAmmoAccess::Points(&truck,points)&&points.Count()==1&&access.Check(&truck,&actor)); } - manager.contexts.clear();manager.contexts.resize(65,&context);assert(!access.Check(&truck,&actor));manager.contexts={&context}; - context.actions.resize(17,&storage);assert(!access.Check(&truck,&actor));context.actions={&storage}; + manager.contexts.clear();manager.contexts.resize(129,&context);assert(!access.Check(&truck,&actor));manager.contexts={&context}; + // Native helicopter door contexts include 38 actions, most unrelated to storage. + for(int count:{33,38,64}){context.actions.clear();context.actions.resize(count,&door);context.actions.back()=&storage;assert(access.Check(&truck,&actor));} + context.actions.resize(65,&storage);assert(!access.Check(&truck,&actor));context.actions={&storage}; manager.contexts={nullptr};assert(!access.Check(&truck,&actor));manager.contexts={&context}; assert(!access.Check(&truck,&wall));assert(!access.Check(nullptr,&actor)); // A blocked context never skips an intervening external entity; another independently clear authored context may qualify. diff --git a/tests/test_infantry_ammo_transfer.py b/tests/test_infantry_ammo_transfer.py index df10801..b781130 100644 --- a/tests/test_infantry_ammo_transfer.py +++ b/tests/test_infantry_ammo_transfer.py @@ -10,6 +10,7 @@ callback=s[s.index('class ERR_InfantryAmmoCallback'):s.index('// One exact physi code=s[s.index('class ERR_InfantryAmmoTransfer\n'):] code=re.sub(r'//[^\n]*','',code).replace('protected ','').replace('class ','struct ') for src,dst in { + 'm_Donor.FindComponent(VehicleHelicopterSimulation)':'m_Donor->helicopter', 'BaseMagazineComponent.Cast(exactMagazine.FindComponent(BaseMagazineComponent))':'exactMagazine->magazine', 'BaseWeaponManagerComponent.Cast(actor.FindComponent(BaseWeaponManagerComponent))':'actor->weapons', 'BaseWeaponComponent.Cast(rifle.FindComponent(BaseWeaponComponent))':'rifle->weapon', @@ -78,7 +79,7 @@ struct Usage{Slot*pilot=nullptr;Slot*GetPilotCompartmentSlot(){return pilot;}}; struct VehicleManager{IEntity vehicle=nullptr;IEntity FindVehicle(IEntity v){return vehicle==v?v:nullptr;}}; struct Utility{VehicleManager*m_VehicleMgr=nullptr;bool military=true;bool IsMilitary(){return military;}}; struct Group{int faction=1,players=0;bool slave=false;Utility*utility=nullptr;int GetPlayerCount(bool){return players;}int GetFaction(){return faction;}bool IsSlave(){return slave;}Utility*GetGroupUtilityComponent(){return utility;}}; -struct Entity{bool alive=true,awake=true,player=false,captured=false;int faction=1;Group*group=nullptr;IEntity vehicle=nullptr,parent=nullptr; +struct Entity{bool helicopter=false;bool alive=true,awake=true,player=false,captured=false;int faction=1;Group*group=nullptr;IEntity vehicle=nullptr,parent=nullptr; Inventory*inventory=nullptr;Magazine*magazine=nullptr;Manager*weapons=nullptr;Weapon*weapon=nullptr;Item*item=nullptr;Usage*usage=nullptr;Physics_*physics=nullptr;vector position; IEntity GetParent(){return parent;}Physics_*GetPhysics(){return physics;}vector GetOrigin(){return position;}}; struct Record{SCR_AIGroup Group=nullptr;bool Physical=true,Eliminated=false;}; diff --git a/tests/test_intel_body_inspection.py b/tests/test_intel_body_inspection.py new file mode 100644 index 0000000..c5cb83f --- /dev/null +++ b/tests/test_intel_body_inspection.py @@ -0,0 +1,88 @@ +"""Execute the production physical body-inspection gate; engine traces are boundary doubles.""" +from policy_harness import ROOT, body, run +import re +p=ROOT/'addons/core/Scripts/Game/ERR/20_Intel/ERR_IntelEvidence.c' +s='\n'.join(body(p,sig) for sig in ('bool Inspect(', 'bool RetainedWithin(', 'void Delivered(', 'void Release(')) +for old,new in [('out array missing','Array& missing'),('array','Array'),('array','Array'),('foreach (','for ('),('ERR_BodyEvidence record','ERR_BodyEvidence* record'),('ERR_IssuedIntel issued','ERR_IssuedIntel* issued'),('TraceParam trace','TraceParam* trace'),('InventoryStorageManagerComponent inventory','InventoryStorageManagerComponent* inventory'),('SCR_GarbageSystem garbage','SCR_GarbageSystem* garbage'),('null','nullptr')]:s=s.replace(old,new) +s=s.replace('InventoryStorageManagerComponent.Cast(body.FindComponent(InventoryStorageManagerComponent))','body->inventory') +for cls in ('Replication','ERR_GameMode','ERR_Actors','CompartmentAccessComponent','vector','TraceFlags','ERR_DiscoveryService','SCR_GarbageSystem'): + s=s.replace(cls+'.',cls+'::') +s=s.replace('parent.GetParent()', 'parent->GetParent()') +for v in ('body','observer','record','issued','trace','garbage'): + s=s.replace(v+'.',v+'->') +s=s.replace('ERR_GameMode::Get().','ERR_GameMode::Get()->').replace('GetGame().GetWorld().','GetGame()->GetWorld()->') +run(r''' +#include +#include +#include +#include +#include +using string=std::string; +templatestruct Array:std::vector{using std::vector::vector;void Clear(){this->clear();}bool Contains(T t){return std::find(this->begin(),this->end(),t)!=this->end();}void Insert(T t){this->push_back(t);}}; +struct vector {float x=0,y=0,z=0;vector()=default;vector(float a,float b,float c):x(a),y(b),z(c){}vector(const char*){}vector operator+(vector v){return {x+v.x,y+v.y,z+v.z};}static float Distance(vector a,vector b){return std::hypot(std::hypot(a.x-b.x,a.y-b.y),a.z-b.z);}}; +struct Entity;using IEntity=Entity*; +struct InventoryStorageManagerComponent{Array folders;}; +struct Entity{IEntity parent=nullptr;IEntity GetParent(){return parent;}bool alive=true,fit=true,player=false,seated=false;InventoryStorageManagerComponent* inventory=nullptr;vector position;vector GetOrigin(){return position;}}; +struct ERR_Actors{static bool Alive(IEntity e){return e&&e->alive;}static bool CanFight(IEntity e){return e&&e->alive&&e->fit;}static bool Player(IEntity e){return e&&e->player;}}; +struct CompartmentAccessComponent{static bool GetVehicleIn(IEntity e){return e->seated;}}; +struct TraceFlags{enum{ENTS=1,WORLD=2};};struct TraceParam{vector Start,End;IEntity Exclude=nullptr,TraceEnt=nullptr;int Flags=0;}; +struct World{float fraction=1;IEntity hit=nullptr;float TraceMove(TraceParam* t,void*){t->TraceEnt=hit;return fraction;}}; +struct Game{World world;World* GetWorld(){return &world;}} game;Game* GetGame(){return &game;} +struct Replication{inline static bool server=true;static bool IsServer(){return server;}}; +struct ERR_GameMode{bool active=true;static ERR_GameMode* Get(){static ERR_GameMode mode;return &mode;}bool Active(){return active;}}; +struct ERR_DiscoveryService{static void Folders(InventoryStorageManagerComponent* inv,Array& folders){folders=inv->folders;}}; +struct ERR_IssuedIntel{IEntity Folder;string SiteId;}; +struct ERR_BodyEvidence{IEntity Actor;Array Issued;bool Searched=false;bool Reported=false;}; +struct SCR_GarbageSystem { + inline static int releases=0,inserts=0;inline static bool blacklisted=true;inline static float delay=0; + static SCR_GarbageSystem* GetByEntityWorld(IEntity){static SCR_GarbageSystem g;return &g;} + void UpdateBlacklist(IEntity,bool value){blacklisted=value;releases++;} + void Insert(IEntity,float value){assert(!blacklisted);inserts++;delay=value;} +}; +struct Evidence{Array m_Roster;bool m_Stopped=false;ERR_BodyEvidence* record=nullptr;ERR_BodyEvidence* Find(IEntity b){return record&&record->Actor==b?record:nullptr;} +'''+s+r''' +}; +int main(){ + for(int invalid=0;invalid<9;invalid++){ + Entity body,observer,folder;body.alive=false;InventoryStorageManagerComponent inventory;body.inventory=&inventory; + ERR_IssuedIntel issued{&folder,"radio-1"};ERR_BodyEvidence record{&body,{&issued},false};Evidence service;service.record=&record;Array result; + game.world.fraction=1;Replication::server=true;ERR_GameMode::Get()->active=true; + if(invalid==0)Replication::server=false; + if(invalid==1)service.m_Stopped=true; + if(invalid==2)body.alive=true; + if(invalid==3)observer.player=true; + if(invalid==4)observer.fit=false; + if(invalid==5)observer.seated=true; + if(invalid==6)observer.position={3.01f,0,0}; + if(invalid==7)game.world.fraction=.5; + if(invalid==8)body.inventory=nullptr; + assert(!service.Inspect(&body,&observer,result)&&!record.Searched&&result.empty()); + } + Replication::server=true;game.world.fraction=1; + Entity body,observer,present,missing;body.alive=false;InventoryStorageManagerComponent inv;body.inventory=&inv;inv.folders.Insert(&present); + ERR_IssuedIntel a{&present,"radio-1"},b{&missing,"mortar-3"},c{nullptr,"depot-1"};ERR_BodyEvidence record{&body,{&a,&b,&c},false};Evidence service;service.record=&record;Array result; + observer.position={3,0,0};assert(service.Inspect(&body,&observer,result));assert(result.size()==2&&result.Contains("mortar-3")&&result.Contains("depot-1")&&!result.Contains("radio-1")); + assert(!service.Inspect(&body,&observer,result)); + Entity wreck,seat,unrelated;body.parent=&seat;seat.parent=&wreck;service.m_Roster={&record}; + assert(service.RetainedWithin(&body)&&service.RetainedWithin(&seat)&&service.RetainedWithin(&wreck)); + assert(!service.RetainedWithin(&unrelated)&&!service.RetainedWithin(nullptr)); + body.alive=true;assert(!service.RetainedWithin(&wreck));body.alive=false; + record.Reported=true;assert(!service.RetainedWithin(&wreck));record.Reported=false; + service.m_Stopped=true;assert(!service.RetainedWithin(&wreck));service.m_Stopped=false; + // Searching protects the body until HQ receives the report. Premature, + // client-side, stopped and repeated deliveries never release/renew cleanup. + record.Searched=false;service.Delivered(&body);assert(!record.Reported&&SCR_GarbageSystem::inserts==0); + record.Searched=true;Replication::server=false;service.Delivered(&body);assert(!record.Reported); + Replication::server=true;service.m_Stopped=true;service.Delivered(&body);assert(!record.Reported); + service.m_Stopped=false;service.Delivered(&unrelated);assert(!record.Reported); + assert(service.RetainedWithin(&body));service.Delivered(&body); + assert(record.Reported&&!service.RetainedWithin(&body)); + assert(SCR_GarbageSystem::inserts==1&&SCR_GarbageSystem::releases==1&&SCR_GarbageSystem::delay==300); + service.Delivered(&body);assert(SCR_GarbageSystem::inserts==1&&SCR_GarbageSystem::releases==1); + record.Reported=false; + // Corrupt/cyclic parent links cannot cause unbounded cleanup traversal. + seat.parent=&seat;assert(!service.RetainedWithin(&wreck)); + +} +''') +print('PASS: physical inspection and parent-wreck retention; server-only searched/report delivery unlocks native cleanup once with a 300-second delay') diff --git a/tests/test_intel_discovery_feedback.py b/tests/test_intel_discovery_feedback.py index c33469a..935fbbb 100644 --- a/tests/test_intel_discovery_feedback.py +++ b/tests/test_intel_discovery_feedback.py @@ -15,28 +15,28 @@ def cpp(s): s=s.replace('OnTick().Remove(ERR_FlushDiscoveryFeedback);','OnTick().Remove(0);') s=s.replace('string.Format(', 'Format(') return s -methods='\n'.join(cpp(body(P,s)) for s in ('protected void ERR_QueueDiscoveryFeedback(', 'protected void ERR_FlushDiscoveryFeedback(', 'protected void ERR_ReceiveDiscoveryFeedback(', 'protected void ERR_StopDiscoveryFeedback(')) +methods='\n'.join(cpp(body(P,s)) for s in ('protected void ERR_QueueDiscoveryFeedback(', 'protected void ERR_FlushDiscoveryFeedback(', 'protected void ERR_ReceiveDiscoveryFeedback(', 'protected void ERR_StopDiscoveryFeedback(', 'void ERR_IntelExhausted(')) run(r''' #include #include #include #include -struct string:std::string {using std::string::string;using std::string::operator=;string(std::string s):std::string(s){}bool IsEmpty()const{return empty();}}; +struct string:std::string {using std::string::string;using std::string::operator=;string(std::string s):std::string(s){}bool IsEmpty()const{return empty();}bool Contains(const char* text)const{return find(text)!=npos;}}; string Format(string text,int identified,int rough){text.replace(text.find("%1"),2,std::to_string(identified));text.replace(text.find("%2"),2,std::to_string(rough));return text;} struct Replication {static bool server;static bool IsServer(){return server;}};bool Replication::server=true; struct SCR_Global {static bool edit;static bool IsEditMode(){return edit;}};bool SCR_Global::edit=false; struct SCR_ChatComponent {static std::vector messages;static void RadioProtocolMessage(string text){messages.push_back(text);}};std::vector SCR_ChatComponent::messages; struct Math {static float Max(float a,float b){return a>b?a:b;}}; namespace ERR_Phase {enum {PREPARING,PRISON,RADIO_SEARCH,EXTRACTION};} -namespace ERR_LocationKind {enum {PRISON,RADIO,EXTRACTION,OUTPOST,DEPOT};} -struct ERR_LocationComponent {int kind=ERR_LocationKind::OUTPOST,knowledge=1;string label="Hidden outpost";int Kind(){return kind;}int Knowledge(){return knowledge;}string Label(){return label;}}; +namespace ERR_LocationKind {enum {PRISON,RADIO,EXTRACTION,OUTPOST,DEPOT,MORTAR};} +struct ERR_LocationComponent {int kind=ERR_LocationKind::OUTPOST,knowledge=1;string label="Hidden outpost";int Kind(){return kind;}int Knowledge(){return knowledge;}string Label(){return label;}string Id(){return "outpost-id";}}; template struct Map {std::map values;bool Find(K k,V& v){auto i=values.find(k);if(i==values.end())return false;v=i->second;return true;}bool Contains(K k){return values.count(k);}int Count(){return values.size();}bool IsEmpty(){return values.empty();}void Set(K k,V v){values[k]=v;}void Clear(){values.clear();}}; struct World {float time=0;float GetWorldTime(){return time;}} world; struct Game {bool controller=true;bool GetPlayerController(){return controller;}} game; Game* currentGame=&game;Game* GetGame(){return currentGame;} struct Events {int removes=0;void Remove(int){removes++;}}; struct ERR_IntelDiscoveryText {static -'''+cpp(body(P,'static string Message('))+r''' +'''+cpp(body(P,'static string Type('))+'static '+cpp(body(P,'static string Message('))+r''' }; struct ERR_GameMode { static ERR_GameMode* current;static ERR_GameMode* Get(){return current;} @@ -57,14 +57,14 @@ int main(){ m.ERR_QueueDiscoveryFeedback(nullptr,0,1);m.ERR_QueueDiscoveryFeedback(&site,1,1);m.ERR_QueueDiscoveryFeedback(&site,0,2);m.ERR_QueueDiscoveryFeedback(&site,-1,1);assert(m.m_ERRIntelPending.IsEmpty()); // Rough clues do not disclose labels, kinds or coordinates. Listen callbacks dedupe. m.ERR_QueueDiscoveryFeedback(&site,0,1);m.ERR_FlushDiscoveryFeedback(1499);assert(SCR_ChatComponent::messages.empty()); - m.ERR_FlushDiscoveryFeedback(1500);assert(m.rpcCount==1&&SCR_ChatComponent::messages.size()==1);assert(SCR_ChatComponent::messages.back().find("Hidden")==string::npos);assert(SCR_ChatComponent::messages.back().find("500 m")!=string::npos); + m.ERR_FlushDiscoveryFeedback(1500);assert(m.rpcCount==1&&SCR_ChatComponent::messages.size()==1);assert(SCR_ChatComponent::messages.back().find("Hidden")==string::npos);assert(SCR_ChatComponent::messages.back().find("New location marked. Type unknown.")!=string::npos); m.ERR_QueueDiscoveryFeedback(&site,0,1);assert(m.m_ERRIntelPending.IsEmpty()); site.knowledge=2;world.time=2000;m.ERR_QueueDiscoveryFeedback(&site,1,2);m.ERR_FlushDiscoveryFeedback(6499);assert(m.rpcCount==1); - m.ERR_FlushDiscoveryFeedback(6500);assert(m.rpcCount==2&&SCR_ChatComponent::messages.back().find("Hidden outpost")!=string::npos); + m.ERR_FlushDiscoveryFeedback(6500);assert(m.rpcCount==2&&SCR_ChatComponent::messages.back()=="Hey! A outpost was identified in the intel!"); // A same-batch rough→exact update becomes one final identified location. ERR_GameMode batch;ERR_GameMode::current=&batch;ERR_LocationComponent a,b;world.time=0; batch.ERR_QueueDiscoveryFeedback(&a,0,1);a.knowledge=2;batch.ERR_QueueDiscoveryFeedback(&a,1,2);batch.ERR_QueueDiscoveryFeedback(&b,0,1);batch.ERR_FlushDiscoveryFeedback(1500); - assert(SCR_ChatComponent::messages.back().find("1 identified locations and 1 search areas")!=string::npos); + assert(SCR_ChatComponent::messages.back().find("Hey! A outpost was identified in the intel!")!=string::npos && SCR_ChatComponent::messages.back().find("New location marked. Type unknown.")!=string::npos); // End/restart cancels pending output and removes both hooks, new mode is clean. world.time=2000;b.knowledge=2;batch.ERR_QueueDiscoveryFeedback(&b,1,2);batch.ERR_StopDiscoveryFeedback();assert(batch.m_ERRIntelSeen.IsEmpty()&&batch.m_ERRIntelPending.IsEmpty()&&batch.discovered.removes==1&&batch.tick.removes==1); int calls=batch.rpcCount;batch.ERR_FlushDiscoveryFeedback(99999);batch.ERR_QueueDiscoveryFeedback(&b,0,2);assert(batch.rpcCount==calls&&batch.m_ERRIntelPending.IsEmpty()); @@ -76,7 +76,9 @@ int main(){ next.ERR_ReceiveDiscoveryFeedback(2,"new");assert(SCR_ChatComponent::messages.size()==shown+2); // Queue is bounded even for malformed/unusually large authored scenarios. Replication::server=true;ERR_GameMode bounded;ERR_GameMode::current=&bounded;ERR_LocationComponent many[65];for(auto& item:many)bounded.ERR_QueueDiscoveryFeedback(&item,0,1);assert(bounded.m_ERRIntelSeen.Count()==64&&bounded.m_ERRIntelPending.Count()==64); - assert(ERR_IntelDiscoveryText::Message(0,0,"secret").empty());assert(ERR_IntelDiscoveryText::Message(1,0,"")=="A location identified. Check your field map."); + assert(ERR_IntelDiscoveryText::Type(ERR_LocationKind::MORTAR,"any")=="mortar site"); + ERR_GameMode::current=&m;m.ERR_IntelExhausted();assert(SCR_ChatComponent::messages.back()=="Every known location has already been identified. This intel doesn't help us."); + assert(ERR_IntelDiscoveryText::Message(0,0,"secret").empty());assert(ERR_IntelDiscoveryText::Message(1,0,"")=="Hey! A location was identified in the intel!"); } ''') for required in ('OnLocationDiscovered().Insert(ERR_QueueDiscoveryFeedback)', 'OnTick().Insert(ERR_FlushDiscoveryFeedback)', 'OnStop().Insert(ERR_StopDiscoveryFeedback)', '[RplRpc(RplChannel.Reliable, RplRcver.Broadcast)]'): diff --git a/tests/test_intel_registration.py b/tests/test_intel_registration.py index 0e0c73f..41dd1d2 100644 --- a/tests/test_intel_registration.py +++ b/tests/test_intel_registration.py @@ -10,9 +10,9 @@ def adapt(s): s=s.replace('override ', '').replace('protected ', '').replace('null','nullptr') s=re.sub(r'array<([^>]+)> (\w+) = \{\};',r'Array<\1> \2;',s) s=re.sub(r'foreach \((\w+) (\w+) : ([^)]+)\)',r'for (\1 \2 : \3)',s) - for n in ('Replication','ERR_Actors','Math','ERR_Resources','ERR_DiscoveryService','SCR_InventoryStorageManagerComponent','SCR_AIGroupUtilityComponent','ERR_MortarSupplyState'): + for n in ('Replication','ERR_Actors','Math','ERR_Resources','ERR_DiscoveryService','SCR_InventoryStorageManagerComponent','SCR_AIGroupUtilityComponent','ERR_MortarSupplyState','ERR_LocationKind','ERR_ObjectiveDefence'): s=s.replace(n+'.',n+'::') - for n in ('group','member','actor','request','inventory','known','unit','m_Mode','m_Battery','mode','m_Reroll','home','sites','record','m_Mortars'): + for n in ('group','member','actor','request','inventory','known','unit','m_Mode','m_Battery','mode','m_Reroll','home','sites','record','m_Mortars','m_Scouts','m_Recon','m_Airfield'): s=s.replace(n+'.',n+'->') s=s.replace('Actor.FindComponent(', 'Actor->FindComponent(').replace('SCR_InventoryStorageManagerComponent inventory', 'SCR_InventoryStorageManagerComponent* inventory').replace('SCR_AIGroupUtilityComponent utility', 'SCR_AIGroupUtilityComponent* utility') s=s.replace('home->Definition.', 'home->Definition->').replace('Patrol.IsEmpty()', 'Patrol->IsEmpty()').replace('unit->StatusSampler.Bind(', 'unit->StatusSampler.Bind(') @@ -24,7 +24,7 @@ def adapt(s): s=s.replace('ERR_IntelRequest request = new ERR_IntelRequest();','ERR_IntelRequest request = new Request();') s=s.replace('ERR_HQUnit unit = new ERR_HQUnit();','ERR_HQUnit unit = new Unit();') s=re.sub(r'^.*(?:Print|m_Journal.Write)\([^\n]*$', ';',s,flags=re.M) - return s + return s.replace('Evidence().','Evidence()->').replace('Cargo().','Cargo()->') register=adapt(body(intel,'void RegisterGroup('));tick=adapt(body(intel,'void Tick('));hqregister=adapt(body(hq,'override void RegisterGroup('));hqbind=adapt(body(hq,'protected void BindPhysical(')) observation=adapt(body(intel,'bool ERR_ProbeWasRolled(')) poll=body(ready,'ERR_MortarSupplyState Poll(') @@ -37,7 +37,7 @@ assert poll.index('if (m_Ready) return') < poll.index('mode.RegisterGroup(group) for condition in ('if (!operation.Finished)', 'if (!operation.Succeeded)', 'if (population == ERR_MortarSupplyState.FAILED)', 'if (population == ERR_MortarSupplyState.PENDING)'): assert poll.index(condition)& a){a.clear();if(i->folder)a.Insert(nullptr);}} struct Utility{static Utility* Cast(void* p){return static_cast(p);}};using SCR_AIGroupUtilityComponent=Utility; struct Group{Array members;Utility utility;int faction=1;void GetAgents(Array& out){out=members;}void* FindComponent(){return &utility;}int GetFaction(){return faction;}};using SCR_AIGroup=Group*; -struct Settings{int chance=100;int Get(const char*){return chance;}}; -struct Definition{string Id="site-a";Array* Patrol=nullptr;}; +struct Settings{int chance=100;int Runtime(const char*){return chance;}}; +namespace ERR_LocationKind {enum {RADIO=1};} +namespace ERR_ObjectiveDefence {bool Objective(int kind){return kind==1;}} +struct Definition{int Kind=3;string Id="site-a";Array* Patrol=nullptr;}; struct Site{struct Definition data;struct Definition* Definition=&data;};using ERR_SiteRuntime=Site*; struct Sites{Site home;Site* FindGroup(SCR_AIGroup){return &home;}};using ERR_SiteService=Sites*; struct ForceRecord{string SourceId="site-a";int InitialStrength=4;bool Physical=true,Eliminated=false;};using ERR_ForceRecord=ForceRecord*; struct Forces{ForceRecord record;bool available=false;ForceRecord* FindGroup(SCR_AIGroup){return available?&record:nullptr;}}; -struct Mode{struct Sites sites;struct Forces forces;struct Sites* Sites(){return &sites;}struct Forces* Forces(){return &forces;}struct Settings settings;int publications=0;struct Settings* Settings(){return &settings;}int EnemyFaction(){return 1;}void RegisterGroup(SCR_AIGroup);}; +struct EvidenceService{void Capture(IEntity){}void Retire(IEntity){}}; +struct Mode{EvidenceService evidence;EvidenceService* Evidence(){return &evidence;}struct Sites sites;struct Forces forces;struct Sites* Sites(){return &sites;}struct Forces* Forces(){return &forces;}struct Settings settings;int publications=0;struct Settings* Settings(){return &settings;}int EnemyFaction(){return 1;}bool IsMissionEnemy(int f){return f==1;}void RegisterGroup(SCR_AIGroup);}; struct Request{Callback* Operation=nullptr;IEntity Actor=nullptr;int Attempts=0;bool Accepted=false;};using ERR_IntelRequest=Request*; struct Reroll{void Tick(){}}; struct Intel{Mode* m_Mode;Array m_Requests;Set m_Rolled;bool m_Stopped=false;Reroll* m_Reroll=nullptr;void Unsettled(IEntity){} @@ -82,10 +85,13 @@ struct Sampler{bool Bound(){return bindings>0;}int bindings=0,strength=0;SCR_AIG struct GroupService{int registrations=0;Set seen;Array ids;Array fits; void RegisterGroup(SCR_AIGroup g,int radioId=0,bool fitRadios=true){ids.Insert(radioId);fits.Insert(fitRadios);if(!seen.Contains(g)){seen.Insert(g);registrations++;}}}; struct Mortars{SCR_AIGroup owned=nullptr;bool FindGroup(SCR_AIGroup group){return group&&group==owned;}}; +struct Scouts{SCR_AIGroup owned=nullptr;bool Contains(SCR_AIGroup group){return group&&group==owned;}}; +struct CargoFlight{SCR_AIGroup crew=nullptr;SCR_AIGroup Crew(){return crew;}};struct Airfield{CargoFlight* flight=nullptr;CargoFlight* Cargo(){return flight;}}; +struct Recon{SCR_AIGroup owned=nullptr;bool Owns(SCR_AIGroup group){return group&&group==owned;}}; struct BodyService:GroupService{void RegisterGroup(SCR_AIGroup g,Sampler& s){assert(s.Bound()&&s.group==g);GroupService::RegisterGroup(g);}}; struct Reporting{int initializations=0;void Initialize(int id,float now){assert(id>0&&now==10);initializations++;}}; -struct Unit{Reporting AmmoReporting;string HomeId,ForceSourceId;bool HomeGuard=false;float NextStatus=0;Sampler StatusSampler;int Id=0;SCR_AIGroup Group=nullptr;struct Utility* Utility=nullptr;float NextReport=0;};using ERR_HQUnit=Unit*; -struct HQ{Mode* m_Mode;GroupService m_Equipment;BodyService m_Bodies;int m_NextId=0;Array m_Units;Mortars* m_Mortars=nullptr; +struct Unit{Reporting AmmoReporting;string HomeId,ForceSourceId;bool ReservedForSupport=false;bool HomeGuard=false;float NextStatus=0;Sampler StatusSampler;int Id=0;SCR_AIGroup Group=nullptr;struct Utility* Utility=nullptr;float NextReport=0;};using ERR_HQUnit=Unit*; +struct HQ{void BindSpecialSkills(SCR_AIGroup){}void AuditGroup(ERR_HQUnit){}Mode* m_Mode;GroupService m_Equipment;BodyService m_Bodies;int m_NextId=0;Array m_Units;Mortars* m_Mortars=nullptr;Scouts* m_Scouts=nullptr;Recon* m_Recon=nullptr;Airfield* m_Airfield=nullptr; '''+hqbind+hqregister+r''' }; Intel* intel_;HQ* hq_; @@ -107,6 +113,7 @@ int main(){ battery.shells=27;assert(ready.Finish(&mode,&group)==1&&mode.publications==1&&Math::rolls==4&&intel.m_Requests.Count()==4); assert(hq.m_Units.Count()==1&&hq.m_NextId==1); assert(hq.m_Equipment.registrations==1&&hq.m_Bodies.registrations==1); + assert(!hq.m_Units[0]->ReservedForSupport); assert(hq.m_Units[0]->HomeGuard&&hq.m_Units[0]->HomeId=="site-a"&&hq.m_Units[0]->ForceSourceId=="site-a"); assert(hq.m_Units[0]->StatusSampler.bindings==1&&hq.m_Units[0]->StatusSampler.group==&group&&hq.m_Units[0]->StatusSampler.strength==4); for(Actor& a:actors){assert(intel.ERR_ProbeWasRolled(&a));}assert(!intel.ERR_ProbeWasRolled(nullptr));Actor unrelated;assert(!intel.ERR_ProbeWasRolled(&unrelated)); @@ -133,6 +140,9 @@ int main(){ assert(hq.m_Equipment.ids.Count()==3&&hq.m_Equipment.ids[2]==hq.m_Units[2]->Id&&hq.m_Equipment.ids[2]==3&&hq.m_Equipment.fits[2]); // A subsequent missing mortar registry cannot reroll an already bound group's opt-out. hq.RegisterGroup(&group);assert(hq.m_Equipment.ids.Count()==3&&!hq.m_Equipment.fits[0]&&hq.m_NextId==3); + Group radioGuards;mode.sites.home.data.Kind=ERR_LocationKind::RADIO;hq.RegisterGroup(&radioGuards); + assert(hq.m_Units[3]->ReservedForSupport && hq.m_Units[3]->HomeGuard); + hq.RegisterGroup(&radioGuards);assert(hq.m_Units.Count()==4&&hq.m_Units[3]->ReservedForSupport); } ''') print('PASS: actual Intel/HQ methods and one-shot qualified readiness handoff deliver late actors once; duplicate readiness, accepted spawn, pickup/drop, death and original negative rolls do not mint assignments') diff --git a/tests/test_intel_reroll.py b/tests/test_intel_reroll.py index be2df44..ea45f9c 100644 --- a/tests/test_intel_reroll.py +++ b/tests/test_intel_reroll.py @@ -23,7 +23,7 @@ def adapt(text,cls): text=re.sub(r'^(\w+) (\w+)\(',lambda m:m[1]+' '+cls+'::'+m[2]+'(',text) # Defaults belong only to declarations in C++. text=text.replace('bool failed = false, bool skipped = false','bool failed, bool skipped') - return text + return text.replace('Evidence().','Evidence()->') service_methods=[('void RegisterGroup(', 'void RegisterGroup(SCR_AIGroup group);'),('void Tick(', 'void Tick(float now);'),('bool BeginReroll(', 'bool BeginReroll();'),('bool HasPendingInsertion(', 'bool HasPendingInsertion(IEntity actor);'),('void Unsettled(', 'void Unsettled(IEntity actor);'),('bool Settled(', 'bool Settled(IEntity actor);'),('void Stop(', 'void Stop();')] batch_methods=[('void Add(', 'void Add(IEntity actor);'),('bool Running(', 'bool Running();'),('void Tick(', 'void Tick();'),('protected void Finish(', 'void Finish(ERR_IntelRerollEntry entry,bool failed=false,bool skipped=false);'),('protected void Fail(', 'void Fail(ERR_IntelRerollEntry entry,string reason);'),('protected void Process(', 'void Process(ERR_IntelRerollEntry entry);'),('void Stop(', 'void Stop();')] code=r''' @@ -48,8 +48,9 @@ struct Group{Array agents;void GetAgents(Array& out){out=agent namespace ERR_Actors{bool Alive(IEntity a){return a&&a->alive;}bool Player(IEntity a){return a&&a->player;}} namespace ERR_Resources{int IntelFolder(){return 1;}} struct Discovery{Set spent;bool capacity=true;static void Folders(Inventory* i,Array& out){out=i->folders;}bool IsSpent(IEntity a){return spent.Contains(a);}bool CanMarkSpent(IEntity a){return capacity;}bool MarkSpent(IEntity a){if(!capacity)return false;spent.Insert(a);return true;}};using ERR_DiscoveryService=Discovery; -struct Settings{int chance=100;int Get(const char*){return chance;}}; -struct Mode{bool active=true;struct Settings settings;struct Discovery discovery;Set owned;bool Active(){return active;}struct Settings* Settings(){return &settings;}struct Discovery* Discovery(){return &discovery;}void Own(IEntity a){owned.Insert(a);}}; +struct Settings{int chance=100;int Runtime(const char*){return chance;}}; +struct EvidenceService{void Capture(IEntity){}void Retire(IEntity){}}; +struct Mode{EvidenceService evidence;EvidenceService* Evidence(){return &evidence;}bool active=true;struct Settings settings;struct Discovery discovery;Set owned;bool Active(){return active;}struct Settings* Settings(){return &settings;}struct Discovery* Discovery(){return &discovery;}void Own(IEntity a){owned.Insert(a);}}; struct Entry{string WaitingFor="inventory";Callback* Operation=nullptr;IEntity Actor=nullptr,Removing=nullptr;int Attempts=0;bool GiveIntel=false,Inserting=false,Accepted=false,Done=false;};using ERR_IntelRerollEntry=Entry*; struct Request{Callback* Operation=nullptr;IEntity Actor=nullptr;int Attempts=0;bool Accepted=false;};using ERR_IntelRequest=Request*; struct Service;struct Batch; diff --git a/tests/test_mortar_activity_completion.py b/tests/test_mortar_activity_completion.py index 764306e..80907b8 100644 --- a/tests/test_mortar_activity_completion.py +++ b/tests/test_mortar_activity_completion.py @@ -10,7 +10,8 @@ def adapt(signature): s=s.replace('SCR_AIActionBase action','Action* action').replace('EAIActionState ', 'State ').replace('EAIActionState.', 'State::') s=s.replace('ERR_MortarSupplyState ', 'Supply ').replace('ERR_MortarSupplyState.', 'Supply::').replace('ERR_MortarSalvo.', 'Salvo::') s=s.replace('string reason', 'std::string reason') - for name in ['Activity','m_ObservedActivity','m_Transfer','m_Readiness']: + s=s.replace('new ERR_MortarStation(this)', 'new Station(this)') + for name in ['Activity','m_ObservedActivity','m_Transfer','m_Readiness','m_Station']: s=s.replace(name+'.',name+'->') for name in ['OnActivityCompleted','OnActivityFailed']: s=s.replace('.Insert('+name+')', '.Insert(this, &Battery::'+name+')').replace('.Remove('+name+')', '.Remove(this, &Battery::'+name+')') @@ -28,6 +29,7 @@ enum class Supply {FAILED=-1,PENDING,READY}; struct Salvo {static constexpr float TIMEOUT_MS=120000;}; struct Text:std::string {using std::string::string;bool IsEmpty(){return empty();}}; struct Action;struct Battery; +struct Station {Station(Battery*){} void Update(float){} void Stop(){}}; struct Invoker {Battery* owner=nullptr;void(Battery::*callback)(Action*)=nullptr;int inserts=0,removes=0,calls=0; void Insert(Battery* b,void(Battery::*f)(Action*)){owner=b;callback=f;inserts++;} void Remove(Battery* b,void(Battery::*f)(Action*)){if(owner==b&&callback==f){owner=nullptr;callback=nullptr;removes++;}} @@ -44,7 +46,7 @@ struct Battery { bool m_Stopped=false,CommandOpen=true,StartupActivated=true,operational=true,RefreshRejected=false,Released=false; Action* Activity=nullptr;Action* m_ObservedActivity=nullptr;int OrderId=1,m_ObservedActivityOrder=0,OrderShots=8,TotalShots=8; State m_ActivityTerminal=State::EVALUATED;float OrderedAt=0,AimedAt=100,AimValidatedAt=100; - Text SpawnFailure;std::string lastCancel;Readiness readiness;Readiness* m_Readiness=&readiness;Transfer transfer;Transfer* m_Transfer=&transfer; + Text SpawnFailure;std::string lastCancel;Readiness readiness;Readiness* m_Readiness=&readiness;Transfer transfer;Transfer* m_Transfer=&transfer;Station* m_Station=nullptr; void* RadioOrder=nullptr;void* PendingRadioReport=nullptr;void* PendingSupplyReport=nullptr;void* Refresh=nullptr;void* Report=this;void* AimSolver=this;void* AimSolution=this; bool Operational(){return operational;}std::string OperationalReason(){return "physical crew unavailable";}void SpawnFailed(const char*){}void UpdateCrewDetail(float){}void FlushTraces(){} '''+methods+cancel+r''' diff --git a/tests/test_mortar_logistics.py b/tests/test_mortar_logistics.py index fc010f2..f4c2e8f 100644 --- a/tests/test_mortar_logistics.py +++ b/tests/test_mortar_logistics.py @@ -128,3 +128,48 @@ int main(){int faction,group,foreign;Usage usage;Actor gun{&faction,&group};gun. } ''') print('PASS: actual native-gun ownership gate rejects player/foreign/replaced/dead occupant, faction change, missing seat and missing registry') + +# Exercise the production between-trip target selector with four batteries. +select = body(p, 'protected void SelectBattery(') +select = select.replace('protected ', '').replace('ERR_MortarLogisticsRules.', 'Rules::').replace('vector.DistanceXZ', 'Distance') +select = select.replace('array batteries = {};', 'Array batteries;').replace('ERR_MortarBattery selected;', 'Battery* selected=nullptr;') +select = select.replace('foreach (ERR_MortarBattery candidate : batteries)', 'for (Battery* candidate : batteries)').replace('float.MAX', '999999') +select = select.replace('m_HQ.Mortars().Batteries(batteries)', 'system.Batteries(batteries)') +for name in ('m_Battery', 'candidate', 'selected', 'm_Cache'): + select = select.replace(name+'.', name+'->') +run(r''' +#include +#include +#include +using std::abs; +struct Battery { bool StartupActivated=true, stopped=false, operational=true; int shells=0, Gun=1, Group=1; float Position=0; + bool Stopped(){return stopped;} bool Operational(){return operational;} int CountShells(){return shells;} }; +template struct Array:std::vector{using std::vector::operator=;void Clear(){this->clear();}}; +struct System {Array all; void Batteries(Array& out){out=all;}}; +struct Cache {float GetOrigin(){return 0;}}; +float Distance(float a,float b){return abs(a-b);} +namespace Rules {bool Need(int n){return n>=0&&n<=6;}} +struct Courier {int m_State=1;bool m_Report=false,m_Move=false,m_Order=false; Battery* m_Battery=nullptr; + int m_OriginalGun=0,m_OriginalBatteryGroup=0;Array m_BatteryMembers;float m_NextReport=99; + Cache cache;Cache* m_Cache=&cache;System system; + bool TargetAvailable(){return m_Battery&&m_Battery->StartupActivated&&!m_Battery->Stopped()&&m_Battery->Operational();} +''' + select + r''' +}; +int main(){ + Battery a,b,c,d;a.Position=200;b.Position=100;c.Position=300;d.Position=400; + Courier s;s.system.all={&a,&b,&c,&d};s.SelectBattery();assert(s.m_Battery==&b); + // Keep an active low-ammo recipient; a new nearer request cannot retarget it. + a.Position=1;s.SelectBattery();assert(s.m_Battery==&b); + // Once replenished, choose another depleted battery and reset only binding. + b.shells=12;s.m_BatteryMembers={9};s.SelectBattery();assert(s.m_Battery==&a&&s.m_BatteryMembers.empty()&&s.m_NextReport==0); + a.operational=false;s.SelectBattery();assert(s.m_Battery==&c); + c.stopped=true;d.StartupActivated=false;s.SelectBattery();assert(s.m_Battery==&c); + d.StartupActivated=true; + for(int gate=0;gate<4;gate++){ + s.m_State=gate==0?3:1;s.m_Report=gate==1;s.m_Move=gate==2;s.m_Order=gate==3; + s.SelectBattery();assert(s.m_Battery==&c); + } + s.m_Order=false;s.SelectBattery();assert(s.m_Battery==&d); +} +''') +print('PASS: four-battery courier selection preserves pending trip/report/transfer identity, skips unavailable crews, and selects the next depleted battery without new stock') diff --git a/tests/test_mortar_station.py b/tests/test_mortar_station.py new file mode 100644 index 0000000..f821f8f --- /dev/null +++ b/tests/test_mortar_station.py @@ -0,0 +1,35 @@ +"""Run actual station scope predicates; native boarding/movement is tested separately.""" +from policy_harness import ROOT, body, run +p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_MortarStation.c' +s='\n'.join(body(p,name) for name in ('bool Holds(', 'bool HoldsSeat(', 'bool HoldsPosition(')) +s=(s.replace('IEntity actor','IEntity* actor').replace('IEntity vehicle','IEntity* vehicle') + .replace('m_Battery.', 'm_Battery->').replace('Replication.', 'Replication::') + .replace('ERR_HQ.', 'ERR_HQ::').replace('ERR_Actors.', 'ERR_Actors::') + .replace('CompartmentAccessComponent.', 'CompartmentAccessComponent::')) +run(r''' +#include +struct IEntity {bool player=false, fighting=true; int group=1; IEntity* vehicle=nullptr;}; +struct Battery {int Group=1; IEntity* Gun=nullptr;}; +namespace Replication {bool server=true;bool IsServer(){return server;}} +namespace ERR_HQ {bool active=true;bool MissionActive(){return active;}} +namespace ERR_Actors {bool Player(IEntity* a){return a->player;}int Group(IEntity* a){return a->group;}bool CanFight(IEntity* a){return a&&a->fighting;}} +namespace CompartmentAccessComponent {IEntity* GetVehicleIn(IEntity* a){return a->vehicle;}} +struct Station {Battery* m_Battery;IEntity* m_Gunner=nullptr;bool m_Stopped=false,intact=true;bool GunIntact(){return intact;} +''' + s + r''' +}; +int main(){ + IEntity gun,other,actor;Battery b;b.Gun=&gun;Station s{&b};actor.vehicle=&gun; + assert(s.Holds(&actor)&&s.HoldsPosition(&actor)&&s.HoldsSeat(&actor,&gun)); + assert(!s.HoldsSeat(&actor,&other));actor.vehicle=nullptr;assert(!s.HoldsSeat(&actor,&gun)); + s.m_Gunner=&actor;assert(s.Holds(&actor)&&!s.HoldsPosition(&actor)); // gunner may walk to board + s.m_Gunner=nullptr;actor.fighting=false;assert(!s.HoldsPosition(&actor));actor.fighting=true; + actor.player=true;assert(!s.Holds(&actor));actor.player=false; + actor.group=2;assert(!s.Holds(&actor));actor.group=1; + s.intact=false;assert(!s.Holds(&actor)&&!s.HoldsPosition(&actor));s.intact=true; + s.m_Stopped=true;assert(!s.Holds(&actor));s.m_Stopped=false; + Replication::server=false;assert(!s.Holds(&actor));Replication::server=true; + ERR_HQ::active=false;assert(!s.Holds(&actor));ERR_HQ::active=true; + assert(!s.Holds(nullptr)); +} +''') +print('PASS: mortar duty scope excludes players, other crews, stopped/client/inactive states; only destruction releases an active assignment, while the gunner may approach the gun') diff --git a/tests/test_native_end_lifecycle.py b/tests/test_native_end_lifecycle.py index 658e4ee..bdcbddc 100644 --- a/tests/test_native_end_lifecycle.py +++ b/tests/test_native_end_lifecycle.py @@ -9,7 +9,7 @@ def cpp(s): s=s.replace('GetGame().','GetGame()->').replace('GetCallqueue().','GetCallqueue()->').replace('GetPlayerManager().','GetPlayerManager()->') return s methods='\n'.join(cpp(body(SOURCE, signature)) for signature in ('bool Active(', 'protected override void OnGameModeEnd(', 'protected void StopServices(')) -startGuard=body(SOURCE,'protected void StartScenario(').split('if (!m_Factions',1)[0].replace('protected ','') +startGuard=body(SOURCE,'protected void StartScenario(').split('if (!m_FactionSelection',1)[0].replace('protected ','') run(r'''#include #include #include diff --git a/tests/test_native_package_external.py b/tests/test_native_package_external.py index 5f30099..9d1c87b 100644 --- a/tests/test_native_package_external.py +++ b/tests/test_native_package_external.py @@ -79,7 +79,7 @@ class ExternalPackageTests(unittest.TestCase): records=n.expose_external(project,profile,self.steam,context) expected={'ArmaReforger'}|({'ERRI_'+guid for guid in ext.ALLOWED} if context else set()) self.assertEqual({p.name for p in (profile/'addons').iterdir()},expected) - self.assertEqual(len(records),6 if context else 0) + self.assertEqual(len(records),len(ext.ALLOWED) if context else 0) for link in (profile/'addons').iterdir(): self.assertTrue(link.is_symlink());self.assertNotEqual(link.resolve(),self.source) self.assertNotEqual(link.resolve(),unrelated) @@ -117,7 +117,7 @@ class ExternalPackageTests(unittest.TestCase): def test_native_clean_exit_binds_before_after_external_fingerprints(self): self.prepare();report=self.native_double() self.assertTrue(report['candidate_created']);self.assertTrue(report['external_addons_unchanged_after_run']) - self.assertTrue(report['profile_addons_unchanged']);self.assertEqual(report['external_addon_count'],6) + self.assertTrue(report['profile_addons_unchanged']);self.assertEqual(report['external_addon_count'],len(ext.ALLOWED)) evidence=self.session/'evidence/core' before=json.loads((evidence/'external-addons-before.json').read_text()) after=json.loads((evidence/'external-addons-after.json').read_text()) @@ -164,7 +164,7 @@ class ExternalPackageTests(unittest.TestCase): return base.NativePackageTests.completed_report(True) code,summary,calls=self.run_double(args,result) self.assertEqual(code,0);self.assertEqual([c.args[3] for c in calls],['core']) - self.assertEqual(summary['external_addon_count'],6);self.assertTrue(summary['external_addons_unchanged_after_run']) + self.assertEqual(summary['external_addon_count'],len(ext.ALLOWED));self.assertTrue(summary['external_addons_unchanged_after_run']) self.assertFalse(summary['release_qualified']);self.assertFalse(summary['published']) self.assertTrue((args.session/'external-addons-after.json').is_file()) def test_diagnostic_continuation_stops_on_mid_batch_external_mutation(self): diff --git a/tests/test_packed_external_game.py b/tests/test_packed_external_game.py index 724fb35..5ba3cd7 100644 --- a/tests/test_packed_external_game.py +++ b/tests/test_packed_external_game.py @@ -77,7 +77,7 @@ class ExternalPackedTests(unittest.TestCase): def test_private_copy_exact_six_links_and_schema(self): unrelated=self.source/'protected';unrelated.mkdir();(unrelated/'addon.gproj').write_text('GameProject {GUID "5F16D7E4A1CBE075"}') self.prepare();raw=self.snapshot();value=json.loads(raw) - self.assertEqual(value['schema_version'],4);self.assertEqual(value['kind'],'packed-chat-external-runtime-input');self.assertEqual(len(value['external_addons']),6) + self.assertEqual(value['schema_version'],4);self.assertEqual(value['kind'],'packed-chat-external-runtime-input');self.assertEqual(len(value['external_addons']),len(ext.ALLOWED)) self.assertEqual({p.name for p in (self.profile/'addons').iterdir()},{'ArmaReforger',*('ERRI_'+g for g in ext.ALLOWED)}) self.assertEqual(self.seal(raw)['packed_inputs_unchanged_after_run'],True) self.assertNotEqual((self.candidate/'core/data.pak').stat().st_ino,(self.packed['input']/'core/data.pak').stat().st_ino) @@ -113,7 +113,7 @@ class ExternalPackedTests(unittest.TestCase): self.prepare();raw=self.snapshot();(self.session/v4.MANIFEST).write_bytes(raw+b' ') self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run']) def test_valid_full_nine_mount_database_proof(self): - result=self.evidence();self.assertTrue(result['ready'],result);self.assertFalse(result['release_qualified']);self.assertEqual(result['external_addon_count'],6) + result=self.evidence();self.assertTrue(result['ready'],result);self.assertFalse(result['release_qualified']);self.assertEqual(result['external_addon_count'],len(ext.ALLOWED)) def test_runtime_evidence_rejects_redirects_extra_missing_and_loose(self): content=self.content();guid=ext.DIRECT[0];name=self.packed['external_names'][guid];folder=self.profile/'addons'/('ERRI_'+guid) variants=[content.replace(str(folder),'/unreviewed'),content.replace('(pak count: 1) to filesystem under name '+name,'(pak count: 2) to filesystem under name '+name),content+'00 ENGINE : dir: "ignored"\n'+f"00 ENGINE : dir: '{self.source}'\n",content+f"00 ENGINE : FileSystem: Adding relative directory '{folder}/Scripts' to filesystem under name {name}\n",re.sub(r'[^\n]*gproj:[^\n]*'+guid+r'[^\n]*\n','',content),re.sub(r'[^\n]*ResourceDB: loading cache[^\n]*name='+name+r' [^\n]*\n','',content),content.replace('PLATFORM_WINDOWS','PLATFORM_WINDOWS,WORKBENCH')] diff --git a/tests/test_participant_selection.py b/tests/test_participant_selection.py index 1db1543..fe893e4 100644 --- a/tests/test_participant_selection.py +++ b/tests/test_participant_selection.py @@ -42,8 +42,8 @@ namespace CompartmentAccessComponent{IEntity GetVehicleIn(IEntity a){return a?a- struct Roster{std::map lives;ERR_PlayerLife Get(int id){auto p=lives.find(id);return p==lives.end()?nullptr:p->second;} '''+roster+r''' }; -struct ERR_GameMode{static ERR_GameMode* current;Roster* roster;int phase=2,faction=1;bool active=true;static ERR_GameMode* Get(){return current;}bool Active(){return active;}int Phase(){return phase;}int PlayerFaction(){return faction;}int PrisonPosition(){return 0;}Roster* Players(){return roster;}};ERR_GameMode* ERR_GameMode::current=nullptr; -struct Placement{int nearCalls=0,prisonCalls=0;IEntity last=nullptr;bool clear=true;bool Near(IEntity a,int,vector& p){nearCalls++;last=a;p=a->origin;return clear;}bool Prison(int,int,vector& p){prisonCalls++;p=0;return true;}}; +struct ERR_GameMode{static ERR_GameMode* current;Roster* roster;int phase=2,faction=1;bool active=true;static ERR_GameMode* Get(){return current;}bool Active(){return active;}int Phase(){return phase;}int PlayerFaction(){return faction;}int PrisonPosition(){return 0;}float PrisonHeading(){return 90;}Roster* Players(){return roster;}};ERR_GameMode* ERR_GameMode::current=nullptr; +struct Placement{int nearCalls=0,prisonCalls=0;IEntity last=nullptr;bool clear=true;bool Near(IEntity a,int,vector& p){nearCalls++;last=a;p=a->origin;return clear;}bool Prison(int,int,vector& p,float heading){assert(heading==90);prisonCalls++;p=0;return true;}}; struct Spawn{Placement m_Placement;int m_AuthorizedPositions=0; '''+spawn+r''' }; diff --git a/tests/test_prison_ai.py b/tests/test_prison_ai.py index b591305..2270014 100644 --- a/tests/test_prison_ai.py +++ b/tests/test_prison_ai.py @@ -26,7 +26,7 @@ namespace ERR_Actors {bool Player(Actor* a){return a&&a->player;}bool CanFight(A namespace SCR_FactionManager {int SGetFaction(Actor* a){return a->faction;}} struct Hook {std::set callbacks;int inserted=0;void Insert(std::string c){callbacks.insert(c);inserted++;}void Remove(std::string c){callbacks.erase(c);}}; struct ERR_GameMode {static ERR_GameMode* current;int phase=0,enemy=2;Hook phases,stops; - static ERR_GameMode* Get(){return current;}int Phase(){return phase;}int EnemyFaction(){return enemy;} + static ERR_GameMode* Get(){return current;}int Phase(){return phase;}int EnemyFaction(){return enemy;}bool IsMissionEnemy(int f){return enemy&&f>=enemy;} Hook* OnPhaseChanged(){return &phases;}Hook* OnStop(){return &stops;}}; ERR_GameMode* ERR_GameMode::current=nullptr; struct Agent {ERR_GameMode* m_ERRPrisonMode=nullptr;Actor* actor=nullptr;bool pending=false,active=true;int starts=0; diff --git a/tests/test_prison_placement_policy.py b/tests/test_prison_placement_policy.py index 6983ac2..205ea88 100644 --- a/tests/test_prison_placement_policy.py +++ b/tests/test_prison_placement_policy.py @@ -4,7 +4,7 @@ This verifies bounded search order/termination, not terrain or body clearance. from policy_harness import ROOT,body,run source=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnPlacement.c' search=(body(source,'bool Prison(').replace('map reservations','map* reservations') - .replace('out vector position','vector& position').replace('ERR_Actors.', 'ERR_Actors::').replace('Math.', 'Math::')) + .replace('out vector position','vector& position').replace('ERR_Actors.', 'ERR_Actors::').replace('Math.', 'Math::').replace('ERR_CampTerrain.', 'ERR_CampTerrain::')) run('''#include #include #include @@ -16,6 +16,7 @@ vector Vector(float x,float y,float z){return {x,y,z};} template struct map {}; namespace ERR_Actors {vector Ground(vector value){return value;}} namespace Math {int AbsInt(int value){return std::abs(value);}} +namespace ERR_CampTerrain {vector Rotate(vector v,float a){float r=a*3.14159265359f/180;return {v.x*std::cos(r)+v.z*std::sin(r),v.y,-v.x*std::sin(r)+v.z*std::cos(r)};}} struct Search { std::vector visited; int accept=-1; @@ -23,6 +24,7 @@ struct Search { ''' + search + ''' }; int main(){ + Search rotated;rotated.accept=0;vector turned;assert(rotated.Prison(Vector(0,0,0),nullptr,turned,90));assert(std::abs(turned.x+1.5)<.001&&std::abs(turned.z-1.5)<.001); const vector center={6090,100,7160}; vector result={-1,-1,-1}; Search blocked; @@ -57,3 +59,37 @@ int main(){ } ''') print('PASS: prison search packs 16 unique 3m-spaced inside-wall centers, prioritizes inner/edge positions and never bypasses clearance') + +occupancy = (body(source, 'protected bool Occupied(').replace('protected ', '') + .replace('IEntity entity', 'IEntity* entity').replace('entity.', 'entity->') + .replace('ChimeraCharacter.Cast', 'ChimeraCharacter::Cast') + .replace('vector.', 'vector::').replace('Math.', 'Math::')) +run(r''' +#include +#include +#include +struct vector { + float x=0,y=0,z=0; + float operator[](int i) const {return i==0?x:(i==1?y:z);} + static float DistanceXZ(vector a,vector b){return std::hypot(a.x-b.x,a.z-b.z);} +}; +struct IEntity {vector position;bool character=true;vector GetOrigin(){return position;}}; +namespace ChimeraCharacter {bool Cast(IEntity* e){return e&&e->character;}} +namespace Math {float AbsFloat(float f){return std::abs(f);}} +struct Placement { + IEntity* m_Exclude=nullptr; vector m_Candidate; bool m_Blocked=false; +''' + occupancy + r''' +}; +int main(){ + IEntity body; + for(float distance : {0.f,1.199f,1.2f,2.12f,3.f}) { + Placement p;body.position={distance,0,0}; + assert(p.Occupied(&body)==(distance>=1.2f)); + } + Placement vertical;body.position={0,2,0};assert(vertical.Occupied(&body)); + Placement excluded;excluded.m_Exclude=&body;body.position={0,0,0};assert(excluded.Occupied(&body)); + Placement scenery;body.character=false;assert(scenery.Occupied(&body)); // geometry uses the separate native trace + assert(scenery.Occupied(nullptr)); +} +''') +print('PASS: physical body occupancy rejects overlap, permits diagonal neighboring slots, and excludes own body and unrelated broad-phase bounds') diff --git a/tests/test_random_settings.py b/tests/test_random_settings.py new file mode 100644 index 0000000..802e9eb --- /dev/null +++ b/tests/test_random_settings.py @@ -0,0 +1,47 @@ +"""Exercise concrete bounds, saved modes, calendar mapping and daylight edges.""" +from datetime import date, timedelta +from policy_harness import ROOT, body, run +core = ROOT/'addons/core/Scripts/Game/ERR/00_Core' +rules = '\n'.join(body(core/file, sig) for file, sig in ( + ('ERR_Settings.c', 'static bool Valid('), + ('ERR_Settings.c', 'static bool Randomizable('), + ('ERR_Settings.c', 'static bool ValidSelection('), + ('ERR_Settings.c', 'static bool RandomBounds('), + ('ERR_EnvironmentService.c', 'static int DateFromOrdinal('), + ('ERR_TimeService.c', 'static bool DaylightBounds('), +)) +rules = rules.replace('string ', 'std::string ').replace('out int ', 'int& ').replace('Math.Floor', 'std::floor').replace('Math.Ceil', 'std::ceil') +calendar = [] +for year in (1900, 1989, 2000, 2024, 2050): + first = date(year, 1, 1) + days = (date(year+1,1,1)-first).days + for ordinal in range(days): + day = first+timedelta(days=ordinal) + code = (year-1900)*10000+day.month*100+day.day + calendar.append(f'assert(DateFromOrdinal({year},{ordinal})=={code});') + calendar.append(f'assert(DateFromOrdinal({year},{days})==-1);') +run('#include \n#include \n#include \n' + rules + r""" +int main() { + int lo=-1,hi=-1; + for (std::string key:{"reportIntervalSeconds","supportSquadLimit","responseRadiusMetres","aiIntelChancePercent","intelRevealChancePercent","radioDefenceSeconds","startTimeMinutes","timeMultiplier","startDateCode","weatherProfile","convoyCount","heliTimerMinutes"}) { + assert(ValidSelection(key,-1)); assert(!Valid(key,-1)); + assert(ValidSelection(key,-2)==(key=="startTimeMinutes")); assert(!ValidSelection(key,-3)); + if(key=="startDateCode")continue; + assert(RandomBounds(key,lo,hi)); + assert(lo<=hi); + for(int value=lo;value<=hi;value++) assert(Valid(key,value)); + } + for(std::string key:{"unknown","","stingerChanceOneIn","automaticConvoyEnabled"}) assert(!ValidSelection(key,-1)); + assert(RandomBounds("convoyCount",lo,hi)&&lo==0&&hi==20); + assert(RandomBounds("weatherProfile",lo,hi)&&lo==1&&hi==3); + assert(RandomBounds("startTimeMinutes",lo,hi)&&lo==0&&hi==1439); + assert(DaylightBounds(6.0,18.0,lo,hi)&&lo==361&&hi==1080); + assert(DaylightBounds(6.125,18.125,lo,hi)&&lo==368&&hi==1088); + for(int minute=lo;minute6.125&&minute/60.0<18.125); + assert(!DaylightBounds(18,6,lo,hi)); assert(!DaylightBounds(12,12,lo,hi)); + assert(!DaylightBounds(-1,18,lo,hi));assert(!DaylightBounds(6,25,lo,hi)); + assert(!DaylightBounds(6.0,6.001,lo,hi)); + assert(DaylightBounds(0,24,lo,hi)&&lo==1&&hi==1440); + assert(DateFromOrdinal(1899,0)==-1&&DateFromOrdinal(2051,0)==-1&&DateFromOrdinal(2000,-1)==-1); +""" + ''.join(calendar) + '}') +print('PASS: random mode restrictions, full numeric ranges, independent calendar dates and strict daylight boundaries') diff --git a/tests/test_rough_intel_feedback.py b/tests/test_rough_intel_feedback.py index 4f2e294..5a3c5b1 100644 --- a/tests/test_rough_intel_feedback.py +++ b/tests/test_rough_intel_feedback.py @@ -46,8 +46,8 @@ int main(){ assert(has(unknown,"Choose either marked supply cache")&&!has(unknown,"Intel recovered")); radio.knowledge=1; string rough=Actual::PhaseDescription(&mode,true); - assert(rough!=unknown&&has(rough,"Intel recovered")&&has(rough,"500 m search square")); - assert(has(rough,"carried field map")&&has(rough,"different intelligence folder")); + assert(rough!=unknown&&has(rough,"Intel recovered")&&has(rough,"question mark")); + assert(!has(rough,"search square")&&has(rough,"different intelligence folder")); assert(has(rough,"already recovered cannot provide another clue")); assert(Actual::PhaseDescription(&mode,false)==rough); // Same task and guide copy. for(int i=0;i<10;i++) assert(Actual::PhaseDescription(&mode,true)==rough); diff --git a/tests/test_scouts.py b/tests/test_scouts.py new file mode 100644 index 0000000..5703087 --- /dev/null +++ b/tests/test_scouts.py @@ -0,0 +1,79 @@ +"""Execute production hill/road geometry and finite admission state machine.""" +import re +from policy_harness import ROOT, body, run +p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_ScoutSystem.c' +hill=body(p,'static bool Hill(') +road=body(p,'static bool OnSegment(') +prepare=body(p,'ERR_PrepareResult Prepare(') +for name in ('Math','vector','ERR_PrepareResult','Replication'): + hill=hill.replace(name+'.',name+'::');road=road.replace(name+'.',name+'::');prepare=prepare.replace(name+'.',name+'::') +prepare=prepare.replace('ERR_ScenarioDefinition definition','ERR_ScenarioDefinition* definition').replace('foreach (ERR_ScoutPost post : m_Posts)','for (ERR_ScoutPost* post : m_Posts)').replace('post.','post->').replace('bool pending;','bool pending=false;').replace('GetGame().GetWorld().','clock.').replace('GetGame().GetWorldEntity().','world.').replace('System.GetTickCount()','0') +motion=body(p,'static void Estimate(').replace('ERR_HQReport report','ERR_HQReport* report').replace('ERR_HQReport previous','ERR_HQReport* previous') +for name in ('report','previous'): motion=motion.replace(name+'.',name+'->') +for name in ('vector','ERR_HQMemoryRules'): motion=motion.replace(name+'.',name+'::') +memory=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQMemory.c' +credible=body(memory,'static float SpeedLimit(')+'\n'+body(memory,'static bool MotionCredible(') +credible=credible.replace('string ', 'std::string ') +run(r''' +#include +#include +#include +#include +#include +struct Math{static float Max(float a,float b){return std::max(a,b);}static float Clamp(float n,float a,float b){return std::clamp(n,a,b);}static float AbsFloat(float n){return std::abs(n);}}; +struct vector{float v[3]={};vector()=default;vector(float x,float y,float z):v{x,y,z}{}float&operator[](int i){return v[i];}float operator[](int i)const{return v[i];}vector operator-(vector b)const{return{v[0]-b[0],v[1]-b[1],v[2]-b[2]};}vector operator/(float f)const{return{v[0]/f,v[1]/f,v[2]/f};}static const vector Zero;static float Dot(vector a,vector b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2];}static vector Lerp(vector a,vector b,float f){return{a[0]+(b[0]-a[0])*f,a[1]+(b[1]-a[1])*f,a[2]+(b[2]-a[2])*f};}static float DistanceXZ(vector a,vector b){return std::hypot(a[0]-b[0],a[2]-b[2]);}}; +const vector vector::Zero{}; +'''+hill+'\n'+road+r''' +struct ERR_HQReport{void* Target=nullptr;void* Source=nullptr;bool Distress=false,HasVelocity=false;vector Position,Velocity;float ObservedAt=0;std::string TravelMode="on foot";}; +struct ERR_HQMemoryRules{static bool Finite(float n){return std::isfinite(n);} +'''+credible+r'''}; +'''+motion+r''' +enum class ERR_PrepareResult{PENDING,READY,FAILED}; +struct Replication{static bool server;static bool IsServer(){return server;}};bool Replication::server=true; +struct ERR_ScenarioDefinition{};struct ERR_ScoutPost{bool Ready=false,Group=false;}; +struct Clock{float now=0;float GetWorldTime(){return now;}}; +struct World{void GetWorldBounds(vector&,vector&) {}}; +struct Population{ + bool m_Stopped=false,m_Ready=false,m_Begun=false,m_Surveyed=false;ERR_ScenarioDefinition*m_Definition=nullptr;int m_Started=0,spawns=0,stocks=0,surveys=0;float m_Deadline=10;vector m_Low,m_High;Clock clock;World world; + std::vectorm_Posts; + ERR_PrepareResult survey=ERR_PrepareResult::READY,stock=ERR_PrepareResult::READY; + ERR_PrepareResult Survey(){surveys++;m_Surveyed=survey==ERR_PrepareResult::READY;return survey;} + ERR_PrepareResult Fail(const char*){m_Stopped=true;return ERR_PrepareResult::FAILED;} + bool Spawn(ERR_ScoutPost*p){spawns++;p->Group=true;return true;} + ERR_PrepareResult Stock(ERR_ScoutPost*p){stocks++;p->Ready=stock==ERR_PrepareResult::READY;return stock;} +'''+prepare+r''' +}; +int main(){ + assert(Hill(90,200,6,4));assert(!Hill(34,100,30,8));assert(!Hill(35,200,30,8));assert(!Hill(100,200,5.9,8));assert(!Hill(100,200,30,3)); + vector a{0,10,0},b{100,20,0}; + assert(OnSegment({50,15,2},a,b,4));assert(!OnSegment({50,15,2.01},a,b,4)); + assert(!OnSegment({50,25,0},a,b,4)); // aircraft/overpass != ground road + assert(!OnSegment({105,20,0},a,b,4)); // no infinite extension past end + assert(!OnSegment({50,15,0},a,a,4));assert(!OnSegment({50,15,0},a,b,0)); + // A bend is tested as its real segments, not a diagonal shortcut. + assert(!OnSegment({50,10,50},{0,10,0},{100,10,0},4));assert(!OnSegment({50,10,50},{100,10,0},{100,10,100},4)); + ERR_HQReport previous,current;previous.Target=&previous;current.Target=&previous;previous.Source=¤t;current.Source=¤t; + previous.ObservedAt=1000;previous.Position={0,0,0};current.ObservedAt=11000;current.Position={20,5,30}; + Estimate(¤t,&previous);assert(current.HasVelocity&¤t.Velocity[0]==2&¤t.Velocity[1]==0&¤t.Velocity[2]==3); + current.Target=¤t;Estimate(¤t,&previous);assert(!current.HasVelocity);current.Target=&previous; + current.Source=nullptr;Estimate(¤t,&previous);assert(!current.HasVelocity);current.Source=¤t; + current.ObservedAt=1000;Estimate(¤t,&previous);assert(!current.HasVelocity); + current.ObservedAt=33000;Estimate(¤t,&previous);assert(!current.HasVelocity); + current.ObservedAt=11000;current.Position={500,0,0};Estimate(¤t,&previous);assert(!current.HasVelocity); + current.TravelMode="in vehicle";Estimate(¤t,&previous);assert(!current.HasVelocity); + previous.TravelMode="in vehicle";Estimate(¤t,&previous);assert(current.HasVelocity&¤t.Velocity[0]==50); + previous.Distress=true;Estimate(¤t,&previous);assert(!current.HasVelocity); + ERR_ScenarioDefinition def;Population pop;ERR_ScoutPost first,second;pop.m_Posts={&first,&second}; + pop.survey=ERR_PrepareResult::PENDING;assert(pop.Prepare(&def)==ERR_PrepareResult::PENDING&&pop.spawns==0); + pop.survey=ERR_PrepareResult::READY;assert(pop.Prepare(&def)==ERR_PrepareResult::PENDING&&pop.spawns==1); + pop.stock=ERR_PrepareResult::PENDING;assert(pop.Prepare(&def)==ERR_PrepareResult::PENDING&&pop.spawns==2); + assert(pop.Prepare(&def)==ERR_PrepareResult::PENDING&&pop.spawns==2); + pop.stock=ERR_PrepareResult::READY;assert(pop.Prepare(&def)==ERR_PrepareResult::READY); + int stocks=pop.stocks;first.Group=false;first.Ready=false; + assert(pop.Prepare(&def)==ERR_PrepareResult::READY&&pop.spawns==2&&pop.stocks==stocks); // dead original never replaced/refilled + Population timeout;timeout.m_Begun=true;timeout.m_Surveyed=true;timeout.clock.now=11;assert(timeout.Prepare(&def)==ERR_PrepareResult::FAILED&&timeout.spawns==0); + Population stopped;stopped.m_Stopped=true;assert(stopped.Prepare(&def)==ERR_PrepareResult::FAILED&&stopped.surveys==0); + Replication::server=false;Population client;assert(client.Prepare(&def)==ERR_PrepareResult::FAILED&&client.surveys==0); +} +''') +print('PASS: hill prominence/elevation, actual road edges/heights/bends and server-only finite scout admission, pending stock, timeout and no replacements/refills') diff --git a/tests/test_settings_migration.py b/tests/test_settings_migration.py index dff8f34..7279e55 100644 --- a/tests/test_settings_migration.py +++ b/tests/test_settings_migration.py @@ -5,12 +5,12 @@ from policy_harness import ROOT,body,run spec=importlib.util.spec_from_file_location('import_settings',BASE/'tools/import_settings.py') m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) result,deferred=m.merge({'configVersion':1,'reportIntervalSeconds':20.0,'supportSquadLimit':2,'responseRadiusMetres':1500,'convoyCount':3},{'aiIntelChancePercent':0,'intelRevealChancePercent':100}) -assert result==dict(reportIntervalSeconds=20,supportSquadLimit=2,responseRadiusMetres=1500,aiIntelChancePercent=0,intelRevealChancePercent=100,radioDefenceSeconds=180,startTimeMinutes=840,timeMultiplier=1,startDateCode=0,weatherProfile=0,stingerChanceOneIn=15,automaticConvoyEnabled=1) -assert deferred==['ai:convoyCount=3 mapped to automaticConvoyEnabled=1; successor capped at one initial convoy'] +assert result==dict(reportIntervalSeconds=20,supportSquadLimit=2,responseRadiusMetres=1500,aiIntelChancePercent=0,intelRevealChancePercent=100,radioDefenceSeconds=180,startTimeMinutes=840,timeMultiplier=1,startDateCode=0,weatherProfile=0,stingerChanceOneIn=15,automaticConvoyEnabled=1,convoyCount=3,heliTimerMinutes=15) +assert deferred==[] for count in (0,1,3,20): values,notes=m.merge(ai={'convoyCount':count}) assert values['automaticConvoyEnabled']==int(count>0) - assert bool(notes)==(count>1) + assert values['convoyCount']==count and not notes for invalid in (-1,21,True,False,None,'1',1.5,float('nan'),float('inf')): try:m.merge(ai={'convoyCount':invalid}) except ValueError:pass @@ -38,7 +38,7 @@ with tempfile.TemporaryDirectory() as tmp: legacy=Path(tmp)/'legacy-ai.json';legacy.write_text('{"convoyCount":3}');original=legacy.read_bytes() preview=subprocess.run([sys.executable,str(BASE/'tools/import_settings.py'),'--ai',str(legacy)],capture_output=True,text=True,check=True) assert json.loads(preview.stdout)['automaticConvoyEnabled']==1 - assert 'successor capped at one initial convoy' in preview.stderr and legacy.read_bytes()==original + assert json.loads(preview.stdout)['convoyCount']==3 and not preview.stderr and legacy.read_bytes()==original # Test the actual runtime validator against the migration schema at each boundary. validator=body(BASE/'addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c','static bool Valid(').replace('string key','std::string key') diff --git a/tests/test_site_discovery_policy.py b/tests/test_site_discovery_policy.py index 206a9e8..5c44a7d 100644 --- a/tests/test_site_discovery_policy.py +++ b/tests/test_site_discovery_policy.py @@ -6,7 +6,7 @@ methods = '\n'.join(body(source, sig) for sig in ( 'static float RadioRadiusLimit(', 'static bool ValidRadius(', 'static bool InRange(' )).replace('ERR_LocationKind.', 'ERR_LocationKind::') run('''#include -enum class ERR_LocationKind { PRISON, RADIO, EXTRACTION, OUTPOST, DEPOT }; +enum class ERR_LocationKind { PRISON, RADIO, EXTRACTION, OUTPOST, DEPOT, MORTAR }; ''' + methods.replace('int kind,', 'ERR_LocationKind kind,') + ''' int main() { using K=ERR_LocationKind; @@ -14,6 +14,7 @@ int main() { assert(!ValidRadius(K::RADIO,8.01f) && !ValidRadius(K::RADIO,-1)); assert(ValidRadius(K::DEPOT,60) && ValidRadius(K::OUTPOST,60)); assert(!ValidRadius(K::DEPOT,60.01f)); + assert(ValidRadius(K::MORTAR,35) && !ValidRadius(K::MORTAR,61)); assert(ValidRadius(K::PRISON,0) && ValidRadius(K::EXTRACTION,0)); assert(!ValidRadius(K::PRISON,1) && !ValidRadius(K::EXTRACTION,1)); assert(!ValidRadius(static_cast(42),0)); diff --git a/tests/test_supply_convoys.py b/tests/test_supply_convoys.py new file mode 100644 index 0000000..ee884ad --- /dev/null +++ b/tests/test_supply_convoys.py @@ -0,0 +1,251 @@ +"""Execute production supply-circuit and combat timing policies with native boundaries doubled.""" +from policy_harness import ROOT, body, run +import re +D=ROOT/'addons/core/Scripts/Game/ERR/10_AI' + +def cpp(s): + s=re.sub(r'//[^\n]*','',s).replace('protected ','').replace('override ','').replace('foreach (','for (').replace('null','nullptr') + s=s.replace('m_Task.', 'm_Task->').replace('m_Mode.', 'm_Mode->').replace('m_Service.', 'm_Service->') + for typ in ('ERR_SupplyConvoyTask','ERR_ConvoyVehicle','ERR_SupplyTeam','ERR_ForceRoute','IEntity'): + s=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',s) + for obj in ('task','vehicle','departure','team','lost','boarding','stranded','leg','infantryTeam','member','delayed'): + s=s.replace(obj+'.',obj+'->') + for cls in ('Math','ERR_SupplyConvoyRules','ERR_HQ','ERR_ForceRules','ERR_Actors','vector','string'): + s=s.replace(cls+'.',cls+'::') + s=s.replace('bool surviving, pending;','bool surviving=false, pending=false;') + s=s.replace('float nearest = 400; bool found;', 'float nearest = 400; bool found=false;') + s=s.replace('m_Support[released].', 'm_Support[released]->').replace('Vehicles[released].', 'Vehicles[released]->').replace('infantryTeam->Group.', 'infantryTeam->Group->') + s=s.replace('->Receipt.', '->Receipt->').replace('Vehicles[2].', 'Vehicles[2]->') + s=s.replace('.Entity.', '.Entity->').replace('->Entity.', '->Entity->') + return s +common=r''' +#include +#include +#include +#include +#include +struct string:std::string{using std::string::string;string(std::string s):std::string(s){}templatestatic string Format(T...){return "event";}}; +struct vector{float x=0;static float Distance(vector a,vector b){return std::abs(a.x-b.x);}static float DistanceXZ(vector a,vector b){return Distance(a,b);}}; +templatestruct Array:std::vector{using std::vector::vector;int Count(){return this->size();}}; +namespace Math{int RandomInt(int low,int){return low;}float RandomFloat(float low,float){return low;}} +namespace ERR_HQ{void Event(string,string){}} +namespace ERR_ForceRules{bool Finite(float v){return std::isfinite(v);}bool FiniteVector(vector v){return Finite(v.x);}} +''' +next_base=cpp(body(D/'ERR_SupplyConvoySystem.c','static int NextBase(')) +run(common+'struct ERR_SupplyConvoyRules{'+next_base+r'''}; +struct Entity{vector position;vector GetOrigin(){return position;}}; +struct ERR_ForceRoute{Array points;void Add(vector p){points.push_back(p);}void Seal(){}}; +struct Receipt{string SourceId="test";}; +struct ERR_ConvoyVehicle{bool Released=false,Arrived=false;int cleared=0,RouteIndex=3;struct Entity entity;struct Entity* Entity=&entity; + void ClearOrder(){cleared++;}void Tick(void*,ERR_ForceRoute*,float){}}; +struct ERR_SupplyConvoyTask{Array Vehicles;float TravelDeadline=1000,Departure=0;bool Waiting=false,stopped=false;int LastBase=-1,Legs=0; + Array Bases={{0},{1000},{2000}};ERR_ForceRoute* Route=nullptr;struct Receipt receipt;struct Receipt* Receipt=&receipt; + void Stop(void*,string){stopped=true;}}; +struct Service{void* m_Mode=nullptr; +'''+cpp(body(D/'ERR_SupplyConvoyService.c','protected void Circuit('))+r'''}; +int main(){ + for(int last=0;last<5;last++)for(int roll=0;roll<4;roll++){int next=ERR_SupplyConvoyRules::NextBase(5,last,roll);assert(next>=0&&next<5&&next!=last);} + assert(ERR_SupplyConvoyRules::NextBase(1,0,0)==-1);assert(ERR_SupplyConvoyRules::NextBase(3,3,0)==-1); + Service s;ERR_SupplyConvoyTask t;ERR_ConvoyVehicle a,b;t.Vehicles={&a,&b}; + s.Circuit(&t,999);assert(!t.Waiting&&!a.Arrived);s.Circuit(&t,1000);assert(a.Arrived&&b.Arrived&&t.Waiting&&t.Departure==46000); + s.Circuit(&t,45999);assert(t.Legs==0);s.Circuit(&t,46000);assert(t.Legs==1&&!t.Waiting&&!a.Arrived&&!b.Arrived&&t.LastBase==0&&t.TravelDeadline==646000); + a.entity.position={250};b.entity.position={251};s.Circuit(&t,47000);assert(a.Arrived&&!b.Arrived&&!t.Waiting); + b.entity.position={249};s.Circuit(&t,48000);assert(t.Waiting);s.Circuit(&t,93000);assert(t.LastBase==1&&t.Legs==2); + a.Released=true;b.Released=true;s.Circuit(&t,94000);assert(t.stopped); // no replacements +} +''') +run(common+r''' +struct Group{int GetFaction(){return 1;}};struct IEntity{};struct Mode{int EnemyFaction(){return 1;}}; +namespace ERR_Actors{bool Alive(IEntity*){return true;}struct Group* Group(IEntity*){return nullptr;}} +struct ERR_ConvoyVehicle{bool Released=false;void* Group=nullptr;void* Entity=nullptr;}; +struct ERR_SupplyTeam{bool Released=false,aboard=true,living=true;struct Group nativeGroup;struct Group* Group=&nativeGroup;Array Members;int orders=0;void Clear(){} + bool Aboard(void*,bool required){return aboard==required;}bool Living(){return living;}void Release(void*){Released=true;}void Add(int){orders++;}}; +struct Receipt{string SourceId="test";}; +struct ERR_SupplyConvoyTask{Array Vehicles;Array Teams;struct Receipt receipt;struct Receipt* Receipt=&receipt;}; +struct Tactics{ERR_SupplyConvoyTask* m_Task;Mode mode;Mode* m_Mode=&mode;Arraym_Support;bool m_Stopped=false,m_TruckWithdrawn=false,seeing=false,truck=true; + int m_State=0,starts=0,reorders=0,withdraws=0,finishes=0,m_DismountAttempts=0,flanks=0;float m_NextDismount=0;void Flank(int,bool){flanks++;}float m_LastContact=-1,m_NextOrders=0,m_BoardDeadline=0;vector m_Contact; + bool Observe(void*,vector& p,float&){p={100};return seeing;}void Start(vector p,float){m_Contact=p;m_State=1;m_TruckWithdrawn=false;starts++;} + void Orders(bool,float){reorders++;}bool InfantryAboard(bool required){for(auto t:m_Task->Teams)if(!t->Released&&!t->Aboard(nullptr,required))return false;return true;} + void Support(int i,bool hold){if(i==2&&!hold)withdraws++;}bool Usable(int){return truck;}void Clear(){}int Board(void*,bool){return 1;} + void Finish(float){m_State=0;finishes++;} +'''+cpp(body(D/'ERR_SupplyConvoyTactics.c','void Report('))+cpp(body(D/'ERR_SupplyConvoyTactics.c','bool Tick('))+r'''}; +int main(){ + ERR_SupplyConvoyTask task;ERR_ConvoyVehicle driver;task.Vehicles={&driver,&driver,&driver};ERR_SupplyTeam left,right;task.Teams={&left,&right};Tactics t{&task}; + t.Report({100},std::numeric_limits::quiet_NaN(),0);assert(t.starts==0); + t.Report({100},-1,0);t.Report({100},10,0);t.Report({100},0,60001);assert(t.starts==0); + t.Report({100},1000,1000);assert(t.starts==1&&t.m_State==1);t.Report({0},999,1001);assert(t.m_LastContact==1000); + assert(t.Tick(60999)&&t.m_State==1&&t.withdraws==0);left.aboard=right.aboard=false; + assert(t.Tick(61000)&&t.m_State==2&&t.withdraws==1&&left.orders==1&&right.orders==1&&t.m_BoardDeadline==181000); + assert(t.Tick(180999));left.aboard=true;assert(!t.Tick(181000)&&t.finishes==1&&!left.Released&&right.Released); + right.Released=false;t.Report({100},200000,200000);t.Tick(260000);assert(t.m_State==2); + t.seeing=true;t.Tick(260001);assert(t.m_State==1&&t.starts==3); // fresh contact interrupts reboarding + t.seeing=false;t.truck=false;t.Tick(320001);assert(t.m_State==0&&left.Released&&right.Released); // truck lost: release surviving infantry + t.m_Stopped=true;t.Report({100},400000,400000);assert(t.starts==3&&!t.Tick(400000)); + left.Released=right.Released=false;left.aboard=right.aboard=true;Tactics retry{&task};retry.Report({100},0,0); + for(int i=1;i<=9;i++){retry.Report({100},10000*i,10000*i);retry.Tick(10000*i);} + assert(retry.m_DismountAttempts==6&&retry.flanks==12); // six attempts per still-mounted team, no infinite retry + +} +''') +startup=cpp(body(D/'ERR_SupplyConvoySystem.c','void Tick(')) +run(common+r''' +struct Service{int count=0;int Count(){return count;}}; +struct Startup{Service* m_Service;bool m_Stopped=false,m_Automatic=true,create=false;int m_Target=3,m_Attempts=0,calls=0; + float m_Began=-1,m_Next=0;string m_Reason; + bool StartOne(){calls++;if(create)m_Service->count++;return create;} +'''+startup+r'''}; +int main(){ + Service service;Startup s{&service};s.Tick(1000,true);s.Tick(30999,true);assert(!s.calls);s.Tick(31000,false);assert(!s.calls);s.Tick(36000,true);assert(s.calls==1); + s.create=true;s.Tick(41000,true);s.Tick(46000,true);s.Tick(51000,true);assert(service.count==3&&s.calls==4);s.Tick(56000,true);assert(s.calls==4); + Service noRoad;Startup fail{&noRoad};fail.Tick(0,true);for(int i=0;i<200;i++)fail.Tick(30000+5000*i,true);assert(fail.calls==120&&fail.m_Attempts==120); + Startup off{&noRoad};off.m_Target=0;off.Tick(0,true);off.Tick(30000,true);assert(!off.calls); + off.m_Target=3;off.m_Automatic=false;off.Tick(90000,true);assert(!off.calls); +} +''') +retain=body(D/'ERR_SupplyConvoyTactics.c','static bool Retain(') +retain=retain.replace('IEntity actor','IEntity* actor').replace('IEntity vehicle','IEntity* vehicle') +retain=retain.replace('SCR_VehicleDamageManagerComponent damage = SCR_VehicleDamageManagerComponent.Cast(vehicle.FindComponent(SCR_VehicleDamageManagerComponent));','SCR_VehicleDamageManagerComponent* damage = vehicle->damage;') +for name in ('ERR_Actors','CompartmentAccessComponent','EDamageState'):retain=retain.replace(name+'.',name+'::') +retain=retain.replace('damage.', 'damage->') +run(r""" +#include +#include +enum class EDamageState{UNDAMAGED,DAMAGED,DESTROYED}; +struct SCR_VehicleDamageManagerComponent{float health=1;bool engine=true,gear=true;EDamageState state=EDamageState::UNDAMAGED;float GetHealthScaled(){return health;}bool GetEngineFunctional(){return engine;}bool GetGearboxFunctional(){return gear;}EDamageState GetState(){return state;}}; +struct IEntity{bool player=false,fit=true;IEntity* vehicle=nullptr;SCR_VehicleDamageManagerComponent* damage=nullptr;}; +namespace ERR_Actors{bool Player(IEntity* a){return a->player;}bool CanFight(IEntity* a){return a->fit;}} +namespace CompartmentAccessComponent{IEntity* GetVehicleIn(IEntity* a){return a->vehicle;}} +struct Map:std::map{bool Contains(IEntity* a){return count(a);}IEntity* Get(IEntity* a){return at(a);}}s_Crews; +"""+retain+r""" +int main(){ + IEntity driver,truck,other;SCR_VehicleDamageManagerComponent health;truck.damage=&health;driver.vehicle=&truck; + assert(!Retain(&driver,&truck));s_Crews[&driver]=&truck;assert(Retain(&driver,&truck));assert(!Retain(&driver,&other)); + driver.player=true;assert(!Retain(&driver,&truck));driver.player=false;driver.fit=false;assert(!Retain(&driver,&truck));driver.fit=true; + health.state=EDamageState::DAMAGED;health.health=.9;assert(Retain(&driver,&truck));health.health=.25;assert(!Retain(&driver,&truck));health.health=1; + health.engine=false;assert(!Retain(&driver,&truck));health.engine=true;health.gear=false;assert(!Retain(&driver,&truck));health.gear=true; + health.state=EDamageState::DESTROYED;assert(!Retain(&driver,&truck));health.state=EDamageState::UNDAMAGED;driver.vehicle=nullptr;assert(!Retain(&driver,&truck)); +} +""") +boarding=body(D/'ERR_SupplyConvoyTactics.c','static AIWaypoint Board(') +boarding=boarding.replace('AIWaypoint Board','AIWaypoint* Board').replace('Vehicle truck','Vehicle* truck').replace('truck.', 'truck->').replace('waypoint.', 'waypoint->').replace('specific.', 'specific->').replace('null','nullptr') +boarding=boarding.replace('SCR_BoardingWaypoint waypoint','SCR_BoardingWaypoint* waypoint').replace('SCR_BoardingEntityWaypoint specific','SCR_BoardingEntityWaypoint* specific') +boarding=boarding.replace('SCR_BoardingWaypoint.Cast(', 'dynamic_cast(').replace('SCR_BoardingEntityWaypoint.Cast(', 'dynamic_cast(').replace('ERR_Actors.Spawn','ERR_Actors::Spawn').replace('EAIWaypointCompletionType.All','EAIWaypointCompletionType::All') +run(r""" +#include +#include +using ResourceName=std::string; +enum class EAIWaypointCompletionType{Leader,All}; +struct Vehicle{int GetOrigin(){return 7;}}; +struct AIWaypoint{virtual ~AIWaypoint(){}int radius=9;EAIWaypointCompletionType completion=EAIWaypointCompletionType::Leader;void SetCompletionRadius(int n){radius=n;}void SetCompletionType(EAIWaypointCompletionType type){completion=type;}}; +struct SCR_BoardingWaypoint:AIWaypoint{bool driver=true,gunner=true,cargo=false;void SetAllowance(bool d,bool g,bool c){driver=d;gunner=g;cargo=c;}}; +struct SCR_BoardingEntityWaypoint:SCR_BoardingWaypoint{Vehicle* vehicle=nullptr;void SetEntity(Vehicle* v){vehicle=v;}}; +namespace ERR_Actors{bool fail=false;AIWaypoint* Spawn(ResourceName resource,int){if(fail)return nullptr;if(resource.find("GetIn.et")!=std::string::npos)return new SCR_BoardingEntityWaypoint();return new SCR_BoardingWaypoint();}} +"""+boarding+r""" +int main(){ + Vehicle truck;assert(!Board(nullptr,false)); + auto out=dynamic_cast(Board(&truck,false));assert(out&&out->completion==EAIWaypointCompletionType::All&&out->radius==100&&!out->driver&&!out->gunner&&out->cargo); + auto in=dynamic_cast(Board(&truck,true));assert(in&&in->radius==100&&in->completion==EAIWaypointCompletionType::All&&in->vehicle==&truck&&!in->driver&&!in->gunner&&in->cargo); + delete out;delete in;ERR_Actors::fail=true;assert(!Board(&truck,true)); +} +""") +delivery=body(D/'ERR_SupplyConvoyService.c','override bool ApplyCombatOrder(') +delivery=delivery.replace('override ','').replace('ERR_SupplyConvoyTask.Cast(task)','dynamic_cast(task)') +for typ in ('ERR_ConvoyTask','ERR_SupplyConvoyTask','ERR_ConvoyCombatOrder'):delivery=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',delivery) +for name in ('supply','order'):delivery=delivery.replace(name+'.',name+'->') +delivery=delivery.replace('Tactics.','Tactics->').replace('super.ApplyCombatOrder','Base::ApplyCombatOrder') +run(r""" +#include +struct ERR_ConvoyTask{virtual ~ERR_ConvoyTask(){}}; +struct ERR_ConvoyCombatOrder{bool Hold=true;int Position=3;float ObservedAt=100;}; +struct Tactics{int state=0,reports=0;int State(){return state;}void Report(int,float,float){reports++;}}; +struct ERR_SupplyConvoyTask:ERR_ConvoyTask{struct Tactics tactics;struct Tactics* Tactics=&tactics;}; +struct Base{bool accept=true;int applied=0;bool ApplyCombatOrder(ERR_ConvoyTask*,ERR_ConvoyCombatOrder*,float){applied++;return accept;}}; +struct Service:Base{ +"""+delivery+r""" +}; +int main(){ + Service service;ERR_SupplyConvoyTask task;ERR_ConvoyCombatOrder order; + for(int state=0;state<3;state++){task.tactics.state=state;assert(service.ApplyCombatOrder(&task,&order,100));} + assert(task.tactics.reports==3);order.Hold=false;assert(!service.ApplyCombatOrder(&task,&order,100));assert(service.applied==3); + task.tactics.state=0;assert(service.ApplyCombatOrder(&task,&order,100));assert(task.tactics.reports==3); + service.accept=false;order.Hold=true;assert(!service.ApplyCombatOrder(&task,&order,100)&&task.tactics.reports==3); + assert(!service.ApplyCombatOrder(nullptr,&order,100));assert(!service.ApplyCombatOrder(&task,nullptr,100)); +} +""") +print('PASS: actual supply route cycling/arrival/stops, finite startup bounds, contact freshness, dismount hold, quiet interval, interrupted boarding, timeout and lost-truck release') + +# Mounted groups retain their native spawn origin; sighting distance must follow vehicles. +distance=cpp(body(D/'ERR_SupplyConvoyTactics.c','protected float DistanceToConvoy(')).replace('Math.Min','Math::Min') +observe=cpp(body(D/'ERR_SupplyConvoyTactics.c','protected bool Observe(')) +for typ in ('SCR_AIGroup','SCR_AIGroupPerception','PerceptionManager','SCR_AITargetInfo'): + observe=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',observe) +for obj in ('group','perception','manager','target'):observe=observe.replace(obj+'.',obj+'->') +observe=observe.replace('out vector position','vector& position').replace('inout float nearest','float& nearest').replace('bool found;','bool found=false;') +observe=observe.replace('GetGroupUtilityComponent().','GetGroupUtilityComponent()->').replace('GetFaction().','GetFaction()->').replace('GetGame().','GetGame()->').replace('EAITargetInfoCategory.','EAITargetInfoCategory::') +run(common+r""" +namespace Math{float Min(float a,float b){return a Vehicles;}; +struct Faction{bool hostile=true;bool IsFactionEnemy(Faction* other){return other->hostile;}}; +enum class EAITargetInfoCategory{IDENTIFIED,SUSPECTED}; +struct SCR_AITargetInfo{void* m_Entity=this;Faction* m_Faction=nullptr;float m_fTimestamp=10;EAITargetInfoCategory m_eCategory=EAITargetInfoCategory::IDENTIFIED;vector m_vWorldPos={5100};}; +struct SCR_AIGroupPerception{Array m_aTargets;}; +struct Utility{SCR_AIGroupPerception* m_Perception=nullptr;bool allowed=true;bool IsPositionAllowed(vector){return allowed;}}; +struct SCR_AIGroup{Faction faction;Utility utility;Utility* GetGroupUtilityComponent(){return &utility;}Faction* GetFaction(){return &faction;}vector GetOrigin(){return {0};}}; +struct PerceptionManager{float now=10;float GetTime(){return now;}}; +struct Game{PerceptionManager manager;PerceptionManager* GetPerceptionManager(){return &manager;}}game; +Game* GetGame(){return &game;} +struct Observer{ERR_SupplyConvoyTask* m_Task; +"""+distance+observe+r""" +}; +int main(){ + Entity car{{5000}},lost{{0}};ERR_ConvoyVehicle live{false,&car},released{true,&lost};ERR_SupplyConvoyTask task{{&live,&released}};Observer observer{&task}; + Faction enemy;SCR_AITargetInfo target;target.m_Faction=&enemy;SCR_AIGroupPerception perception{{&target}};SCR_AIGroup group;group.utility.m_Perception=&perception; + vector position;float nearest=400;assert(observer.Observe(&group,position,nearest)&&position.x==5100&&nearest==100); + target.m_vWorldPos={100};nearest=400;assert(!observer.Observe(&group,position,nearest)); // stale group/spawn origin is irrelevant + target.m_vWorldPos={5401};assert(!observer.Observe(&group,position,nearest));target.m_vWorldPos={5100}; + target.m_eCategory=EAITargetInfoCategory::SUSPECTED;assert(!observer.Observe(&group,position,nearest));target.m_eCategory=EAITargetInfoCategory::IDENTIFIED; + target.m_fTimestamp=4;assert(!observer.Observe(&group,position,nearest));target.m_fTimestamp=11;assert(!observer.Observe(&group,position,nearest));target.m_fTimestamp=5; + assert(observer.Observe(&group,position,nearest));nearest=400;enemy.hostile=false;assert(!observer.Observe(&group,position,nearest));enemy.hostile=true; + group.utility.allowed=false;assert(!observer.Observe(&group,position,nearest));group.utility.allowed=true; + live.Released=true;assert(!observer.Observe(&group,position,nearest));assert(!observer.Observe(nullptr,position,nearest)); +} +""") +retain_group=body(D/'ERR_SupplyConvoyTactics.c','static bool RetainGroup(') +retain_group=retain_group.replace('SCR_AIGroup group','SCR_AIGroup* group').replace('IEntity vehicle','IEntity* vehicle').replace('IEntity leader','IEntity* leader').replace('group.','group->').replace('CompartmentAccessComponent.','CompartmentAccessComponent::') +source=(D/'ERR_SupplyConvoyTactics.c').read_text() +overrides=[] +for cls in ('SCR_AIGetOutVehicle','SCR_AIGetOutActivity'): + section=source[source.index('modded class '+cls):];section=section[:section.index('\nclass ') if '\nclass ' in section else len(section)] + for method in ('CustomEvaluate','EvaluatePriorityLevel'): + # Each method's exact production body, selecting only this class's first occurrence. + a=section.index('override float '+method+'(');b=section.index('\n }',a)+3 + method_source=section[a:b].replace('override ','').replace('m_Utility.','m_Utility->').replace('ERR_ConvoyMountedCrew.','ERR_ConvoyMountedCrew::').replace('super.','Base::') + overrides.append(method_source) +run(r""" +#include +struct IEntity{bool held=false;IEntity* vehicle=nullptr;}; +struct SCR_AIGroup{int players=0;IEntity* leader=nullptr;int GetPlayerCount(bool){return players;}IEntity* GetLeaderEntity(){return leader;}}; +namespace CompartmentAccessComponent{IEntity* GetVehicleIn(IEntity* actor){return actor->vehicle;}} +struct ERR_ConvoyMountedCrew{static bool Retain(IEntity* actor,IEntity* vehicle){return actor&&actor->held&&vehicle&&actor->vehicle==vehicle;} +"""+retain_group+r""" +}; +struct Utility{IEntity* m_OwnerEntity;SCR_AIGroup* m_Owner;}; +struct Base{float CustomEvaluate(){return 1162;}float EvaluatePriorityLevel(){return 1000;}}; +struct Individual:Base{Utility* m_Utility=nullptr;struct{IEntity* m_Value=nullptr;}m_Vehicle; +"""+''.join(overrides[:2])+r""" +}; +struct Activity:Base{Utility* m_Utility=nullptr;struct{IEntity* m_Value=nullptr;}m_Vehicle; +"""+''.join(overrides[2:])+r""" +}; +int main(){ + IEntity truck,driver{true,&truck};SCR_AIGroup group{0,&driver};Utility utility{&driver,&group};Individual individual;Activity activity; + individual.m_Utility=&utility;individual.m_Vehicle.m_Value=&truck;activity.m_Utility=&utility;activity.m_Vehicle.m_Value=&truck; + assert(individual.CustomEvaluate()==0&&individual.EvaluatePriorityLevel()==0);assert(activity.CustomEvaluate()==0&&activity.EvaluatePriorityLevel()==0); + assert(ERR_ConvoyMountedCrew::RetainGroup(&group,nullptr));group.players=1;assert(activity.CustomEvaluate()==1162&&activity.EvaluatePriorityLevel()==1000);group.players=0; + driver.held=false;assert(individual.CustomEvaluate()==1162&&individual.EvaluatePriorityLevel()==1000);assert(activity.CustomEvaluate()==1162&&activity.EvaluatePriorityLevel()==1000); + group.leader=nullptr;assert(!ERR_ConvoyMountedCrew::RetainGroup(&group,&truck));assert(!ERR_ConvoyMountedCrew::RetainGroup(nullptr,&truck)); +} +""") +print('PASS: moving-convoy perception boundaries and scoped individual/group abandonment priorities') diff --git a/tests/test_vehicle_maintenance_stock.py b/tests/test_vehicle_maintenance_stock.py index 9b189ef..ad92f76 100644 --- a/tests/test_vehicle_maintenance_stock.py +++ b/tests/test_vehicle_maintenance_stock.py @@ -21,7 +21,7 @@ for name,site in sites.items(): stock[name].append((ref[1],ref[2],count,item)) assert sum(item[2] for items in stock.values() for item in items)<=256 # Both established routes to radio intel and the prison field map remain usable. -for name in ('everon-supply-1','everon-supply-2'): +for name in ('everon-supply-1','everon-medical-1'): assert 'RevealOnEscape 1' in sites[name] clues=[i for i in stock[name] if i[1]=='Prefabs/ERR/ObjectiveDocument.et'] assert sum(i[2] for i in clues)>=2 and all('TargetId "everon-radio-1"' in i[3] for i in clues) @@ -29,8 +29,11 @@ assert any(i[1].endswith('PaperMap_01_folded.et') for i in stock['everon-prison- kitpath='Prefabs/ERR/Items/EmergencyRepairKit.et' canpath='Prefabs/Items/Fuel/Jerrycan_01/Jerrycan_01_item.et' maintenance=[(name,i) for name,items in stock.items() for i in items if i[1] in (kitpath,canpath)] -assert len(maintenance)==2 and all(name=='everon-supply-2' for name,_ in maintenance) -assert {i[1]:i[2] for _,i in maintenance}=={kitpath:1,canpath:2} +assert 'everon-supply-2' not in sites +assert not maintenance +assert all('Transport ERR_SiteTransport' not in site for site in sites.values()) +assert 'RequireAirfield 1' in sites['everon-extraction-1'] +assert 'Position 4800.953 0 11487.412' in sites['everon-extraction-1'] # Own container must be finite and must not turn off native resource eligibility. kit=(R/'addons/core'/kitpath).read_text() assert kit.startswith('GenericEntity : "{33B2DFDCD0EBA3DB}Prefabs/Items/Equipment/Kits/RepairKit_01/RepairKit_01_wrench.et"') @@ -38,10 +41,10 @@ assert kit.count('SCR_ResourceContainer ')==1 and 'm_fResourceValueCurrent 20' i for forbidden in ('m_aGenerators','m_aDisabledResourceTypes','m_eFactionUsageCheck','m_fMaxHealScaled','SCR_RepairSupportStationComponent','SCR_ResourceConsumer '): assert forbidden not in kit,forbidden meta=(R/'addons/core'/Path(kitpath+'.meta')).read_text();guid=re.search(r'Name "\{([A-F0-9]{16})\}',meta)[1] -assert [i[0] for _,i in maintenance if i[1]==kitpath]==[guid] +assert guid=='C09C7C97EFA34909' vanilla=json.loads((R/'docs/vanilla-resources.json').read_text()) assert vanilla[canpath]=='12D5AD21E383B768' assert vanilla['Prefabs/Items/Equipment/Kits/RepairKit_01/RepairKit_01_wrench.et']=='33B2DFDCD0EBA3DB' evidence=json.loads((R/'docs/vehicle-maintenance-source-evidence.json').read_text()) assert evidence['finite_initial_stock']['litres_per_can']==5 and evidence['native_validation']=='pending' -print('PASS: finite maintenance stock/resource identity, bounded scenario stock and preserved two-cache clue redundancy; native interaction not claimed') +print('PASS: removed guarded vehicle cache and maintenance stock, airfield-only extraction config, and two remaining finite radio-clue sources') diff --git a/tools/check.py b/tools/check.py index 2699f49..1ec543a 100644 --- a/tools/check.py +++ b/tools/check.py @@ -72,7 +72,7 @@ def check(): assert set(mapped)==set(inventory['script_paths']), 'Original inventory has untracked or unknown entries' print(f'PASS: {len(mapped)} original script entries tracked across {len(feature_ids)} unfinished responsibilities') print(f'PASS: {len(ids)} isolated addons, acyclic dependencies, {len(resources)} unique resources, exact vanilla/authorized external references') - for name in ['test_hq_road_intercept.py', 'test_hq_delivered_task.py', 'test_hq_local_handoff.py', 'test_hq_local_handoff_intake.py', 'test_native_local_handoff_probe.py', 'test_infantry_resupply_waypoints.py', 'test_infantry_placement_settle.py', 'test_native_infantry_fixture_lifecycle.py', 'test_infantry_ammo_access.py', 'test_infantry_resupply_planning.py', 'test_enemy_gunner_skill.py', 'test_native_memory_deadlines.py', 'test_convoy_auto_settings.py', 'test_convoy_startup.py', 'test_failure_result_refresh.py', 'test_infantry_resupply_coordinator.py', 'test_infantry_ammo_transfer.py', 'test_hq_ammo_reporting.py', 'test_enemy_radio.py', 'test_native_maintenance_stock.py', 'test_packed_external_game.py', 'test_hq_radio_node.py', 'test_vehicle_maintenance_stock.py', 'test_vehicle_entry_diagnostics.py', 'test_vehicle_security_native_probe.py', 'test_vehicle_security.py', 'test_native_convoy_cover_probe.py', 'test_enemy_optics.py', 'test_convoy_combat.py', 'test_ai_stinger_admin.py', 'test_hq_ammo_dispatch.py', 'test_hq_convoy_combat.py', 'test_hq_ammo_sampler.py', 'test_native_hq_body_probe.py', 'test_hq_delivered_casualties.py', 'test_external_profile_review.py', 'test_hq_uplink_fairness.py', 'test_actor_group.py', 'test_native_package_external.py', 'test_native_convoy_probe.py', 'test_convoy_placement.py', 'test_convoy_road_plan.py', 'test_hq_convoy_dispatch.py', 'test_convoy_admission.py', 'test_convoy_dispatch.py', 'test_convoy_lifecycle.py', 'test_convoy_budget.py', 'test_hq_body_observer.py', 'test_hq_status_sampler.py', 'test_hq_watch.py', 'test_hq_assignment_lifecycle.py', 'test_force_ledger.py', 'test_enemy_equipment.py', 'test_hq_doctrine.py', 'test_hq_coverage.py', 'test_hq_pursuit.py', 'test_hq_memory.py', 'test_external_addons.py', 'test_admin_tick_delivery.py', 'test_project_schema.py', 'test_admin_session.py', 'test_admin_providers.py', 'test_admin_menu.py', 'test_admin_player_tools.py', 'test_intel_admin.py', 'test_intel_reroll.py', 'test_intel_discovery_feedback.py', 'test_hq_report_selection.py', 'test_casualty_feedback.py', 'test_initial_admission_exhaustion.py', 'test_prison_held_action_proof.py', 'test_prison_held_action_evidence.py', 'test_prison_held_input_adapter.py', 'test_prison_held_runner.py', 'test_prison_coarse_facing.py', 'test_aircraft_field_probe.py', 'test_aircraft_field_runner.py', 'test_aircraft_field_rules.py', 'test_aircraft_field_envelope.py', 'test_aircraft_field_flight.py', 'test_command_maintenance.py', 'test_aircraft_service.py', 'test_aircraft_service_cancel_runner.py', 'test_prison_action_selection_proof.py', 'test_prison_action_selection_evidence.py', 'test_ordinary_gate_choices.py', 'test_aircraft_control_bridge.py', 'test_prison_alignment_proof.py', 'test_prison_alignment_evidence.py', 'test_aircraft_route_demand.py', 'test_prison_approach_proof.py', 'test_prison_approach_evidence.py', 'test_ordinary_gate_evidence.py', 'test_ordinary_gate_observer.py', 'test_ordinary_look_observer.py', 'test_ordinary_look_evidence.py', 'test_relative_look_runner.py', 'test_radio_waiting_text.py', 'test_inventory_control_runner.py', 'test_inventory_layout.py', 'test_headless_input_proof.py', 'test_headless_input_exercise_runner.py', 'test_private_prefix_cleanup.py', 'test_runner_private_cleanup.py', 'test_ordinary_input_runner.py', 'test_ordinary_input_observer.py', 'test_aircraft_route_envelope.py', 'test_aircraft_route_policy.py', 'test_aircraft_route_probe.py', 'test_rough_intel_feedback.py', 'test_headless_display_probe.py', 'test_headless_display_runner.py', 'test_preparation_status.py', 'test_hint_delivery.py', 'test_native_end_lifecycle.py', 'test_native_end_runner.py', 'test_escape_result_wording.py', 'test_native_stats.py', 'test_aircraft_translation.py', 'test_spawn_retry_budget.py', 'test_action_feedback.py', 'test_native_aircraft_control.py', 'test_aircraft_flight_policy.py', 'test_aircraft_flight_envelope.py', 'test_ordinary_screenshot.py', 'test_participant_selection.py', 'test_native_shared_map_ownership.py', 'test_shared_map_ownership.py', 'test_intel_registration.py', 'test_mortar_activity_completion.py', 'test_packed_chat_game.py', 'test_native_mortar_refresh.py', 'test_archive_packed.py', 'test_mortar_refresh.py', 'test_packed_core_game.py', 'test_pak_inventory.py', 'test_native_mortar_salvo.py', 'test_spectator_guidance.py', 'test_native_package.py', 'test_item_station_teardown.py', 'test_source_bundle.py', 'test_native_flight_footprint.py', 'test_native_flight_input_diagnostics.py', 'test_mortar_logistics.py', 'test_mortar_courier_storage.py','test_powered_flight_pad.py','test_prison_escape_policy.py','test_powered_flight_policy.py','test_flight_action_runner.py','test_native_local_admission_probe.py','test_mortar_report_ledger.py','test_dedicated_native.py','test_native_mortar_transfer.py','test_mortar_aim_renewal.py','test_dedicated_preflight.py','test_mortar_transactions.py','test_native_mortar_probe.py','test_diagnostic_no_splash.py','test_casualty_incapacitation.py','test_casualty_remove_probe.py','test_extraction_placement_policy.py','test_guard_cancellation_policy.py','test_casualty_ground_policy.py','test_casualty_reservation_death.py','test_casualty_loading_policy.py','test_initial_guard_policy.py','test_headless_game_runner.py','test_vanilla_game_control.py','test_archive_game_control.py','test_site_discovery_policy.py','test_faction_catalog_scope.py','test_mission_rules.py','test_admin_setting_policy.py', 'test_player_admission_policy.py','test_progression_identity.py','test_discovery_map_position.py','test_local_admission.py','test_prison_placement_policy.py', 'test_recovery_policy.py','test_time_policy.py','test_environment_policy.py','test_environment_restart_runner.py', 'test_inventory_enumeration_policy.py','test_radio_defence_policy.py','test_radio_feedback_policy.py','test_performance_comparison.py','test_release_check.py','test_settings_migration.py','hq_network_regression.py','helicopter_availability_regression.py','mortar_salvo_regression.py']: + for name in ['test_hunter_lifecycle.py', 'test_enemy_awareness.py', 'test_intel_body_inspection.py', 'test_air_support_policy.py', 'test_air_logistics_orders.py', 'test_air_recon.py', 'test_scouts.py', 'test_mortar_station.py', 'test_random_settings.py', 'test_debug_ai.py', 'test_generated_sites.py', 'test_faction_selection.py', 'test_content_policy.py', 'test_hq_road_intercept.py', 'test_hq_delivered_task.py', 'test_hq_local_handoff.py', 'test_hq_local_handoff_intake.py', 'test_native_local_handoff_probe.py', 'test_infantry_resupply_waypoints.py', 'test_infantry_placement_settle.py', 'test_native_infantry_fixture_lifecycle.py', 'test_infantry_ammo_access.py', 'test_infantry_resupply_planning.py', 'test_enemy_gunner_skill.py', 'test_native_memory_deadlines.py', 'test_convoy_auto_settings.py', 'test_convoy_startup.py', 'test_failure_result_refresh.py', 'test_infantry_resupply_coordinator.py', 'test_infantry_ammo_transfer.py', 'test_hq_ammo_reporting.py', 'test_enemy_radio.py', 'test_native_maintenance_stock.py', 'test_packed_external_game.py', 'test_hq_radio_node.py', 'test_vehicle_maintenance_stock.py', 'test_vehicle_entry_diagnostics.py', 'test_vehicle_security_native_probe.py', 'test_vehicle_security.py', 'test_native_convoy_cover_probe.py', 'test_enemy_optics.py', 'test_convoy_combat.py', 'test_ai_stinger_admin.py', 'test_hq_ammo_dispatch.py', 'test_hq_convoy_combat.py', 'test_hq_ammo_sampler.py', 'test_native_hq_body_probe.py', 'test_hq_delivered_casualties.py', 'test_external_profile_review.py', 'test_hq_uplink_fairness.py', 'test_actor_group.py', 'test_native_package_external.py', 'test_native_convoy_probe.py', 'test_convoy_placement.py', 'test_convoy_road_plan.py', 'test_hq_convoy_dispatch.py', 'test_convoy_admission.py', 'test_convoy_dispatch.py', 'test_convoy_lifecycle.py', 'test_convoy_budget.py','test_supply_convoys.py', 'test_hq_body_observer.py', 'test_hq_status_sampler.py', 'test_hq_watch.py', 'test_hq_assignment_lifecycle.py', 'test_force_ledger.py', 'test_enemy_equipment.py', 'test_hq_doctrine.py', 'test_hq_coverage.py', 'test_hq_pursuit.py', 'test_hq_memory.py', 'test_external_addons.py', 'test_admin_tick_delivery.py', 'test_project_schema.py', 'test_admin_session.py', 'test_admin_providers.py', 'test_admin_menu.py', 'test_admin_player_tools.py', 'test_intel_admin.py', 'test_intel_reroll.py', 'test_intel_discovery_feedback.py', 'test_hq_report_selection.py', 'test_casualty_feedback.py', 'test_initial_admission_exhaustion.py', 'test_prison_held_action_proof.py', 'test_prison_held_action_evidence.py', 'test_prison_held_input_adapter.py', 'test_prison_held_runner.py', 'test_prison_coarse_facing.py', 'test_aircraft_field_probe.py', 'test_aircraft_field_runner.py', 'test_aircraft_field_rules.py', 'test_aircraft_field_envelope.py', 'test_aircraft_field_flight.py', 'test_command_maintenance.py', 'test_aircraft_service.py', 'test_aircraft_service_cancel_runner.py', 'test_prison_action_selection_proof.py', 'test_prison_action_selection_evidence.py', 'test_ordinary_gate_choices.py', 'test_aircraft_control_bridge.py', 'test_prison_alignment_proof.py', 'test_prison_alignment_evidence.py', 'test_aircraft_route_demand.py', 'test_prison_approach_proof.py', 'test_prison_approach_evidence.py', 'test_ordinary_gate_evidence.py', 'test_ordinary_gate_observer.py', 'test_ordinary_look_observer.py', 'test_ordinary_look_evidence.py', 'test_relative_look_runner.py', 'test_radio_waiting_text.py', 'test_inventory_control_runner.py', 'test_inventory_layout.py', 'test_headless_input_proof.py', 'test_headless_input_exercise_runner.py', 'test_private_prefix_cleanup.py', 'test_runner_private_cleanup.py', 'test_ordinary_input_runner.py', 'test_ordinary_input_observer.py', 'test_aircraft_route_envelope.py', 'test_aircraft_route_policy.py', 'test_aircraft_route_probe.py', 'test_rough_intel_feedback.py', 'test_headless_display_probe.py', 'test_headless_display_runner.py', 'test_preparation_status.py', 'test_hint_delivery.py', 'test_native_end_lifecycle.py', 'test_native_end_runner.py', 'test_escape_result_wording.py', 'test_native_stats.py', 'test_aircraft_translation.py', 'test_spawn_retry_budget.py', 'test_action_feedback.py', 'test_native_aircraft_control.py', 'test_aircraft_flight_policy.py', 'test_aircraft_flight_envelope.py', 'test_ordinary_screenshot.py', 'test_participant_selection.py', 'test_native_shared_map_ownership.py', 'test_shared_map_ownership.py', 'test_intel_registration.py', 'test_mortar_activity_completion.py', 'test_packed_chat_game.py', 'test_native_mortar_refresh.py', 'test_archive_packed.py', 'test_mortar_refresh.py', 'test_packed_core_game.py', 'test_pak_inventory.py', 'test_native_mortar_salvo.py', 'test_spectator_guidance.py', 'test_native_package.py', 'test_item_station_teardown.py', 'test_source_bundle.py', 'test_native_flight_footprint.py', 'test_native_flight_input_diagnostics.py', 'test_mortar_logistics.py', 'test_mortar_courier_storage.py','test_powered_flight_pad.py','test_prison_escape_policy.py','test_powered_flight_policy.py','test_flight_action_runner.py','test_native_local_admission_probe.py','test_mortar_report_ledger.py','test_dedicated_native.py','test_native_mortar_transfer.py','test_mortar_aim_renewal.py','test_dedicated_preflight.py','test_mortar_transactions.py','test_native_mortar_probe.py','test_diagnostic_no_splash.py','test_casualty_incapacitation.py','test_casualty_remove_probe.py','test_extraction_placement_policy.py','test_guard_cancellation_policy.py','test_casualty_ground_policy.py','test_casualty_reservation_death.py','test_casualty_loading_policy.py','test_initial_guard_policy.py','test_headless_game_runner.py','test_vanilla_game_control.py','test_archive_game_control.py','test_site_discovery_policy.py','test_faction_catalog_scope.py','test_mission_rules.py','test_admin_setting_policy.py', 'test_player_admission_policy.py','test_progression_identity.py','test_discovery_map_position.py','test_local_admission.py','test_prison_placement_policy.py', 'test_recovery_policy.py','test_time_policy.py','test_environment_policy.py','test_environment_restart_runner.py', 'test_inventory_enumeration_policy.py','test_radio_defence_policy.py','test_radio_feedback_policy.py','test_performance_comparison.py','test_release_check.py','test_settings_migration.py','hq_network_regression.py','helicopter_availability_regression.py','mortar_salvo_regression.py']: subprocess.run([sys.executable,str(ROOT/'tests'/name)],check=True) print('All repository checks passed. Native compilation and multiplayer tests are separate requirements.') if __name__=='__main__':check() diff --git a/tools/external_addons.py b/tools/external_addons.py index 9add7b5..5f07e99 100644 --- a/tools/external_addons.py +++ b/tools/external_addons.py @@ -4,8 +4,9 @@ Never copy third-party assets or expose the whole workshop tree to Workbench. from pathlib import Path, PurePosixPath import hashlib,json,re,stat BASE='58D0FB3206B6F859' -DIRECT=('595F2BF2F44836FB','629B2BA37EFFD577','5E524E4FEECCA92B') +DIRECT=('595F2BF2F44836FB','629B2BA37EFFD577','5E524E4FEECCA92B','65930CB4CD0237B2') ALLOWED={ + '65930CB4CD0237B2':{BASE}, '595F2BF2F44836FB':{BASE,'1337C0DE5DABBEEF','BADC0DEDABBEDA5E'}, '1337C0DE5DABBEEF':{BASE},'BADC0DEDABBEDA5E':{BASE}, '629B2BA37EFFD577':{BASE},'5E389BB9F58B79A6':{BASE,'629B2BA37EFFD577'}, diff --git a/tools/headless_workbench.py b/tools/headless_workbench.py index a709987..39a95bc 100644 --- a/tools/headless_workbench.py +++ b/tools/headless_workbench.py @@ -14,7 +14,7 @@ p.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam') p.add_argument('--external-addons', type=Path, help='Installed RHS/WCS/BMP source root; expose only audited closure in a separate Chat profile') p.add_argument('--compat', type=Path, required=True, help='Disposable cloned Proton compatdata directory outside Steam') p.add_argument('--profile', type=Path, required=True, help='Separate disposable successor Workbench profile') -p.add_argument('--smoke', choices=['hq-local-handoff', 'infantry-transfer', 'hq-body', 'convoy', 'hq-memory', 'aircraft-field', 'aircraft-route', 'native-end-active', 'native-end-preparing', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate', 'map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory') +p.add_argument('--smoke', choices=['aggression', 'air-logistics', 'air-assault', 'air-recon', 'airfield-markers', 'opening-tools', 'mortar-station', 'generated-sites', 'hq-local-handoff', 'infantry-transfer', 'hq-body', 'convoy', 'supply-convoys', 'hq-memory', 'aircraft-field', 'aircraft-route', 'native-end-active', 'native-end-preparing', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate', 'map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory') p.add_argument('--convoy-cover', action='store_true', help='After convoy driving, arrange the existing prisoner to verify native sighting/radio HOLD and RESUME') p.add_argument('--field-terrain-audit', action='store_true', help='After a fixed aircraft-field survey refusal, log one read-only 1681-node coarse terrain grid; never selects or qualifies a corridor') p.add_argument('--field-service-cancel', action='store_true', help='Explicit aircraft-field variant: Stop the original retained service during measured cruise; requires physical cancellation landing') @@ -23,10 +23,12 @@ p.add_argument('--station-teardown-diagnostics', action='store_true', help='Opt- p.add_argument('--flight-autohover', action='store_true', help='Opt-in owned native helicopter autohover experiment; requires Core powered-flight and actor-local action inputs') p.add_argument('--flight-action-inputs', action='store_true', help='Experimental exact-AI ActionManager inputs; Core powered-flight fixture only') p.add_argument('--flight-input-diagnostics', action='store_true', help='Opt-in read-only powered-flight input/application and native rotor signal observations') +p.add_argument('--recon-takeover-only', action='store_true', help='Air-recon fixture: only native faction handover/release and explicit aircraft deletion') p.add_argument('--rounds', type=int, default=2) p.add_argument('--timeout', type=int, default=360) p.add_argument('--project', choices=['core', 'chat'], default='chat', help='Diagnostic fixture family (both load the combined mod)') a = p.parse_args() +if a.recon_takeover_only and (a.smoke != 'air-recon' or a.project != 'core' or a.rounds != 1): p.error('--recon-takeover-only requires Core air-recon and one round') steam, compat, profile = a.steam.resolve(), a.compat.resolve(), a.profile.resolve() if not 1 <= a.rounds <= 10 or not 60 <= a.timeout <= 1800: p.error('Use 1..10 rounds and a 60..1800 second timeout') @@ -41,6 +43,7 @@ if a.smoke in {'mortar-refresh', 'mortar-salvo', 'mortar-expiry', 'mortar-transf if a.smoke in {'aircraft-field', 'aircraft-route', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 420): p.error('aircraft-field/aircraft-route/aircraft-control/aircraft-cancel/aircraft-translate requires Chat, one round, and at least 420 seconds') if a.convoy_cover and a.smoke != 'convoy': p.error('--convoy-cover requires --smoke convoy') if a.convoy_cover and a.timeout < 900: p.error('--convoy-cover requires timeout at least900 seconds') +if a.smoke == 'supply-convoys' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 900): p.error('supply-convoys requires Chat, one round, and at least900 seconds') if a.smoke == 'convoy' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 600): p.error('convoy requires Chat, exactly one round, and timeout at least600 seconds') if a.smoke == 'infantry-transfer' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 360): p.error('infantry-transfer requires Chat, one round and timeout at least360 seconds') if a.smoke == 'hq-local-handoff' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 360): p.error('hq-local-handoff requires Chat, one round and timeout at least360 seconds') @@ -111,8 +114,9 @@ if a.station_teardown_diagnostics: command += ['-errStationTeardownDiag', '1'] if a.flight_input_diagnostics: command += ['-errFlightInputDiag', '1'] if a.flight_action_inputs: command += ['-errFlightActionInputs', '1'] if a.flight_autohover: command += ['-errFlightAutohover', '1'] +if a.recon_takeover_only: command += ['-errReconTakeoverOnly', '1'] if a.project == 'core': - if a.smoke not in {'native-end-active', 'native-end-preparing', 'station-teardown', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'victory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'}: p.error('Unsupported Core-only fixture') + if a.smoke not in {'aggression', 'air-logistics', 'air-assault', 'air-recon', 'airfield-markers', 'opening-tools', 'mortar-station', 'generated-sites', 'native-end-active', 'native-end-preparing', 'station-teardown', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'victory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'}: p.error('Unsupported Core-only fixture') command[command.index('-plugin=ERR_ValidateWorldPlugin')] = '-plugin=ERR_ValidateBaseWorldPlugin' command[command.index('-errSmoke')] = '-errBaseSmoke' if a.smoke in {'station-teardown', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'} and a.project != 'core': p.error('This fixture requires --project core') diff --git a/tools/import_settings.py b/tools/import_settings.py index e932e03..22a4305 100644 --- a/tools/import_settings.py +++ b/tools/import_settings.py @@ -15,8 +15,10 @@ SCHEMA={ 'weatherProfile':(0,0,3), 'stingerChanceOneIn':(15,0,10000), 'automaticConvoyEnabled':(1,0,1), + 'convoyCount':(3,0,20), + 'heliTimerMinutes':(15,1,1440), } -AI_KEYS=('reportIntervalSeconds','supportSquadLimit','responseRadiusMetres','stingerChanceOneIn','automaticConvoyEnabled') +AI_KEYS=('reportIntervalSeconds','supportSquadLimit','responseRadiusMetres','stingerChanceOneIn','automaticConvoyEnabled','heliTimerMinutes') INTEL_KEYS=('aiIntelChancePercent','intelRevealChancePercent') def merge(ai=None,intel=None): result={key:spec[0] for key,spec in SCHEMA.items()};deferred=[] @@ -38,8 +40,8 @@ def merge(ai=None,intel=None): if type(count) not in (int,float) or not 0<=count<=20 or int(count)!=count: raise ValueError('convoyCount must be an integer in [0,20]') result['automaticConvoyEnabled']=int(count>0) + result['convoyCount']=int(count) recognized.add('convoyCount') - if count>1:deferred.append(f'ai:convoyCount={int(count)} mapped to automaticConvoyEnabled=1; successor capped at one initial convoy') deferred.extend(label+':'+key for key in data if key not in recognized) return result,deferred