Fix convoy coordination, AI combat and spectator recovery

This commit is contained in:
2026-09-27 22:28:07 +02:00
parent 2551d171b3
commit c5629f7f4f
108 changed files with 4337 additions and 282 deletions
@@ -0,0 +1,763 @@
BehaviorTree "{D0BDF32990D2C687}AI/BehaviorTrees/ERR/MortarRapidFire.bt" {
Name "AI/BehaviorTrees/ERR/MortarRapidFire.bt"
Nodes {
RootNode Root {
Name "Root"
EditorPos 2512 -464
Nodes {
Repeater Repeater {
Name "Repeater"
EditorPos 2512.222 -312.222
Nodes {
Sequence Sequence {
Name "Sequence"
EditorPos 2507.166 -173.666
Nodes {
SCR_AIGetStaticArtilleryBehaviorParameters "SCR_AI Get Static Artillery Behavior Parameters" {
Name "SCR_AI Get Static Artillery Behavior Parameters"
EditorPos -1398.001 1037.668
OutPorts {
SingleOutputPortContainer SingleOutputPortContainer {
Port 0
PortName "ArtilleryEntity"
}
SingleOutputPortContainer SingleOutputPortContainer {
Port 17
PortName "TargetPos"
}
SingleOutputPortContainer SingleOutputPortContainer {
Port -1
PortName "InventoryEntity"
}
SingleOutputPortContainer SingleOutputPortContainer {
Port 1
PortName "AmmoPrefab"
}
}
}
Selector Selector {
Name "Selector"
EditorPos 413.238 1284.881
Nodes {
DecoTestVariable "Test Variable" {
Name "Test Variable"
EditorPos 165.588 1481.158
Nodes {
Sequence Sequence {
Name "Sequence"
EditorPos -131.571 2021.904
Nodes {
AITaskCharacterSetMovementSpeed "Character Set Movement Speed" {
Name "Character Set Movement Speed"
EditorPos -1542.333 2485
Group "{619AE77C783232F7}"
"Movement speed" RUN
}
Selector Selector {
Name "Selector"
EditorPos -1077.214 2563.785
Nodes {
DecoIsInVehicle "Is In Vehicle" {
Name "Is In Vehicle"
EditorPos -1249.942 2695.149
Nodes {
AITaskGetInVehicle "Get In Vehicle" {
Name "Get In Vehicle"
EditorPos -1266.143 2863.728
Group "{619AE77C783232F7}"
PrecisionXZ 1
Position TURRET
InVehicle 21
}
}
Group "{619AE77C783232F7}"
NegativeCondition 1
}
AITaskReturnState "Return State" {
Name "Return State"
EditorPos -1003.786 2694.214
Group "{619AE77C783232F7}"
Result SUCCESS
}
}
Group "{619AE77C783232F7}"
}
Selector Selector {
Name "Selector"
EditorPos -198.12 2172.832
Nodes {
SCR_AIGetArtilleryAimDistanceCompensation "SCR_AI Get Artillery Aim Distance Compensation" {
Name "SCR_AI Get Artillery Aim Distance Compensation"
EditorPos -633.667 2389.631
InPorts {
SingleInputPortContainer SingleInputPortContainer {
Port 16
PortName "ArtilleryEntity"
}
SingleInputPortContainer SingleInputPortContainer {
Port 19
PortName "AmmoPrefab"
}
SingleInputPortContainer SingleInputPortContainer {
Port 18
PortName "TargetPos"
}
}
OutPorts {
SingleOutputPortContainer SingleOutputPortContainer {
Port 20
PortName "AimPos"
}
SingleOutputPortContainer SingleOutputPortContainer {
Port 27
PortName "InitSpeedId"
}
}
Group "{619AE77C783232F7}"
}
Sequence Sequence {
Name "Sequence"
EditorPos -16.266 2433.212
Nodes {
SCR_AITalk "SCR_AI Talk" {
Name "SCR_AI Talk"
EditorPos -153.255 2556.307
InPorts {
SingleInputPortContainer SingleInputPortContainer {
Port -1
PortName "SpeakerIn"
}
SingleInputPortContainer SingleInputPortContainer {
Port -1
PortName "TargetIn"
}
SingleInputPortContainer SingleInputPortContainer {
Port -1
PortName "LocationIn"
}
SingleInputPortContainer SingleInputPortContainer {
Port -1
PortName "EnumIn"
}
}
Group "{619AE77C783232F7}"
m_messageType REPORT_NEGATIVE
m_ePreset MANDATORY
}
SCR_AIFailAction "SCR_AI Fail Action" {
Name "SCR_AI Fail Action"
EditorPos -30.897 2555.629
Group "{619AE77C783232F7}"
m_eFailReason TARGET_UNREACHABLE
}
AITaskReturnState "Return State" {
Name "Return State"
EditorPos 95.77 2555.712
Group "{619AE77C783232F7}"
Result RUNNING
}
}
Group "{619AE77C783232F7}"
}
}
Group "{619AE77C783232F7}"
}
AITaskAim Aim {
Name "Aim"
EditorPos 353.709 2413.569
Group "{619AE77C783232F7}"
InAimAtPosition 22
PrecisionXZ 0.05
PrecisionY 0.05
}
AITaskSetVariable "Set Variable" {
Name "Set Variable"
EditorPos 697.464 2397.535
Group "{619AE77C783232F7}"
SetValue "true"
OutVariable 23
}
AITaskGetOutVehicle "Get Out Vehicle" {
Name "Get Out Vehicle"
EditorPos 910.25 2399.999
Group "{619AE77C783232F7}"
}
AITaskIdle Idle {
Name "Idle"
EditorPos 1101.667 2391.666
Group "{619AE77C783232F7}"
Period 0.75
PeriodRandom 0
}
}
Group "{619AE77C783232F7}"
}
}
NegativeCondition 1
InVariable 24
TestType EqualsValue
TestValue "true"
}
DecoTestVariable "Test Variable" {
Name "Test Variable"
EditorPos 350.389 1454.611
Nodes {
AITaskReturnState "Return State" {
Name "Return State"
EditorPos 349.948 1608.115
Result SUCCESS
}
}
InVariable 24
TestType EqualsValue
TestValue "true"
}
Sequence Sequence {
Name "Sequence"
EditorPos 637.139 1525.278
Nodes {
AITaskIdle Idle {
Name "Idle"
EditorPos 560 1643.75
Group "{619AE77BE5296DF2}"
PeriodRandom 0
}
AITaskReturnState "Return State" {
Name "Return State"
EditorPos 692.833 1641.25
Group "{619AE77BE5296DF2}"
Result FAIL
}
}
Group "{619AE77BE5296DF2}"
}
}
}
Selector Selector {
Name "Selector"
EditorPos 2100.833 754.167
Nodes {
SCR_AIFindItemInInventory "SCR_AI Find Item In Inventory" {
Name "SCR_AI Find Item In Inventory"
EditorPos 1820.595 1035.833
InPorts {
SingleInputPortContainer SingleInputPortContainer {
Port 13
PortName "PrefabResourceName"
}
}
OutPorts {
SingleOutputPortContainer SingleOutputPortContainer {
Port 14
PortName "ItemEntity"
}
}
}
Sequence Sequence {
Name "Sequence"
EditorPos 2577.5 1823
Nodes {
Sequence Sequence {
Name "Sequence"
EditorPos 1923.916 2246.194
Nodes {
AITaskCreatePosition "Create Position" {
Name "Create Position"
EditorPos 1717.386 2367.864
Group "{618C93F9D918DCD9}"
InEntity 3
OutPosition 2
}
SCR_AIFindArsenalWithPrefab "SCR_AI Find Arsenal With Prefab" {
Name "SCR_AI Find Arsenal With Prefab"
EditorPos 1830.487 2493.422
InPorts {
SingleInputPortContainer SingleInputPortContainer {
Port 2
PortName "Pos"
}
SingleInputPortContainer SingleInputPortContainer {
Port 4
PortName "PrefabResourceName"
}
}
OutPorts {
SingleOutputPortContainer SingleOutputPortContainer {
Port 5
PortName "ArsenalEntity"
}
}
Group "{618C93F9D918DCD9}"
m_fSearchRadius 20
}
AITaskCharacterSetMovementSpeed "Character Set Movement Speed" {
Name "Character Set Movement Speed"
EditorPos 1993 2373.167
Group "{618C93F9D918DCD9}"
"Movement speed" RUN
}
}
Group "{618C93F9D918DCD9}"
}
Selector Selector {
Name "Selector"
EditorPos 2580.178 2035.203
Nodes {
AITaskMoveToEntity "Move To Entity" {
Name "Move To Entity"
EditorPos 2380.512 2174.286
Group "{618C93F9D918DCD9}"
PrecisionXZ 2
InEntity 9
}
AITaskReturnState "Return State" {
Name "Return State"
EditorPos 2668.428 2175.453
Group "{618C93F9D918DCD9}"
Result SUCCESS
}
}
Group "{618C93F9D918DCD9}"
}
SCR_AITakeItemFromArsenal "SCR_AI Take Item From Arsenal" {
Name "SCR_AI Take Item From Arsenal"
EditorPos 2894.548 2307.881
InPorts {
SingleInputPortContainer SingleInputPortContainer {
Port 6
PortName "ArsenalEntity"
}
SingleInputPortContainer SingleInputPortContainer {
Port 7
PortName "PrefabResourceName"
}
}
OutPorts {
SingleOutputPortContainer SingleOutputPortContainer {
Port 8
PortName "ItemEntity"
}
SingleOutputPortContainer SingleOutputPortContainer {
Port -1
PortName "CountItemsTaken"
}
}
Group "{618C93F9D918DCD9}"
m_iMaxItemsToTake 4
}
}
Group "{618C93F9D918DCD9}"
}
Sequence Sequence {
Name "Sequence"
EditorPos 2608.667 990
Nodes {
SCR_AITalk "SCR_AI Talk" {
Name "SCR_AI Talk"
EditorPos 2485.833 1116.944
InPorts {
SingleInputPortContainer SingleInputPortContainer {
Port -1
PortName "SpeakerIn"
}
SingleInputPortContainer SingleInputPortContainer {
Port -1
PortName "TargetIn"
}
SingleInputPortContainer SingleInputPortContainer {
Port -1
PortName "LocationIn"
}
SingleInputPortContainer SingleInputPortContainer {
Port -1
PortName "EnumIn"
}
}
Group "{619AE77C86E958CB}"
m_messageType REPORT_NO_AMMO
m_ePreset MANDATORY
}
SCR_AIFailAction "SCR_AI Fail Action" {
Name "SCR_AI Fail Action"
EditorPos 2605 1116.944
Group "{619AE77C86E958CB}"
m_eFailReason NO_AMMO
}
AITaskReturnState "Return State" {
Name "Return State"
EditorPos 2736.166 1116.944
Group "{619AE77C86E958CB}"
Result RUNNING
}
}
Group "{619AE77C86E958CB}"
}
}
}
SCR_AITakeGadgetInLeftHand "SCR_AI Take Gadget In Left Hand" {
Name "SCR_AI Take Gadget In Left Hand"
EditorPos 3229.69 912.619
InPorts {
SingleInputPortContainer SingleInputPortContainer {
Port 15
PortName "ItemIn"
}
}
OutPorts {
SingleOutputPortContainer SingleOutputPortContainer {
Port -1
PortName "ItemOut"
}
}
m_eGadgetType NONE
}
Sequence Sequence {
Name "Sequence"
EditorPos 4223.904 609.286
Nodes {
AITaskCharacterSetMovementSpeed "Character Set Movement Speed" {
Name "Character Set Movement Speed"
EditorPos 3828.833 809.333
Group "{618C93F9C535F544}"
"Movement speed" RUN
}
Selector Selector {
Name "Selector"
EditorPos 4224.476 805.571
Nodes {
AITaskMoveToEntity "Move To Entity" {
Name "Move To Entity"
EditorPos 4009.333 942.667
Group "{618C93F9C535F544}"
PrecisionXZ 1.5
InEntity 10
}
AITaskReturnState "Return State" {
Name "Return State"
EditorPos 4313.857 945.047
Group "{618C93F9C535F544}"
Result SUCCESS
}
}
Group "{618C93F9C535F544}"
}
Selector Selector {
Name "Selector"
EditorPos 5528.9 1018.859
Nodes {
Sequence Sequence {
Name "Sequence"
EditorPos 5132.62 1133.074
Nodes {
DecoTestVariable "Test Variable" {
Name "Test Variable"
EditorPos 4678.954 1280.82
Nodes {
SCR_AIConfigureArtilleryAmmo "SCR_AI Configure Artillery Ammo" {
Name "SCR_AI Configure Artillery Ammo"
EditorPos 4615.083 1454.909
InPorts {
SingleInputPortContainer SingleInputPortContainer {
Port 12
PortName "AmmoEntity"
}
SingleInputPortContainer SingleInputPortContainer {
Port 28
PortName "InitSpeedId"
}
}
Group "{618C93F9C535F544}"
}
}
Group "{618C93F9C535F544}"
NegativeCondition 1
InVariable 28
TestType EqualsValue
TestValue "-1"
}
SCR_AIShootStaticArtillery "SCR_AI Shoot Static Artillery" {
Name "SCR_AI Shoot Static Artillery"
EditorPos 5066.497 1316.781
InPorts {
SingleInputPortContainer SingleInputPortContainer {
Port 11
PortName "ArtilleryEntity"
}
SingleInputPortContainer SingleInputPortContainer {
Port 12
PortName "AmmoEntity"
}
}
Group "{618C93F9C535F544}"
}
AITaskIdle Idle {
Name "Idle"
EditorPos 5311.732 1318.751
Group "{618C93F9C535F544}"
Period 0
PeriodRandom 0
}
SCR_AIStaticArtilleryBehavior_ShootingLogic "SCR_AI Static Artillery Behavior_ Shooting Logic" {
Name "SCR_AI Static Artillery Behavior_ Shooting Logic"
EditorPos 5446.416 1323.941
OutPorts {
SingleOutputPortContainer SingleOutputPortContainer {
Port 25
PortName "AimingNeeded"
}
}
Group "{618C93F9C535F544}"
m_iShotsBetweenAiming 5
}
DecoTestVariable "Test Variable" {
Name "Test Variable"
EditorPos 5875.542 1327.115
Nodes {
AITaskSetVariable "Set Variable" {
Name "Set Variable"
EditorPos 5853.542 1466.37
Group "{618C93F9C535F544}"
SetValue "false"
OutVariable 26
}
}
Group "{618C93F9C535F544}"
InVariable 25
TestType EqualsValue
TestValue "true"
}
}
Group "{618C93F9C535F544}"
}
AITaskReturnState "Return State" {
Name "Return State"
EditorPos 5616.589 1132.723
Group "{618C93F9C535F544}"
Result SUCCESS
}
}
Group "{618C93F9C535F544}"
}
AITaskIdle Idle {
Name "Idle"
EditorPos 5794.508 1010.276
Group "{618C93F9C535F544}"
Period 0
PeriodRandom 0
}
}
Group "{618C93F9C535F544}"
}
}
}
}
RepeatTimes -1
}
}
}
}
Variables {
BtVariableEntity artilleryEntity {
Flags 0
Desc "IEntity"
}
BtVariableResourceName ammoPrefab {
Flags 0
Desc "ResourceName"
}
BtVariableVector vArtilleryPos {
Flags 0
Desc "vector"
}
BtVariableEntity arsenalEntity {
Flags 0
Desc "IEntity"
}
BtVariableEntity artilleryAmmoEntity {
Flags 0
Desc "IEntity"
}
BtVariableVector vTargetPos {
Flags 0
Desc "vector"
}
BtVariableVector vAimPos {
Flags 0
Desc "vector"
}
BtVariableBool bTurretAimed {
Flags 0
Desc "bool"
}
BtVariableBool bDoAimingAgain {
Flags 0
Desc "bool"
}
BtVariableInteger iInitSpeedId {
Flags 0
Desc "int"
}
}
VariablesRefs {
VariableReference artilleryEntity {
VariableType 0
EditorPos -944 976
}
VariableReference ammoPrefab {
VariableType 1
EditorPos -944 1136
}
VariableReference vArtilleryPos {
VariableType 2
EditorPos 1616 2528
Group "{618C93F9D918DCD9}"
}
VariableReference artilleryEntity {
VariableType 0
EditorPos 1520 2432
Group "{618C93F9D918DCD9}"
}
VariableReference ammoPrefab {
VariableType 1
EditorPos 1552 2608
Group "{618C93F9D918DCD9}"
}
VariableReference arsenalEntity {
VariableType 3
EditorPos 1920 2608
Group "{618C93F9D918DCD9}"
}
VariableReference arsenalEntity {
VariableType 3
EditorPos 2688 2304
Group "{618C93F9D918DCD9}"
}
VariableReference ammoPrefab {
VariableType 1
EditorPos 2624 2384
Group "{618C93F9D918DCD9}"
}
VariableReference artilleryAmmoEntity {
VariableType 4
EditorPos 2896 2432
Group "{618C93F9D918DCD9}"
}
VariableReference arsenalEntity {
VariableType 3
EditorPos 2192 2208
Group "{618C93F9D918DCD9}"
}
VariableReference artilleryEntity {
VariableType 0
EditorPos 3792 976
Group "{618C93F9C535F544}"
}
VariableReference artilleryEntity {
VariableType 0
EditorPos 4864 1312
Group "{618C93F9C535F544}"
}
VariableReference artilleryAmmoEntity {
VariableType 4
EditorPos 4336 1456
Group "{618C93F9C535F544}"
}
VariableReference ammoPrefab {
VariableType 1
EditorPos 1568 1072
}
VariableReference artilleryAmmoEntity {
VariableType 4
EditorPos 1824 1136
}
VariableReference artilleryAmmoEntity {
VariableType 4
EditorPos 2976 976
}
VariableReference artilleryEntity {
VariableType 0
EditorPos -864 2368
Group "{619AE77C783232F7}"
}
VariableReference vTargetPos {
VariableType 5
EditorPos -944 1056
}
VariableReference vTargetPos {
VariableType 5
EditorPos -848 2528
Group "{619AE77C783232F7}"
}
VariableReference ammoPrefab {
VariableType 1
EditorPos -928 2448
Group "{619AE77C783232F7}"
}
VariableReference vAimPos {
VariableType 6
EditorPos -224 2352
Group "{619AE77C783232F7}"
}
VariableReference artilleryEntity {
VariableType 0
EditorPos -1472 2896
Group "{619AE77C783232F7}"
}
VariableReference vAimPos {
VariableType 6
EditorPos 192 2448
Group "{619AE77C783232F7}"
}
VariableReference bTurretAimed {
VariableType 7
EditorPos 736 2512
Group "{619AE77C783232F7}"
}
VariableReference bTurretAimed {
VariableType 7
EditorPos -64 1504
}
VariableReference bDoAimingAgain {
VariableType 8
EditorPos 5568 1424
Group "{618C93F9C535F544}"
}
VariableReference bTurretAimed {
VariableType 7
EditorPos 5856 1584
Group "{618C93F9C535F544}"
}
VariableReference iInitSpeedId {
VariableType 9
EditorPos -224 2448
Group "{619AE77C783232F7}"
}
VariableReference iInitSpeedId {
VariableType 9
EditorPos 4416 1536
Group "{618C93F9C535F544}"
}
}
Groups {
BTGroup "{618C93F9C535F544}" {
Name "Shoot artillery"
Color 0.6 0.6 0.6 0.251
}
BTGroup "{618C93F9D918DCD9}" {
Name "Find ammo in arsenal"
Color 0.6 0.6 0.6 0.251
}
BTGroup "{619AE77C783232F7}" {
Name "Aim mortar"
Color 0.6 0.6 0.6 0.251
}
BTGroup "{619AE77C86E958CB}" {
Name "No ammo for artillery"
Color 0.6 0.6 0.6 0.251
}
BTGroup "{619AE77BE5296DF2}" {
Name "Unable to get in or aim artillery"
Color 0.6 0.6 0.6 0.251
}
}
}
@@ -0,0 +1,7 @@
MetaFileClass {
Name "{D0BDF32990D2C687}AI/BehaviorTrees/ERR/MortarRapidFire.bt"
Configurations {
BehaviorTreeResourceClass PC {}
BehaviorTreeResourceClass HEADLESS {}
}
}
@@ -12,6 +12,8 @@ ERR_ContentDefinition {
Parent "rhs"
DefaultEnabled 1
NativeFactionKey "RHS_USAF"
ResponseSoldier "{844936B168DFAB8E}Prefabs/Characters/Factions/BLUFOR/RHS_USAF/RHS_USAF_MARSOC/RandomSoldiers/Character_RHS_USMC_MARSOC_Rifleman_Random.et"
ResponseMedic "{DBB8D1F13930220E}Prefabs/Characters/Factions/BLUFOR/RHS_USAF/RHS_USAF_MARSOC/RandomSoldiers/Character_RHS_USMC_MARSOC_Medic_Random.et"
GuardGroup "{19843E954790DF28}Prefabs/Groups/BLUFOR/RHS_USAF/RHS_USAF_USMC_MEF/Group_USAF_USMC_MEF_FireTeam.et"
}
ERR_ContentOption {
@@ -20,6 +22,8 @@ ERR_ContentDefinition {
Parent "rhs"
DefaultEnabled 1
NativeFactionKey "RHS_AFRF"
ResponseSoldier "{B3C496CCFA72D25E}Prefabs/Characters/Factions/OPFOR/RHS_AFRF/MSV/Flora/Character_RHS_RF_MSV_Flora_SL.et"
ResponseMedic "{D9AF475A4E5DC52C}Prefabs/Characters/Factions/OPFOR/RHS_AFRF/MSV/Flora/Character_RHS_RF_MSV_Flora_Medic.et"
GuardGroup "{AB905EF799E0A0AE}Prefabs/Groups/OPFOR/RHS_AFRF/MSV/Flora/Group_RHS_RF_MSV_Flora_FireGroup.et"
}
ERR_ContentOption {
@@ -28,6 +32,8 @@ ERR_ContentDefinition {
Parent "rhs"
DefaultEnabled 1
NativeFactionKey "RHS_ION"
ResponseSoldier "{140001FC383DF9A2}Prefabs/Characters/Factions/INDFOR/RHS_ION_Coy/RandomSoldiers/Character_RHS_ION_Coy_Rifleman_Random.et"
ResponseMedic "{0FA8ACE19A772327}Prefabs/Characters/Factions/INDFOR/RHS_ION_Coy/RandomSoldiers/Character_RHS_ION_Coy_Medic_Random.et"
GuardGroup "{516C8C7400DF2C4D}Prefabs/Groups/INDFOR/RHS_ION/RHS_ION_COY/Group_RHS_ION_COY_StaticSecurityTeam.et"
}
}
@@ -0,0 +1,30 @@
ActionManager : "{795184CF9AD764DB}Configs/System/chimeraInputCommon.conf" {
Contexts {
ActionContext ERR_ObserverContext : "{C538807EEE48BED8}Configs/System/ActionContext/ManualCameraContext.conf" {
Actions {
Action ERR_ObserverPrevious {
InputSource InputSourceValue "{A7B19F416E541001}" {
Input "keyboard:KC_LEFT"
}
}
Action ERR_ObserverNext {
InputSource InputSourceValue "{A7B19F416E541002}" {
Input "keyboard:KC_RIGHT"
}
}
Action ERR_ObserverView {
InputSource InputSourceSum "{A7B19F416E541003}" {
Sources {
InputSourceValue "{A7B19F416E541004}" {
Input "keyboard:KC_RETURN"
}
InputSourceValue "{A7B19F416E541005}" {
Input "keyboard:KC_NUMPADENTER"
}
}
}
}
}
}
}
}
@@ -0,0 +1,11 @@
MetaFileClass {
Name "{A7B19F416E541000}Configs/ERR/ObserverInput.conf"
Configurations {
CONFResourceClass PC { }
CONFResourceClass HEADLESS : PC { }
CONFResourceClass XBOX_ONE : PC { }
CONFResourceClass XBOX_SERIES : PC { }
CONFResourceClass PS4 : PC { }
CONFResourceClass PS5 : PC { }
}
}
+23
View File
@@ -0,0 +1,23 @@
GenericEntity : "{0D9A5DCF89AE7AA9}Prefabs/Items/Medicine/MorphineInjection_01/MorphineInjection_01.et" {
ID "BF10090634B949D0"
components {
InventoryItemComponent "{5222EB4D0C73006B}" {
Attributes SCR_ItemAttributeCollection "{5222EB4D0A2B466B}" {
ItemDisplayName SCR_InventoryUIInfo "{5222EB4D07D865FA}" {
Name "Epinephrine injector"
Description "Helps an unconscious casualty recover consciousness over time. Stop bleeding and replace lost blood separately. Cannot revive the dead."
m_aItemHints {}
}
}
}
SCR_ConsumableItemComponent "{59E68C74C5CEE74F}" {
m_ConsumableEffect ERR_ConsumableEpinephrine "{59E68C74C1F3910E}" {
m_fItemRegenerationSpeed 5
m_fItemRegenerationDuration 30
m_aDamageEffectsToLoad {
ERR_EpinephrineEffect "{6122D60861B541E7}" {}
}
}
}
}
}
@@ -0,0 +1,7 @@
MetaFileClass {
Name "{C68512DB5FC74698}Prefabs/ERR/Epinephrine.et"
Configurations {
EntityTemplateResourceClass PC {}
EntityTemplateResourceClass HEADLESS : PC {}
}
}
@@ -1,6 +1,7 @@
SCR_ManualCamera {
ID "634904BCEDA1A285"
m_bInputEnabled 1
m_sActionContext "ERR_ObserverContext"
m_bStartAtEntity 1
m_aComponents {
ERR_FollowSurvivorCameraComponent "{965C2E8B2339B926}" {}
@@ -11,6 +11,10 @@ class ERR_ContentOption
string NativeFactionKey;
[Attribute("", UIWidgets.ResourceNamePicker, "Physical guard group belonging to this faction", "et")]
ResourceName GuardGroup;
[Attribute("", UIWidgets.ResourceNamePicker, "Optional HQ response soldier; use for factions with empty character catalogs", "et")]
ResourceName ResponseSoldier;
[Attribute("", UIWidgets.ResourceNamePicker, "Optional same-faction response medic with authored medical backpack", "et")]
ResourceName ResponseMedic;
[Attribute("", UIWidgets.ResourceNamePicker, "Optional faction MANPADS override; must spawn loaded and usable. Otherwise FIM-92 is used.", "et")]
ResourceName AntiAirLauncher;
[Attribute("", UIWidgets.EditBox, "Exact resource GUIDs for optional equipment owned by this pack")]
@@ -0,0 +1,49 @@
// Native injection animation/consumption; only resilience is restored over time.
// This is not a blood replacement, bandage, full heal, or resurrection.
[BaseContainerProps()]
class ERR_ConsumableEpinephrine : SCR_ConsumableMorphine
{
override bool CanApplyEffect(notnull IEntity target, notnull IEntity user, out SCR_EConsumableFailReason failReason)
{
SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(target.FindComponent(SCR_CharacterDamageManagerComponent));
CharacterControllerComponent controller = CharacterControllerComponent.Cast(target.FindComponent(CharacterControllerComponent));
if (!damage || damage.GetState() == EDamageState.DESTROYED || !controller || !controller.IsUnconscious())
{ failReason = SCR_EConsumableFailReason.UNDAMAGED; return false; }
array<ref SCR_PersistentDamageEffect> effects = damage.GetAllPersistentEffectsOfType(ERR_EpinephrineEffect);
if (!effects.IsEmpty()) { failReason = SCR_EConsumableFailReason.ALREADY_APPLIED; return false; }
return true;
}
}
class ERR_EpinephrineEffect : SCR_DotDamageEffect
{
override bool HijackDamageEffect(SCR_ExtendedDamageManagerComponent dmgManager)
{
SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(dmgManager);
if (damage) SetAffectedHitZone(damage.GetResilienceHitZone());
return false;
}
override EDamageType GetDefaultDamageType() { return EDamageType.HEALING; }
}
// The held injector uses the existing native injection interaction and animation.
// Only its displayed name changes; ordinary morphine keeps its native behavior.
modded class SCR_MorphineUserAction
{
protected bool m_ERRHoldingEpinephrine;
override bool CanBeShownScript(IEntity user)
{
m_ERRHoldingEpinephrine = false;
bool shown = super.CanBeShownScript(user);
ChimeraCharacter character = ChimeraCharacter.Cast(user);
if (shown && character)
{
SCR_ConsumableItemComponent item = GetConsumableComponent(character);
m_ERRHoldingEpinephrine = item && ERR_ConsumableEpinephrine.Cast(item.GetConsumableEffect());
}
return shown;
}
override bool GetActionNameScript(out string outName)
{
if (m_ERRHoldingEpinephrine) { outName = "Administer epinephrine"; return true; }
return super.GetActionNameScript(outName);
}
}
@@ -497,7 +497,7 @@ class ERR_GameMode : SCR_BaseGameMode
if (!Active()) return;
if (m_Recovery.DefeatDue(now))
{
m_EndReason = "All prisoners are unconscious and no treatment or recovery is progressing.";
m_EndReason = "Everybody died. All prisoners are unconscious and nobody can recover or receive help.";
ERR_HQ.Event("MISSION FAILED", m_EndReason);
Transition(ERR_Phase.DEFEAT);
return;
@@ -120,6 +120,24 @@ class ERR_GeneratedSites
}
return true;
}
protected bool SkipOptional(ERR_SiteDefinition site)
{
if (site.Kind != ERR_LocationKind.OUTPOST && site.Kind != ERR_LocationKind.HOSPITAL && site.Kind != ERR_LocationKind.DEPOT) return false;
int index = m_Definition.Sites.Find(site);
if (index < 0) return false;
// Remove before spawning/registration, so no empty map or intel target remains.
// Keep the object alive while validating or restoring its original position.
ref ERR_SiteDefinition retained = site;
m_Definition.Sites.Remove(index);
if (!m_Definition.Valid())
{
m_Definition.Sites.InsertAt(retained, index);
PrintFormat("Co10 Escape Reintroduced [site required]: %1 (%2) cannot be skipped: remaining scenario would lack required objectives or accessible radio intel", retained.Label, retained.Id);
return false;
}
PrintFormat("Co10 Escape Reintroduced [site skipped]: %1 (%2): no safe placement after %3 attempts and authored fallback rejected; omitted for this round; mission preparation continues", retained.Label, retained.Id, m_Attempts);
return true;
}
ERR_PrepareResult Tick()
{
if (m_Result != ERR_PrepareResult.PENDING) return m_Result;
@@ -145,8 +163,15 @@ class ERR_GeneratedSites
}
else if (fallback)
{
m_Error = "No safe terrain or valid authored fallback for " + site.Id;
m_Result = ERR_PrepareResult.FAILED; return m_Result;
if (SkipOptional(site))
{
m_Index++; m_Attempts = 0; m_Began = System.GetTickCount();
}
else
{
m_Error = "No safe terrain or valid authored fallback for required site " + site.Id;
m_Result = ERR_PrepareResult.FAILED; return m_Result;
}
}
if (System.GetTickCount() - tick >= 8) return ERR_PrepareResult.PENDING;
}
@@ -87,6 +87,9 @@ class ERR_JournalText
if (kind == "MORTAR SITE STATUS") return "Mortar " + Field(message, "site") + ": operational: " + YesNo(Field(message, "operational")) + "; combat-capable crew: " + Field(message, "fighting") + "; HE shells: " + Field(message, "inventory HE") + "; HQ radio connected: " + YesNo(Field(message, "radio connected")) + ".";
if (kind == "MORTAR ORDER STATUS") return "Mortar " + Field(message, "site") + ": " + Field(message, "order shots") + " shots in this order; " + Field(message, "total shots") + " shots fired in total.";
if (kind == "BODY SEARCH COMPLETE") return "A recovery team finished searching a body. " + Short(message);
if (kind == "CONTACT REPORT") return "Squad reporting a captured sighting to HQ: " + Short(message);
if (kind == "GUNFIRE REPORT") return "Squad reporting heard gunfire to HQ: " + Short(message);
if (kind == "INFANTRY COMBAT") return message;
if (kind == "INTEL COMPROMISE") return "HQ learned that intel is missing: " + Short(message);
if (kind == "OBJECTIVE DEFENCE") return "Objective security: " + Short(message);
if (kind == "HELICOPTER LAUNCH") return "Search helicopter launched: " + Short(message);
@@ -54,7 +54,8 @@ 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 == "unconscious-camera" || nativeEndCase == "town-garrisons" || nativeEndCase == "site-garrisons" || nativeEndCase == "radio-interaction" || nativeEndCase == "enemy-loadout" || 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;
array<string> independentFixtures = {"optional-site-skip", "infantry-combat", "medical-combat", "hunter-faction", "unconscious-camera", "town-garrisons", "site-garrisons", "radio-interaction", "enemy-loadout", "aggression", "native-end-active", "native-end-preparing", "generated-sites", "mortar-station", "opening-tools", "airfield-markers", "air-logistics", "air-assault", "air-recon"};
if (System.GetCLIParam("errSmokeOutcome", nativeEndCase) && independentFixtures.Contains(nativeEndCase)) return;
ERR_NativeObjectiveProbe.BeginRound();
ERR_NativeProgressionIdentityProbe.BeginRound();
string outcome;
@@ -269,7 +270,7 @@ modded class ERR_GameMode
{
if (!ERR_BaseCheck(current == ERR_Phase.DEFEAT && m_ERRBaseStep == 502 && EndReason().Contains("All prisoners are unconscious"), "stalled all-unconscious team reaches automatic mission defeat")) return;
if (!ERR_BaseCheck(!RecoveryStatus().IsEmpty() && !AbandonRound(), "terminal event observes active warning and rejects repeated abandonment")) return;
Print("ERR SMOKE COMPLETE: player casualty protection, native healing and automatic all-unconscious defeat");
Print("ERR SMOKE COMPLETE: player unconsciousness, native healing and automatic all-unconscious defeat");
return;
}
if (m_ERRExtractionCase == "objective-ui")
@@ -0,0 +1,39 @@
#ifdef WORKBENCH
// Isolate native waypoint lifecycle from the separate long-distance patrol test.
// Contacts are explicitly injected; no natural target acquisition claim.
modded class ERR_GameMode
{
protected ref ERR_NativePatrolHQProbe m_ERRInfantryCombatProbe;
protected float m_ERRInfantryCombatDeadline;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string outcome;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "infantry-combat") return;
m_ERRInfantryCombatDeadline = System.GetTickCount() + 180000;
GetGame().GetCallqueue().CallLater(ERR_InfantryCombatTick, 1000, true);
}
protected void ERR_InfantryCombatTick()
{
if (System.GetTickCount() >= m_ERRInfantryCombatDeadline)
{
Print("ERR SMOKE FAIL: infantry combat fixture timed out", LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_InfantryCombatTick); return;
}
if (Phase() != ERR_Phase.PRISON || !GetGame().GetPlayerController()) return;
IEntity player = GetGame().GetPlayerController().GetControlledEntity(); if (!player) return;
if (!m_ERRInfantryCombatProbe)
{
DamageManagerComponent.Cast(player.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
m_ERRInfantryCombatProbe = new ERR_NativePatrolHQProbe(this);
}
int result = m_ERRInfantryCombatProbe.Tick(GetWorld().GetWorldTime());
if (result == 0) return;
GetGame().GetCallqueue().Remove(ERR_InfantryCombatTick);
m_ERRInfantryCombatProbe.Stop();
if (result < 0) return;
Transition(ERR_Phase.DEFEAT);
Print("ERR SMOKE COMPLETE: native infantry search pause/resume and preserved home queue; injected contact, not live shooting acceptance");
}
}
#endif
@@ -0,0 +1,159 @@
#ifdef WORKBENCH
modded class ERR_FactionSelection
{
override bool Prepare(ERR_GameMode mode)
{
string outcome;
if (System.GetCLIParam("errSmokeOutcome", outcome) && (outcome == "hunter-faction" || outcome == "air-recon" || outcome == "air-assault"))
{
m_Saved.Set("escapists", "us"); m_Saved.Set("enemy", "rhs_ion"); m_Saved.Set("independent", "fia");
}
return super.Prepare(mode);
}
}
modded class ERR_HQ
{
void ERR_HunterFixtureSighting(IEntity target, SCR_AIGroup source)
{
ERR_HQReport report = new ERR_HQReport();
report.Target = target; report.Source = source; report.Position = target.GetOrigin();
report.ObservedAt = GetGame().GetWorld().GetWorldTime(); report.MessageId = Journal().Message();
Accept(report, report.ObservedAt);
}
}
modded class ERR_HunterService
{
ERR_HunterVehicle ERR_MedicalFixtureHunter()
{
string outcome;
if (!System.GetCLIParam("errSmokeOutcome", outcome) || (outcome != "medical-combat" && outcome != "hunter-faction")) return null;
foreach (ERR_HunterVehicle hunter : m_Hunters) if (hunter.Registered && !hunter.Released) return hunter;
return null;
}
}
modded class ERR_GameMode
{
protected bool m_ERRHunterFactionFixture;
protected vector m_ERRHunterFactionStart;
protected int m_ERRMedicalCombatStep;
protected float m_ERRMedicalCombatDeadline;
protected ref ERR_NativeMedicalProbe m_ERRMedicalCombatProbe;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string outcome;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || (outcome != "medical-combat" && outcome != "hunter-faction")) return;
m_ERRHunterFactionFixture = outcome == "hunter-faction";
m_ERRMedicalCombatDeadline = System.GetTickCount() + 300000;
GetGame().GetCallqueue().CallLater(ERR_MedicalCombatTick, 1000, true);
}
protected bool ERR_MedicalCombatCheck(bool ok, string detail)
{
if (ok) { Print("ERR SMOKE PASS: medical-combat " + detail); return true; }
Print("ERR SMOKE FAIL: medical-combat " + detail, LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_MedicalCombatTick); return false;
}
protected ERR_UnlockPrisonAction ERR_MedicalCombatLock(IEntity entity)
{
BaseActionsManagerComponent manager = BaseActionsManagerComponent.Cast(entity.FindComponent(BaseActionsManagerComponent));
if (manager)
{
array<BaseUserAction> actions = {};
manager.GetActionsList(actions);
foreach (BaseUserAction action : actions)
{
ERR_UnlockPrisonAction lock = ERR_UnlockPrisonAction.Cast(action);
if (lock) return lock;
}
}
IEntity child = entity.GetChildren();
while (child)
{
ERR_UnlockPrisonAction lock = ERR_MedicalCombatLock(child);
if (lock) return lock;
child = child.GetSibling();
}
return null;
}
protected void ERR_MedicalCombatTick()
{
if (System.GetTickCount() >= m_ERRMedicalCombatDeadline) { ERR_MedicalCombatCheck(false, "bounded native fixture deadline"); return; }
if (Phase() < ERR_Phase.PRISON || !GetGame().GetPlayerController()) return;
IEntity player = GetGame().GetPlayerController().GetControlledEntity(); if (!player) return;
if (m_ERRMedicalCombatStep == 0)
{
DamageManagerComponent.Cast(player.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
ERR_UnlockPrisonAction opening;
foreach (IEntity tracked : m_OwnedEntities) { if (tracked) opening = ERR_MedicalCombatLock(tracked); if (opening) break; }
if (!ERR_MedicalCombatCheck(opening != null, "owned prison gate located")) return;
player.SetOrigin(opening.GetOwner().GetOrigin() + "1 0 1");
opening.PerformAction(opening.GetOwner(), player);
if (!ERR_MedicalCombatCheck(Phase() == ERR_Phase.RADIO_SEARCH, "actual prison unlock releases response teams")) return;
m_ERRMedicalCombatStep = 1;
}
if (m_ERRMedicalCombatStep == 1)
{
ERR_HQ hq = ERR_HQ.Cast(Command());
ERR_HunterVehicle hunter; if (hq) hunter = hq.Hunters().ERR_MedicalFixtureHunter();
if (!hunter) return;
IEntity medic;
int specialists;
foreach (IEntity actor : hunter.Members)
{
if (!actor || !actor.GetPrefabData()) continue;
string path = actor.GetPrefabData().GetPrefabName();
if (ERR_EnemyMedicalCatalog.Medic(path)) medic = actor;
if (ERR_EnemyMedicalCatalog.Specialist(path)) specialists++;
}
if (!medic || hunter.Members.Count() <= 2 || hunter.Members.Count() != hunter.Seats.Count() || (!m_ERRHunterFactionFixture && specialists != hunter.Members.Count()))
{ ERR_MedicalCombatCheck(false, "actual hunter UAZ has full special-forces crew and medic"); return; }
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(medic.FindComponent(InventoryStorageManagerComponent));
array<IEntity> items = {}; inventory.GetItems(items);
int epi, morphine, saline, dressings; bool bag;
foreach (IEntity item : items)
{
if (!item || !item.GetPrefabData()) continue;
string prefab = item.GetPrefabData().GetPrefabName();
if (prefab == ERR_EnemyMedicalCatalog.EPI) epi++;
if (prefab == ERR_EnemyMedicalCatalog.MORPHINE) morphine++;
if (prefab.Contains("SalineBag")) saline++;
if (prefab.Contains("FieldDressing")) dressings++;
if (prefab.Contains("Backpack_Medical") || (m_ERRHunterFactionFixture && prefab.Contains("Backpack_"))) bag = true;
}
if (epi < 6 || morphine < 6) return;
if (!ERR_MedicalCombatCheck(bag && saline >= 4 && dressings >= 4, "medic carries native medical bag, at least four saline/bandages, six morphine and six epinephrine")) return;
if (m_ERRHunterFactionFixture)
{
if (!ERR_MedicalCombatCheck(hunter.Group.GetFaction() == EnemyFaction() && EnemyFaction().GetFactionKey() == "RHS_ION", "response crew belongs specifically to RHS ION")) return;
if (!ERR_MedicalCombatCheck(hq.Hunters().VehicleRadioCapability(hunter.Driver) == 2, "seated ION driver can use the physical vehicle manpack")) return;
m_ERRHunterFactionStart = hunter.Entity.GetOrigin();
hq.ERR_HunterFixtureSighting(player, hunter.Group);
m_ERRMedicalCombatStep = 3; return;
}
m_ERRMedicalCombatProbe = new ERR_NativeMedicalProbe(this); m_ERRMedicalCombatStep = 2;
}
if (m_ERRMedicalCombatStep == 3)
{
ERR_HQ command = ERR_HQ.Cast(Command());
ERR_HunterVehicle response = command.Hunters().ERR_MedicalFixtureHunter();
if (!response || !response.Waypoint || response.SourceMessage <= 0 || vector.DistanceXZ(m_ERRHunterFactionStart, response.Entity.GetOrigin()) < 30) return;
if (!ERR_MedicalCombatCheck(response.CrewValid(this), "mod-faction UAZ receives contact order by radio and drives over 30 metres")) return;
GetGame().GetCallqueue().Remove(ERR_MedicalCombatTick);
Transition(ERR_Phase.DEFEAT);
Print("ERR SMOKE COMPLETE: selected mod-faction hunter, stocked medic, contact downlink and native vehicle movement; sighting injected at delivered intake");
return;
}
if (m_ERRMedicalCombatStep == 2)
{
int result = m_ERRMedicalCombatProbe.Tick(GetWorld().GetWorldTime());
if (result < 0) { ERR_MedicalCombatCheck(false, "native medical treatment fixture"); return; }
if (result == 0) return;
GetGame().GetCallqueue().Remove(ERR_MedicalCombatTick);
Transition(ERR_Phase.DEFEAT);
Print("ERR SMOKE COMPLETE: native full hunter crew, stocked medic, bleeding, finite treatment, epinephrine and protected lethal blood loss; injected medical states, not manual/multiplayer acceptance");
}
}
}
#endif
@@ -7,6 +7,8 @@ class ERR_NativeMedicalProbe
protected ERR_GameMode m_Mode;
protected IEntity m_Casualty;
protected IEntity m_Healer;
protected IEntity m_Epinephrine;
protected float m_BeforeEpi;
protected IEntity m_Dressing;
protected IEntity m_Saline;
protected IEntity m_SpareSaline;
@@ -110,12 +112,12 @@ class ERR_NativeMedicalProbe
m_SpareSaline = Spawn("{527D7C5D2E476BDC}Prefabs/Items/Medicine/SalineBag_01/SalineBag_USSR_01.et", anchor + "6 0 0");
if (!Check(m_Casualty && m_Healer && m_Dressing && m_Saline && m_SpareSaline, "two isolated characters and finite stock-type medicine spawned")) return -1;
if (!Check(!ERR_Actors.Player(m_Casualty) && !ERR_Actors.Player(m_Healer), "neither medical actor is a real player")) return -1;
m_Deadline = now + 120000;
m_Deadline = now + 240000;
m_Wait = now + 1000;
m_Step = 1;
return 0;
}
if (!ERR_Actors.Alive(m_Casualty) || !ERR_Actors.Alive(m_Healer))
if (m_Step < 9 && (!ERR_Actors.Alive(m_Casualty) || !ERR_Actors.Alive(m_Healer)))
{
Check(false, "owned casualty or healer died unexpectedly");
return -1;
@@ -127,6 +129,7 @@ class ERR_NativeMedicalProbe
m_SalineStorage = SCR_SalineStorageComponent.Cast(m_Casualty.FindComponent(SCR_SalineStorageComponent));
m_Inventory = SCR_InventoryStorageManagerComponent.Cast(m_Healer.FindComponent(SCR_InventoryStorageManagerComponent));
if (!Check(m_Damage && m_Controller && m_SalineStorage && m_Inventory, "native health, controller and medical storage components available")) return -1;
m_Damage.ERR_EnableCasualtyProtection();
m_Blood = m_Damage.GetBloodHitZone();
if (!Check(m_Blood && !m_Controller.IsUnconscious() && !m_Damage.IsBleeding(), "casualty begins healthy and conscious")) return -1;
if (!Check(m_Inventory.TryInsertItem(m_Dressing) && m_Inventory.TryInsertItem(m_Saline) && m_Inventory.TryInsertItem(m_SpareSaline), "finite medical items enter healer inventory")) return -1;
@@ -177,6 +180,18 @@ class ERR_NativeMedicalProbe
{
if (!Check(!m_Dressing && !m_Damage.IsBleeding() && m_Damage.GetGroupDamageOverTime(ECharacterHitZoneGroup.LEFTARM, EDamageType.BLEEDING) == 0, "dressing is consumed and native bleeding is stopped")) return -1;
if (!Check(m_Controller.IsUnconscious(), "bandage alone has not fabricated blood recovery")) return -1;
m_SeedBlood = m_Blood.GetHealth();
m_Wait = now + 35000; m_Step = 30; return 0;
}
if (m_Step == 30)
{
array<ref SCR_PersistentDamageEffect> diagnostics = m_Damage.GetAllPersistentEffectsOfType(SCR_DotDamageEffect, true);
foreach (SCR_PersistentDamageEffect diagnostic : diagnostics)
{
DotDamageEffect dot = DotDamageEffect.Cast(diagnostic);
PrintFormat("ERR MEDICAL EFFECT: type=%1 damageType=%2 dps=%3 zone=%4 bloodZone=%5 resilienceZone=%6", diagnostic.Type(), dot.GetDamageType(), dot.GetDPS(), dot.GetAffectedHitZone(), m_Blood, m_Damage.GetResilienceHitZone());
}
if (!Check(m_Blood.GetHealth() > m_SeedBlood && m_Controller.IsUnconscious() && m_Damage.ERR_CanRecoverNaturally(), "bandaged casualty regenerates blood slowly without saline and can still recover")) return -1;
SCR_ConsumableItemComponent saline = SCR_ConsumableItemComponent.Cast(m_Saline.FindComponent(SCR_ConsumableItemComponent));
SCR_ConsumableSalineBag effect = SCR_ConsumableSalineBag.Cast(saline.GetConsumableEffect());
SCR_EConsumableFailReason reason;
@@ -209,11 +224,39 @@ class ERR_NativeMedicalProbe
if (m_Saline || AttachedBags() != 0 || SalineEffects() != 0 || m_Damage.GetGroupSalineBagged(ECharacterHitZoneGroup.RIGHTARM)) return 0;
float requiredGain = Math.Min(m_RegenSpeed * 60, (m_Blood.GetMaxHealth() - m_PreInfusionBlood) * 0.8);
if (!Check(m_RegenSpeed > 0 && m_Blood.GetHealth() - m_PreInfusionBlood >= requiredGain, "native infusion restores substantial blood without setting health again")) return -1;
if (!Check(m_Recovered && !m_Controller.IsUnconscious() && ERR_Actors.CanFight(m_Casualty), "casualty regains consciousness through native health recovery")) return -1;
if (!ERR_Actors.CanFight(m_Casualty)) return 0;
if (!Check(m_Recovered && !m_Controller.IsUnconscious(), "bandaged casualty eventually wakes through slow native recovery without epinephrine")) return -1;
if (!Check(now - m_InfusionStarted >= 85000 && !m_Saline && AttachedBags() == 0 && SalineEffects() == 0, "finite saline expires after native duration and releases treatment state")) return -1;
if (!Check(m_SpareSaline != null, "unused spare survives the completed infusion")) return -1;
InventoryItemComponent spareItem = InventoryItemComponent.Cast(m_SpareSaline.FindComponent(InventoryItemComponent));
if (!Check(spareItem && spareItem.GetParentSlot(), "rejected spare bag remains a physical inventory item")) return -1;
m_Damage.ForceUnconsciousness();
m_Epinephrine = Spawn(ERR_EnemyMedicalCatalog.EPI, m_Healer.GetOrigin());
if (!Check(m_Epinephrine && m_Inventory.TryInsertItem(m_Epinephrine), "finite epinephrine enters healer inventory")) return -1;
m_Deadline = now + 45000; m_Wait = now + 1000; m_Step = 6; return 0;
}
if (m_Step == 6)
{
SCR_ConsumableItemComponent item = SCR_ConsumableItemComponent.Cast(m_Epinephrine.FindComponent(SCR_ConsumableItemComponent));
ERR_ConsumableEpinephrine effect = ERR_ConsumableEpinephrine.Cast(item.GetConsumableEffect());
SCR_EConsumableFailReason reason;
if (!Check(effect && effect.CanApplyEffect(m_Casualty, m_Healer, reason), "unconscious casualty accepts actual epinephrine effect")) return -1;
m_BeforeEpi = m_Damage.GetResilienceHitZone().GetHealth();
item.ApplyItemEffect(m_Casualty, m_Healer, effect.GetAnimationParameters(m_Epinephrine, m_Casualty), m_Epinephrine, effect.GetDeleteOnUse());
if (!Check(!effect.CanApplyEffect(m_Casualty, m_Healer, reason), "duplicate injection refused while treatment active")) return -1;
m_Wait = now + 6000; m_Step = 7; return 0;
}
if (m_Step == 7)
{
if (!Check(!m_Epinephrine && m_Damage.GetResilienceHitZone().GetHealth() > m_BeforeEpi,
"epinephrine is consumed and resilience recovers over time")) return -1;
m_Damage.GetBloodHitZone().HandleDamage(100000, EDamageType.TRUE, null);
m_Wait = now + 1000; m_Step = 9; return 0;
}
if (m_Step == 9)
{
if (!Check(ERR_Actors.Alive(m_Casualty) && m_Controller.IsUnconscious() && m_Blood.GetDamageState() != EDamageState.DESTROYED && m_Blood.GetHealthScaled() < 0.1,
"otherwise fatal blood loss preserves the same unconscious treatable body without healing it")) return -1;
m_Result = 1;
Stop();
Print("ERR MEDICAL RESULT: native blood loss, finite treatment and recovery; not manual action or multiplayer acceptance");
@@ -0,0 +1,52 @@
#ifdef WORKBENCH
// Force the reported camp-5 terrain refusal only in this opt-in native fixture.
modded class ERR_CampTerrain
{
static bool s_ERROptionalSiteRefused;
override bool Site(ERR_SiteDefinition site, vector position, float heading, out float baseHeight)
{
string outcome;
if (site.Id == "everon-camp-5" && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "optional-site-skip")
{
s_ERROptionalSiteRefused = true; return false;
}
return super.Site(site, position, heading, baseHeight);
}
}
modded class ERR_GameMode
{
protected float m_ERROptionalSiteDeadline;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string outcome;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "optional-site-skip") return;
ERR_CampTerrain.s_ERROptionalSiteRefused = false;
m_ERROptionalSiteDeadline = System.GetTickCount() + 240000;
GetGame().GetCallqueue().CallLater(ERR_OptionalSiteTick, 1000, true);
}
protected void ERR_OptionalSiteTick()
{
if (System.GetTickCount() >= m_ERROptionalSiteDeadline)
{
Print("ERR SMOKE FAIL: optional site skip startup timed out", LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_OptionalSiteTick); return;
}
if (Phase() != ERR_Phase.PRISON || !GetGame().GetPlayerController()) return;
IEntity player = GetGame().GetPlayerController().GetControlledEntity(); if (!player) return;
GetGame().GetCallqueue().Remove(ERR_OptionalSiteTick);
if (!ERR_CampTerrain.s_ERROptionalSiteRefused || Sites().Find("everon-camp-5"))
{ Print("ERR SMOKE FAIL: forced camp refusal must leave no spawned camp runtime", LogLevel.ERROR); return; }
if (!Sites().Find("everon-prison-1") || !Sites().Find("everon-radio-1") || !Sites().Find("everon-extraction-1"))
{ Print("ERR SMOKE FAIL: required mission sites missing after optional skip", LogLevel.ERROR); return; }
for (int index = 1; index <= 4; index++)
if (!Sites().Find("everon-mortar-" + index.ToString()))
{ Print("ERR SMOKE FAIL: required mortar missing after optional skip", LogLevel.ERROR); return; }
Print("ERR SMOKE PASS: forced camp-5 terrain/fallback failure omitted the camp before spawning");
Print("ERR SMOKE PASS: required prison, radio, extraction and all four mortars retained");
Print("ERR SMOKE PASS: mission preparation completed and a real player was admitted despite the skipped camp");
Transition(ERR_Phase.DEFEAT);
Print("ERR SMOKE COMPLETE: optional site refusal does not block actual mission preparation or player admission");
}
}
#endif
@@ -40,17 +40,20 @@ class ERR_NativeRecoveryProbe
if (now < m_Wait) return 0;
if (m_Step == 0)
{
if (!Check(m_Damage && m_Controller && m_Damage.ERR_IsRevivablePlayer(), "real admitted prisoner has player-only casualty protection")) return -1;
if (!Check(m_Damage && m_Controller && m_Damage.ERR_IsRevivablePlayer(), "real admitted prisoner has scoped medical policy")) return -1;
m_Damage.EnableDamageHandling(true);
// Each exceeds full native health, including blood loss and a head injury.
m_Damage.GetDefaultHitZone().HandleDamage(100000, EDamageType.TRUE, null);
m_Damage.GetBloodHitZone().HandleDamage(100000, EDamageType.TRUE, null);
m_Damage.GetHeadHitZone().HandleDamage(100000, EDamageType.TRUE, null);
m_Damage.ForceUnconsciousness(); HaltPassiveRecovery();
m_Wait = now + 1000; m_Step = 1; return 0;
}
if (m_Step == 1)
{
if (!Check(ERR_Actors.Alive(m_Player) && m_Controller.IsUnconscious() && m_Damage.GetState() != EDamageState.DESTROYED && m_Damage.GetBloodHitZone().GetDamageState() != EDamageState.DESTROYED, "otherwise lethal health, head and blood damage leave the same living unconscious body")) return -1;
if (!Check(ERR_Actors.Alive(m_Player) && m_Controller.IsUnconscious() && m_Damage.GetState() != EDamageState.DESTROYED && m_Damage.GetBloodHitZone().GetDamageState() != EDamageState.DESTROYED, "survivable knockout leaves the same living unconscious body")) return -1;
m_Damage.FullHeal(); m_Damage.ForceUnconsciousness();
m_Wait = now + 35000; m_Step = 10; return 0;
}
if (m_Step == 10)
{
if (!Check(m_Mode.Active() && (ERR_Actors.CanFight(m_Player) || m_Damage.ERR_CanRecoverNaturally()), "viable natural recovery keeps the all-unconscious round alive beyond the failure grace")) return -1;
// Reset fixture injuries, then exercise treatment of an isolated knockout.
m_Damage.FullHeal(); m_Damage.ForceUnconsciousness(); HaltPassiveRecovery();
m_Damage.SetGroupIsBeingHealed(ECharacterHitZoneGroup.RIGHTARM, true);
@@ -16,6 +16,10 @@ class ERR_ObserverRules
if (right && !previousRight) return 1;
return 0;
}
static bool Toggle(bool enabled, bool pressed, bool previous)
{
return enabled && pressed && !previous;
}
static bool Casualty(bool active, bool currentBody, bool alive, bool conscious)
{
return active && currentBody && alive && !conscious;
@@ -48,7 +52,7 @@ class ERR_ObserverGuidance
}
if (!survivorAvailable)
return message + " No survivor is currently available to follow. The view will follow a survivor when one becomes available.";
return message + " Press Left / Right arrow to change teammate (or /esc spectate next / /esc spectate previous). Use camera look controls to orbit and zoom controls to change distance.";
return message + " Press Left / Right arrow to change teammate (or /esc spectate next / /esc spectate previous). Press Enter to switch between third-person and player view. Use camera look controls to orbit and zoom controls to change distance in third-person.";
}
static bool Refresh(bool shown, int previousReason, int reason, bool previousAvailable, bool survivorAvailable)
{
@@ -65,18 +69,55 @@ class ERR_FollowSurvivorCameraComponent : SCR_BaseManualCameraComponent
protected float m_Yaw;
protected float m_Pitch = 20;
protected float m_Distance = 5;
protected bool m_LeftDown, m_RightDown;
protected bool m_LeftDown, m_RightDown, m_EnterDown;
protected bool m_PlayerView;
protected bool m_InputContextActive;
bool InputContextActive() { return m_InputContextActive; }
bool IsPlayerView() { return m_PlayerView; }
// Process one edge per press; menus still consume the state so holding a key
// while closing a menu cannot unexpectedly change the followed teammate.
void Input(bool enabled, bool left, bool right, bool enter)
{
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
int cycle = ERR_ObserverRules.Cycle(enabled, left, right, m_LeftDown, m_RightDown);
if (controller && cycle != 0) controller.ERR_CycleObserver(cycle);
if (ERR_ObserverRules.Toggle(enabled, enter, m_EnterDown)) m_PlayerView = !m_PlayerView;
m_LeftDown = left; m_RightDown = right; m_EnterDown = enter;
}
static bool PlayerView(IEntity target, out vector transform[4])
{
ChimeraCharacter character = ChimeraCharacter.Cast(target);
if (!character || !character.GetAnimation()) return false;
int head = character.GetAnimation().GetBoneIndex("Head");
if (head < 0) return false;
vector bone[4], world[4];
if (!character.GetAnimation().GetBoneMatrix(head, bone)) return false;
character.GetWorldTransform(world);
vector position = bone[3].Multiply4(world);
vector direction = world[2];
CharacterControllerComponent controls = character.GetCharacterController();
if (controls && controls.GetHeadAimingComponent()) direction = controls.GetHeadAimingComponent().GetAimingDirectionWorld();
if (direction.LengthSq() < 0.01) direction = world[2];
direction.Normalize();
Math3D.DirectionAndUpMatrix(direction, "0 1 0", transform);
// Just ahead of the face, following posture and vehicle seating. This reads
// replicated pose; it does not possess the teammate or change their camera.
transform[3] = position + direction * 0.15;
return true;
}
override bool EOnCameraInit() { return true; }
override void EOnCameraFrame(SCR_ManualCameraParam param)
{
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
if (!controller) return;
bool left = (Debug.KeyState(KeyCode.KC_LEFT) & 32768) != 0;
bool right = (Debug.KeyState(KeyCode.KC_RIGHT) & 32768) != 0;
int cycle = ERR_ObserverRules.Cycle(param.isManualInputEnabled, left, right, m_LeftDown, m_RightDown);
m_LeftDown = left; m_RightDown = right;
if (cycle != 0) controller.ERR_CycleObserver(cycle);
m_InputContextActive = GetInputManager().IsContextActive("ERR_ObserverContext");
Input(param.isManualInputEnabled, GetInputManager().GetActionValue("ERR_ObserverPrevious") > 0.5, GetInputManager().GetActionValue("ERR_ObserverNext") > 0.5, GetInputManager().GetActionValue("ERR_ObserverView") > 0.5);
IEntity target = controller.ERR_ObservedEntity();
if (m_PlayerView && target && PlayerView(target, param.transform))
{
param.isDirty = true;
return;
}
vector aim = controller.ERR_ObserverAnchor() + "0 1.5 0";
vector position = aim + "0 4 -6";
IEntity exclude = target;
@@ -147,6 +188,8 @@ modded class SCR_PlayerController
protected ERR_ObserverReason m_ERRShownObserverReason;
protected bool m_ERRShownObserverAvailable;
protected float m_ERRLastCycle = -1000;
protected IEntity m_ERRRecoveryBody;
protected float m_ERRRecoveryDeadline = -1;
ERR_ObserverReason ERR_ObserverState() { return m_ERRObserverReason; }
vector ERR_ObserverAnchor() { return m_ERRObserverAnchor; }
@@ -266,6 +309,7 @@ modded class SCR_PlayerController
protected void ERR_UpdateObserverClient()
{
ERR_GameMode mode = ERR_GameMode.Get();
ERR_UpdateRecoveryCamera(mode);
bool observe = mode && ERR_ObserverRules.View(m_ERRObserverReason, mode.Active(), ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity()));
if (!observe)
{
@@ -299,6 +343,31 @@ modded class SCR_PlayerController
m_ERRShownObserverAvailable = survivorAvailable;
}
}
// Track the actual local body even when an editor/other camera owns the view.
// Run once per recovery, with retries for asynchronous editor close/camera setup.
protected void ERR_UpdateRecoveryCamera(ERR_GameMode mode)
{
IEntity body = GetControlledEntity();
if (!mode || !mode.Active() || !ERR_Actors.Alive(body))
{ m_ERRRecoveryBody = null; m_ERRRecoveryDeadline = -1; return; }
if (!ERR_Actors.CanFight(body))
{ m_ERRRecoveryBody = body; m_ERRRecoveryDeadline = -1; return; }
if (!m_ERRRecoveryBody) return;
if (body != m_ERRRecoveryBody)
{ m_ERRRecoveryBody = null; m_ERRRecoveryDeadline = -1; return; }
float now = GetGame().GetWorld().GetWorldTime();
if (m_ERRRecoveryDeadline < 0) m_ERRRecoveryDeadline = now + 10000;
SCR_EditorManagerEntity editor = SCR_EditorManagerEntity.GetInstance();
if (editor && editor.IsOpened()) editor.Close(false);
CameraManager manager = GetGame().GetCameraManager();
CameraBase playerCamera = GetPlayerCamera();
if (manager && playerCamera) manager.SetCamera(playerCamera);
if ((manager && playerCamera && manager.CurrentCamera() == playerCamera && (!editor || !editor.IsOpened())) || now >= m_ERRRecoveryDeadline)
{
if (now >= m_ERRRecoveryDeadline) Print("Reintroduced [spectator]: recovery camera return timed out waiting for editor/camera; player body remains controlled", LogLevel.WARNING);
m_ERRRecoveryBody = null; m_ERRRecoveryDeadline = -1;
}
}
protected void ERR_CloseObserverCamera()
{
m_ERRObserverReady = false;
@@ -19,6 +19,7 @@ modded class SCR_CharacterDamageManagerComponent
if (!Replication.IsServer()) return;
m_ERRRevivablePlayer = true;
SetPermitUnconsciousness(true, true);
SetRegenScale(0.25, true);
}
bool ERR_IsRevivablePlayer() { return m_ERRRevivablePlayer; }
@@ -44,11 +45,34 @@ modded class SCR_CharacterDamageManagerComponent
if (!m_ERRRevivablePlayer) { super.Kill(instigator); return; }
ERR_RequestKnockout();
}
// Include delayed but viable native regeneration when deciding team defeat.
// A stopped/zero-rate effect or an actively bleeding casualty cannot self-rescue.
bool ERR_CanRecoverNaturally()
{
if (!m_ERRRevivablePlayer || IsBleeding()) return false;
array<ref SCR_PersistentDamageEffect> effects = GetAllPersistentEffectsOfType(SCR_PassiveHitZoneRegenDamageEffect, true);
foreach (SCR_PersistentDamageEffect effect : effects)
{
DotDamageEffect dot = DotDamageEffect.Cast(effect);
if (!dot || dot.GetDamageType() != EDamageType.REGENERATION || dot.GetDPS() >= 0) continue;
HitZone zone = dot.GetAffectedHitZone();
if (!zone || zone.GetHealthScaled() >= 1) continue;
if (zone == GetBloodHitZone()) return true;
if (zone == GetResilienceHitZone() && GetResilienceRegenScale() > 0) return true;
}
return false;
}
// A player always retains a living, treatable body.
override protected bool IsIndefinitelyUnconscious(bool onlyPlayers = true)
{
if (m_ERRRevivablePlayer) return false;
return super.IsIndefinitelyUnconscious(onlyPlayers);
}
override float GetResilienceRegenScale()
{
if (m_ERRRevivablePlayer && IsBleeding()) return 0;
return super.GetResilienceRegenScale();
}
bool ERR_TreatmentInProgress()
{
if (m_aBeingHealedGroup && !m_aBeingHealedGroup.IsEmpty()) return true;
@@ -56,8 +80,7 @@ modded class SCR_CharacterDamageManagerComponent
foreach (SCR_PersistentDamageEffect effect : effects)
{
DotDamageEffect dot = DotDamageEffect.Cast(effect);
// Includes saline and ACE epinephrine. Passive regeneration is checked by
// observed progress, so a stalled regeneration effect cannot block defeat.
// Includes saline and epinephrine; passive regeneration is evaluated separately.
if (dot && dot.GetDamageType() == EDamageType.HEALING && dot.GetDPS() < 0) return true;
}
return false;
@@ -77,3 +100,16 @@ modded class SCR_HitZone
return allowed;
}
}
// Native resilience ignores the per-character regeneration setting. Apply the
// same slow rate here, only to passive recovery; injected medicine keeps its DPS.
modded class SCR_CharacterResilienceHitZone
{
override float CalculatePassiveRegenDPS(bool considerRegenScale = true)
{
float rate = super.CalculatePassiveRegenDPS(considerRegenScale);
SCR_CharacterDamageManagerComponent manager = SCR_CharacterDamageManagerComponent.Cast(GetHitZoneContainer());
if (considerRegenScale && manager && manager.ERR_IsRevivablePlayer()) return rate * 0.25;
return rate;
}
}
@@ -39,6 +39,9 @@ class ERR_RecoveryService
protected float m_StalledSince = -1;
protected ref map<IEntity, ref ERR_RecoverySample> m_Samples = new map<IEntity, ref ERR_RecoverySample>();
protected string m_Message;
protected float m_NextMedicalLog;
protected string m_MedicalDetails;
protected bool m_DefeatLogged;
protected ref ScriptInvoker m_OnChanged = new ScriptInvoker();
void ERR_RecoveryService(ERR_GameMode mode) { m_Mode = mode; }
ScriptInvoker OnChanged() { return m_OnChanged; }
@@ -46,11 +49,29 @@ class ERR_RecoveryService
int Incapacitated() { return m_Incapacitated; }
int Reserved() { return m_Reserved; }
string Message() { return m_Message; }
bool DefeatDue(float now) { return ERR_RecoveryRules.DefeatDue(m_StalledSince, now); }
bool DefeatDue(float now)
{
bool due = ERR_RecoveryRules.DefeatDue(m_StalledSince, now);
if (due && !m_DefeatLogged)
{
m_DefeatLogged = true;
ERR_HQ.Event("MEDICAL DEFEAT", "30 seconds without a viable rescuer or recovery; " + m_MedicalDetails);
}
return due;
}
float StalledSeconds(float now)
{
if (m_StalledSince < 0) return 0;
return Math.Max(0, now - m_StalledSince) / 1000;
}
void Tick()
{
if (!Replication.IsServer() || m_Stopped || !m_Mode || !m_Mode.Active()) return;
float now = GetGame().GetWorld().GetWorldTime();
bool logMedical = now >= m_NextMedicalLog;
string medicalDetails;
int conscious, incapacitated, reserved, reservedRescuers, recovering;
map<IEntity, ref ERR_RecoverySample> nextSamples = new map<IEntity, ref ERR_RecoverySample>();
array<ref ERR_PlayerLife> lives = {};
@@ -76,22 +97,30 @@ class ERR_RecoveryService
if (medical.GetResilienceHitZone()) sample.Resilience = medical.GetResilienceHitZone().GetHealth();
ERR_RecoverySample previous = m_Samples.Get(life.Actor);
bool improving = previous && (sample.Blood > previous.Blood + 0.001 || sample.Resilience > previous.Resilience + 0.001);
if (improving || medical.ERR_TreatmentInProgress()) recovering++;
bool treatment = medical.ERR_TreatmentInProgress();
bool natural = medical.ERR_CanRecoverNaturally();
if (improving || treatment || natural) recovering++;
medicalDetails += string.Format("player=%1 connected=%2 blood=%3 resilience=%4 bleeding=%5 improving=%6 treatment=%7 naturalRecovery=%8; ", life.ConnectionId, life.Connected, sample.Blood, sample.Resilience, medical.IsBleeding(), improving, treatment, natural);
nextSamples.Insert(life.Actor, sample);
}
m_MedicalDetails = medicalDetails;
m_Samples = nextSamples;
m_Conscious = conscious;
m_Incapacitated = incapacitated;
m_Reserved = reserved;
float now = GetGame().GetWorld().GetWorldTime();
bool stalled = ERR_RecoveryRules.Stalled(conscious, incapacitated, reservedRescuers, recovering, GetGame().GetPlayerManager().GetPlayerCount() > 0);
if (!stalled) m_StalledSince = -1;
else if (m_StalledSince < 0 || now < m_StalledSince) m_StalledSince = now;
if (logMedical && incapacitated > 0)
{
m_NextMedicalLog = now + 10000;
ERR_HQ.Event("MEDICAL STATUS", string.Format("conscious=%1 unconscious=%2 reservedRescuers=%3 recovering=%4 defeatTimer=%5/30s; %6", conscious, incapacitated, reservedRescuers, recovering, StalledSeconds(now), medicalDetails));
}
string message;
if (ERR_RecoveryRules.NeedsRescuer(conscious, incapacitated))
{
message = "No conscious prisoner is currently available to help.";
if (recovering > 0) message += " Treatment or recovery is progressing; the round continues.";
if (recovering > 0) message += " Treatment or natural recovery is possible; the round continues.";
else if (reservedRescuers > 0) message += " A conscious prisoner's reconnection may provide help.";
else message += " Everyone is unconscious with no treatment or recovery progressing. The mission fails after 30 uninterrupted seconds in this state.";
}
@@ -30,7 +30,7 @@ class ERR_ScenarioComponent : ScriptComponent
m_Started = true;
#ifdef WORKBENCH
string historicalFixture;
if (System.GetCLIParam("errSmokeOutcome", historicalFixture) && historicalFixture != "site-garrisons" && historicalFixture != "generated-sites" && historicalFixture != "opening-tools" && historicalFixture != "airfield-markers" && historicalFixture != "air-recon" && historicalFixture != "air-logistics" && historicalFixture != "air-assault")
if (System.GetCLIParam("errSmokeOutcome", historicalFixture) && historicalFixture != "optional-site-skip" && historicalFixture != "infantry-combat" && historicalFixture != "site-garrisons" && 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));
@@ -38,7 +38,7 @@ class ERR_ScenarioComponent : ScriptComponent
#ifdef WORKBENCH
// Existing deterministic probes intentionally use authored coordinates.
string fixture;
if (System.GetCLIParam("errSmokeOutcome", fixture) && fixture != "site-garrisons" && fixture != "generated-sites" && fixture != "airfield-markers" && fixture != "air-recon" && fixture != "air-logistics" && fixture != "air-assault")
if (System.GetCLIParam("errSmokeOutcome", fixture) && fixture != "optional-site-skip" && fixture != "infantry-combat" && fixture != "site-garrisons" && 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();
@@ -11,6 +11,11 @@ modded class SCR_PlayerController
if (m_ERRCameraProbeTarget && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "unconscious-camera") return m_ERRCameraProbeTarget;
return super.ERR_ObservedEntity();
}
ERR_FollowSurvivorCameraComponent ERR_ProbeFollowComponent()
{
if (!m_ERRObserverCamera) return null;
return ERR_FollowSurvivorCameraComponent.Cast(m_ERRObserverCamera.FindCameraComponent(ERR_FollowSurvivorCameraComponent));
}
bool ERR_ProbeCameraActive()
{
return m_ERRObserverCamera && GetGame().GetCameraManager().CurrentCamera() == m_ERRObserverCamera;
@@ -24,6 +29,7 @@ modded class ERR_GameMode
protected float m_ERRCameraWait;
protected IEntity m_ERRCameraBody, m_ERRCameraTarget;
protected SCR_CharacterDamageManagerComponent m_ERRCameraDamage;
protected SCR_ManualCamera m_ERRCameraOther;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
@@ -93,6 +99,7 @@ modded class ERR_GameMode
if (m_ERRCameraStage == 2)
{
vector camera = GetGame().GetCameraManager().CurrentCamera().GetOrigin();
PrintFormat("ERR CAMERA TRACK: camera=%1 target=%2 body=%3 active=%4 conscious=%5 reason=%6", camera, m_ERRCameraTarget.GetOrigin(), m_ERRCameraBody.GetOrigin(), pc.ERR_ProbeCameraActive(), ERR_Actors.CanFight(m_ERRCameraBody), pc.ERR_ObserverState());
if (!ERR_CameraCheck(vector.Distance(camera, m_ERRCameraTarget.GetOrigin()) < 13 && vector.Distance(camera, m_ERRCameraBody.GetOrigin()) > 15, "camera follows target in 3D without moving the casualty")) return;
m_ERRCameraTarget.SetOrigin(ERR_Actors.Ground(m_ERRCameraTarget.GetOrigin() + "15 0 0"));
m_ERRCameraStage = 3; m_ERRCameraWait = now + 1500; return;
@@ -100,6 +107,39 @@ modded class ERR_GameMode
if (m_ERRCameraStage == 3)
{
if (!ERR_CameraCheck(vector.Distance(GetGame().GetCameraManager().CurrentCamera().GetOrigin(), m_ERRCameraTarget.GetOrigin()) < 13, "camera tracks a moving target")) return;
InputManager input = GetGame().GetInputManager();
ERR_FollowSurvivorCameraComponent follow = pc.ERR_ProbeFollowComponent();
if (!ERR_CameraCheck(follow != null, "follow component exists")) return;
ResourceName configuredInput;
GameProject.GetModuleConfig("InputManagerSettings").Get("Default", configuredInput);
PrintFormat("ERR CAMERA INPUT: default=%1 context=%2 previous=%3 next=%4 view=%5 orbit=%6", configuredInput, input.IsContextActive("ERR_ObserverContext"), input.IsActionActive("ERR_ObserverPrevious"), input.IsActionActive("ERR_ObserverNext"), input.IsActionActive("ERR_ObserverView"), input.IsActionActive("ManualCameraRotateYaw"));
for (int inputIndex = 0; inputIndex < input.GetActionCount(); inputIndex++)
{
string inputName = input.GetActionName(inputIndex);
if (inputName.Contains("Observer") || inputName == "ManualCameraRotateYaw") Print("ERR CAMERA INPUT REGISTERED: " + inputName);
}
if (!ERR_CameraCheck(follow.InputContextActive() && input.IsActionActive("ERR_ObserverPrevious") && input.IsActionActive("ERR_ObserverNext") && input.IsActionActive("ERR_ObserverView") && input.IsActionActive("ManualCameraRotateYaw"), "dedicated arrow/Enter actions and inherited orbit inputs loaded and active")) return;
follow.Input(true, false, false, true);
if (!ERR_CameraCheck(follow.IsPlayerView(), "Enter handler selects player view")) return;
follow.Input(true, false, false, true);
if (!ERR_CameraCheck(follow.IsPlayerView(), "held Enter does not toggle again")) return;
follow.Input(true, false, false, false);
m_ERRCameraStage = 30; m_ERRCameraWait = now + 1000; return;
}
if (m_ERRCameraStage == 30)
{
vector expected[4];
if (!ERR_CameraCheck(ERR_FollowSurvivorCameraComponent.PlayerView(m_ERRCameraTarget, expected) && vector.Distance(GetGame().GetCameraManager().CurrentCamera().GetOrigin(), expected[3]) < 0.1, "native camera follows teammate head in player view")) return;
pc.ERR_ProbeFollowComponent().Input(true, false, false, true);
pc.ERR_ProbeFollowComponent().Input(true, false, false, false);
m_ERRCameraStage = 31; m_ERRCameraWait = now + 1000; return;
}
if (m_ERRCameraStage == 31)
{
if (!ERR_CameraCheck(!pc.ERR_ProbeFollowComponent().IsPlayerView() && vector.Distance(GetGame().GetCameraManager().CurrentCamera().GetOrigin(), m_ERRCameraTarget.GetOrigin()) > 2, "second Enter restores orbit view")) return;
Resource otherResource = Resource.Load("{EB1210AAE1BB8F54}Prefabs/ERR/SpectatorCamera.et");
m_ERRCameraOther = SCR_ManualCamera.Cast(GetGame().SpawnEntityPrefab(otherResource, GetWorld()));
if (!ERR_CameraCheck(m_ERRCameraOther && GetGame().GetCameraManager().CurrentCamera() == m_ERRCameraOther && !pc.ERR_ProbeCameraActive(), "separate manual camera takes over before recovery")) return;
pc.ERR_ProbeCameraTarget(null); ERR_Actors.DeleteSpawned(m_ERRCameraTarget); m_ERRCameraTarget = null;
m_ERRCameraDamage.SetGroupIsBeingHealed(ECharacterHitZoneGroup.RIGHTARM, false);
m_ERRCameraDamage.FullHeal(); m_ERRCameraStage = 4; m_ERRCameraWait = now + 4000; return;
@@ -108,6 +148,7 @@ modded class ERR_GameMode
{
if (!ERR_CameraCheck(ERR_Actors.CanFight(m_ERRCameraBody) && pc.GetControlledEntity() == m_ERRCameraBody && pc.ERR_ObserverState() == ERR_ObserverReason.NONE && !pc.ERR_HasObserverCamera(), "native healing closes spectator and retains the original conscious player")) return;
if (!ERR_CameraCheck(GetGame().GetCameraManager().CurrentCamera() == pc.GetPlayerCamera(), "normal player camera restored")) return;
if (m_ERRCameraOther) { m_ERRCameraOther.Terminate(); delete m_ERRCameraOther; m_ERRCameraOther = null; }
if (!ERR_CameraCheck(character.GetWeaponManagerComponent().GetCurrentWeapon() && character.GetWeaponManagerComponent().GetCurrentWeapon().GetOwner() == m_ERRCameraWeapon, "revival equips the same rifle without ground pickup")) return;
if (!ERR_CameraCheck(Settings().Set("keepWeaponOnUnconscious", 0), "retention disabled through persistent setting")) return;
ERR_CameraKnockout(); m_ERRCameraStage = 5; m_ERRCameraWait = now + 3000; return;
@@ -22,7 +22,7 @@ class ERR_AirSupportPolicy
float ConsideringSince = -1;
float NextDrop;
string Reason = "waiting for delivered intelligence";
static string Refusal(ERR_AirSupportSituation s)
static string Refusal(ERR_AirSupportSituation s, bool checkAirspace = true)
{
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";
@@ -31,13 +31,16 @@ class ERR_AirSupportPolicy
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";
if (checkAirspace && 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);
// Artillery temporarily closes the approach; it does not invalidate fresh
// contact history or the confirmed lack of ground support. Restarting the
// timer on every salvo could prevent a drop through every clear interval.
string refusal = Refusal(s, false);
if (!refusal.IsEmpty()) { Candidate = 0;
ConsideringSince = -1;
Reason = refusal;
@@ -49,6 +52,9 @@ class ERR_AirSupportPolicy
if (Candidate != s.BeliefId || ConsideringSince < 0) { Candidate = s.BeliefId;
ConsideringSince = now;
}
if (s.ArtilleryBusy) { Reason = Refusal(s);
return false;
}
float delay = 45000;
if (s.ConfirmedLosses >= 2) delay = 20000;
if (now - ConsideringSince < delay) { Reason = "confirming persistent contact and ground-force shortfall";
@@ -0,0 +1,21 @@
// Bounded retries only after boarding/approach progress stops. Reissuing a
// waypoint while soldiers are still closing on the truck interrupts native AI.
class ERR_ConvoyBoardingProgress
{
int Seated = -1;
float Distance = -1;
float LastProgress;
float NextRetry;
int Retries;
bool Retry(int seated, float distance, float now)
{
if (Seated != seated || Distance < 0 || distance <= Distance - 5)
{
Seated = seated; Distance = distance; LastProgress = now;
return false;
}
if (Retries >= 3 || now - LastProgress < 30000 || now < NextRetry) return false;
Retries++; NextRetry = now + 30000;
return true;
}
}
@@ -124,7 +124,7 @@ class ERR_ConvoyRoadPlan
}
return route.Count() < bestRoute.Count();
}
static bool Build(vector seed, out array<vector> positions, out array<vector> angles, out ERR_ForceRoute route)
static bool Build(vector seed, out array<vector> positions, out array<vector> angles, out ERR_ForceRoute route, array<vector> excludedStarts = null, float separation = 0)
{
positions = {}; angles = {}; route = null;
if (!Replication.IsServer() || !ERR_ForceRules.FiniteVector(seed)) return false;
@@ -144,6 +144,10 @@ class ERR_ConvoyRoadPlan
ERR_ForceRoute polyline;
float distance;
if (!FromPolyline(points, seed, candidatePositions, candidateAngles, polyline, distance, candidateIndex)) continue;
// Exclude occupied deployment areas before selecting the best formation.
// Returning the nearest clear-but-too-close road used to hide valid
// alternatives from startup's outer separation check.
if (excludedStarts && !ERR_SupplyConvoyRules.Separated(candidatePositions[0], excludedStarts, separation)) continue;
for (int i = 0; i < candidatePositions.Count(); i++) candidatePositions[i] = ERR_Actors.Ground(candidatePositions[i]);
ERR_ForceRoute grounded = new ERR_ForceRoute();
bool valid = true;
@@ -80,17 +80,21 @@ class ERR_EnemyEquipmentEntry
ref ERR_EnemyOptics Optics;
ref ERR_EnemyLauncherGrant Launcher = new ERR_EnemyLauncherGrant();
ref ERR_EnemyMineGrant Mines = new ERR_EnemyMineGrant();
ref ERR_EnemyMedicine Medicine = new ERR_EnemyMedicine();
int SkillChecks;
bool Released;
void Check(ERR_GameMode mode)
{
Medicine.Check(mode, Group, Actor);
// Medic storage is for treatment; do not consume it with optional mine/AA grants.
if (Actor && Actor.GetPrefabData() && ERR_EnemyMedicalCatalog.Medic(Actor.GetPrefabData().GetPrefabName())) return;
Launcher.Check(mode, Group, Actor);
Mines.Check(mode, Group, Actor);
}
void Stop(ERR_GameMode mode)
{
Launcher.Stop(); Mines.Stop();
Launcher.Stop(); Mines.Stop(); Medicine.Stop();
Released = true;
if (Optics) Optics.Stop();
if (Combat) Combat.ERR_ReleaseEnemySkill(mode);
@@ -0,0 +1,131 @@
class ERR_EnemyMedicalCatalog
{
static const ResourceName EPI = "{C68512DB5FC74698}Prefabs/ERR/Epinephrine.et";
static const ResourceName MORPHINE = "{0D9A5DCF89AE7AA9}Prefabs/Items/Medicine/MorphineInjection_01/MorphineInjection_01.et";
static bool Specialist(string path)
{
path.ToLower();
return path.Contains("_sf") || path.Contains("spetsnaz") || path.Contains("greenberets") || path.Contains("special") || path.Contains("recon") || path.Contains("marsoc");
}
static bool Medic(string path) { path.ToLower(); return path.Contains("medic"); }
static ResourceName Crew(ERR_GameMode mode, bool medic)
{
string key = mode.EnemyFaction().GetFactionKey();
if (key == "USSR")
{
if (medic) return "{8CA70597606992EC}Prefabs/Characters/Factions/OPFOR/USSR_Army/Spetsnaz/Character_USSR_SF_Medic.et";
return "{2DB452B3EC386B92}Prefabs/Characters/Factions/OPFOR/USSR_Army/Spetsnaz/Character_USSR_SF.et";
}
if (key == "US")
{
if (medic) return "{9A234C5857D92187}Prefabs/Characters/Factions/BLUFOR/US_Army/GreenBerets/Character_US_SF_Medic.et";
return "{36E28C628A2F83CB}Prefabs/Characters/Factions/BLUFOR/US_Army/GreenBerets/Character_US_SF.et";
}
ERR_ContentDefinition definition = ERR_ContentRegistry.Definition();
if (definition && definition.Options)
foreach (ERR_ContentOption option : definition.Options)
{
if (!option || option.NativeFactionKey != key) continue;
ResourceName configured = option.ResponseSoldier;
if (medic) configured = option.ResponseMedic;
if (ERR_EnemyLoadoutCatalog.Available(configured)) return configured;
}
SCR_Faction faction = SCR_Faction.Cast(mode.EnemyFaction());
SCR_EntityCatalog catalog;
if (faction) catalog = faction.GetFactionEntityCatalogOfType(EEntityCatalogType.CHARACTER, false);
array<SCR_EntityCatalogEntry> entries = {};
if (catalog) catalog.GetEntityList(entries);
ResourceName fallback;
for (int i = 0; i < Math.Min(entries.Count(), 4096); i++)
{
SCR_EntityCatalogEntry entry = entries[i];
if (!entry || !entry.IsEnabled()) continue;
ResourceName prefab = entry.GetPrefab();
if (Medic(prefab) != medic || !ERR_EnemyLoadoutCatalog.Available(prefab)) continue;
if (Specialist(prefab)) return prefab;
if (fallback.IsEmpty()) fallback = prefab;
}
if (!fallback.IsEmpty()) return fallback;
// Never spawn a medic belonging to the wrong faction.
if (medic) return "";
return mode.FactionSelection().Soldier();
}
}
// One finite startup allowance per physical actor, never a periodic refill.
class ERR_EnemyMedicine
{
protected bool m_Started;
protected bool m_Done;
protected int m_Epi;
protected int m_Morphine;
protected int m_Checks;
protected int m_Issued;
protected bool m_Medic;
protected ref ERR_EnemyEquipmentCallback m_Operation;
void Stop() { m_Done = true; }
protected void Finish(IEntity actor, string reason)
{
m_Done = true;
ERR_HQ.Event("MEDICAL EQUIPMENT", string.Format("soldier=%1 position=%2 medic=%3 added=%4; %5", actor, actor.GetOrigin(), m_Medic, m_Issued, reason));
}
void Check(ERR_GameMode mode, SCR_AIGroup group, IEntity actor)
{
if (m_Done || !ERR_EnemyEquipmentRules.Eligible(mode, group, actor)) return;
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent));
if (++m_Checks > 30) { Finish(actor, "startup deadline; no replacement of pending/looted medicine"); return; }
if (!inventory) return;
array<IEntity> items = {}; inventory.GetItems(items);
if (items.Count() > 512) { Finish(actor, "inventory inspection limit"); return; }
if (!m_Started)
{
m_Started = true;
string prefab;
if (actor.GetPrefabData()) prefab = actor.GetPrefabData().GetPrefabName();
ERR_HQ hq = ERR_HQ.Cast(mode.Command());
bool special = ERR_EnemyMedicalCatalog.Specialist(prefab) || (hq && hq.Hunters().Contains(group));
m_Medic = ERR_EnemyMedicalCatalog.Medic(prefab);
if (!special && !m_Medic && Math.RandomInt(0, 100) >= 25) { m_Done = true; return; }
m_Epi = 1; m_Morphine = 1;
if (special) { m_Epi = 2; m_Morphine = 2; }
if (m_Medic) { m_Epi = 6; m_Morphine = 6; }
foreach (IEntity existing : items)
{
if (!existing || !existing.GetPrefabData()) continue;
ResourceName name = existing.GetPrefabData().GetPrefabName();
if (name == ERR_EnemyMedicalCatalog.EPI) m_Epi--;
if (name == ERR_EnemyMedicalCatalog.MORPHINE) m_Morphine--;
}
}
if (m_Operation)
{
if (!m_Operation.Finished) return;
if (!m_Operation.Succeeded) { Finish(actor, "native insertion failed; no loose or duplicate medicine"); return; }
m_Issued++; m_Operation = null;
}
if (m_Epi <= 0 && m_Morphine <= 0) { Finish(actor, "finite medicine stocked; authored bandages, saline and medical bag retained"); return; }
ResourceName next = ERR_EnemyMedicalCatalog.MORPHINE;
if (m_Epi > 0) { next = ERR_EnemyMedicalCatalog.EPI; m_Epi--; }
else m_Morphine--;
BaseInventoryStorageComponent bag;
if (m_Medic)
foreach (IEntity item : items)
{
if (!item || !item.GetPrefabData()) continue;
string path = item.GetPrefabData().GetPrefabName(); path.ToLower();
if (!path.Contains("backpack")) continue;
bag = BaseInventoryStorageComponent.Cast(item.FindComponent(BaseInventoryStorageComponent));
if (bag) break;
}
m_Operation = new ERR_EnemyEquipmentCallback();
if (inventory.TrySpawnPrefabToStorage(next, bag, -1, EStoragePurpose.PURPOSE_DEPOSIT, m_Operation)) return;
// The native medical bag may already be physically full. A refused operation
// has inserted nothing: put the same finite dose into a free personal pouch.
if (bag)
{
m_Operation = new ERR_EnemyEquipmentCallback();
if (inventory.TrySpawnPrefabToStorage(next, null, -1, EStoragePurpose.PURPOSE_DEPOSIT, m_Operation)) return;
}
Finish(actor, "medicine did not fit; existing loadout preserved");
}
}
@@ -44,10 +44,12 @@ class ERR_EnemyRadio
array<IEntity> items = {}; inventory.GetItems(items);
if (items.Count() > 256) return -1;
int capability;
ERR_HQ hq = ERR_HQ.Get();
if (hq && hq.Hunters()) capability = hq.Hunters().VehicleRadioCapability(actor);
foreach (IEntity item : items)
{
if (!item || !item.FindComponent(BaseRadioComponent)) continue;
capability = 1;
capability = Math.Max(capability, 1);
SCR_RadioComponent radio = SCR_RadioComponent.Cast(item.FindComponent(SCR_RadioComponent));
if (radio && radio.ERR_IsHQManpack()) return 2;
}
@@ -30,9 +30,10 @@ 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;
// Maximum decision skill, with REGULAR aim dispersion tightened by 10%.
// Preserve the requested mean; only the random error around it is reduced.
if (m_CombatComponent && m_CombatComponent.ERR_EnhancedEnemy())
return mu + (super.GetRandomFactor(EAISkill.REGULAR, mu) - mu) * 0.9;
return super.GetRandomFactor(skill, mu);
}
}
+61 -8
View File
@@ -5,6 +5,9 @@ class ERR_HQUnit
SCR_AIGroupUtilityComponent Utility;
SCR_AIGroupPerception Detection;
float NextDetectionLog;
ref ERR_HQLocalAwareness LocalAwareness = new ERR_HQLocalAwareness();
float NextContact;
ref ERR_InfantryCombat InfantryCombat = new ERR_InfantryCombat();
IEntity Radio;
float NextReport;
ref ERR_HQRoute Uplink;
@@ -458,7 +461,7 @@ class ERR_HQ : ERR_CommandService
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));
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 with ten percent less dispersion/alert recognition", unit.Id, unit.ForceSourceId, factionKey, group.GetOrigin(), m_Mode.ForceLiving(group), unit.HomeId, unit.ReservedForSupport));
array<AIAgent> auditAgents = {}; group.GetAgents(auditAgents);
foreach (AIAgent auditAgent : auditAgents)
{
@@ -523,7 +526,8 @@ 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; }
unit.LocalAwareness.Clear();
if (unit.Detection) { unit.Detection.GetOnEnemyDetected().Remove(Detected); unit.Detection.ERR_BindAwareness(null); unit.Detection = null; }
ReleaseUnit(unit, GetGame().GetWorld().GetWorldTime());
unit.Group = null;
unit.Utility = null;
@@ -663,12 +667,32 @@ class ERR_HQ : ERR_CommandService
unit.AmmoReporting.Queued(now);
m_Journal.Write("STATUS REPORT", route.Payload, route.MessageId);
}
// Due routine status and requested check-ins get the next free uplink.
// Positive observations precede a negative search report.
// One captured contact every five seconds at most. Existing transmissions
// finish normally; overdue status gets a slot before more urgent contacts.
protected void ContactReport(ERR_HQUnit unit, float now)
{
if (unit.Uplink || now < unit.NextContact || !unit.Group) return;
ERR_HQReport report = unit.LocalAwareness.Peek(now, unit.SentObservations);
if (!report) return;
unit.NextContact = now + 5000;
ERR_HQRoute route = ObservationRoute(unit);
if (!route) return;
report.MessageId = m_Journal.Message();
route.MessageId = report.MessageId; route.Payload = report.Description(now);
unit.PendingReport = report; unit.Uplink = route;
unit.LocalAwareness.Queued(report, now);
if (report.Target) unit.SentObservations.Queued(report.Target, report.ObservedAt, now);
unit.NextReport = now + m_Mode.Settings().Runtime("reportIntervalSeconds") * 1000;
string kind = "CONTACT REPORT";
if (report.TravelMode == "heard gunfire") kind = "GUNFIRE REPORT";
m_Journal.Write(kind, route.Payload, report.MessageId);
}
protected void QueueReports(ERR_HQUnit unit, float now)
{
if (OriginOccupied(unit.Radio, unit)) { if (unit.Task) unit.Task.ArrivedAt = -1; return; }
if (ERR_HQLocalAwareness.Priority(now, unit.NextContact, unit.NextStatus)) ContactReport(unit, now);
StatusReport(unit, now);
ContactReport(unit, now);
Observe(unit, now);
ObserveBody(unit, now);
Sweep(unit, now);
@@ -783,6 +807,7 @@ class ERR_HQ : ERR_CommandService
}
if (!m_Memory.Ingest(report, now)) return;
if (ERR_HQMemoryRules.Kind(report) == ERR_HQMemoryKind.SIGHTING) m_Coverage.Forget(report.Position);
m_NextAssignment = Math.Min(m_NextAssignment, now);
m_Journal.Write("BELIEF", string.Format("delivered report M%1 retained; tracks=%2", report.MessageId, m_Memory.Count()), report.MessageId);
// A reported corpse or distress call can justify investigation, never fire.
if (ERR_HQMemoryRules.Kind(report) != ERR_HQMemoryKind.SIGHTING) return;
@@ -804,10 +829,30 @@ class ERR_HQ : ERR_CommandService
m_Contacts.Insert(report.Snapshot());
}
// Current combat suspends a sweep without deleting its native queued orders.
// Recent native contacts and the quiet grace period block HQ retasking.
protected bool Fighting(ERR_HQUnit unit)
{
return unit.Utility && unit.Utility.m_Perception && !unit.Utility.m_Perception.m_aTargets.IsEmpty();
return unit.InfantryCombat.Held || ERR_HunterCombat.Active(unit.Group);
}
protected void UpdateInfantryCombat(ERR_HQUnit unit, float now)
{
bool eligible = unit.Eligible(m_Mode);
int changed = unit.InfantryCombat.Update(unit, eligible, eligible && ERR_HunterCombat.Active(unit.Group), now);
if (!changed) return;
string detail = "contact ended; no current mobile search to resume";
if (changed == 1)
{
unit.Group.ActivateAI();
array<AIAgent> reacting = {}; unit.Group.GetAgents(reacting);
foreach (AIAgent actor : reacting)
if (actor && ERR_Actors.CanFight(actor.GetControlledEntity())) actor.ActivateAI();
detail = "enemy contact; native combat takes priority; defensive positions retained";
if (unit.InfantryCombat.Paused()) detail = "enemy contact; HQ search paused; fire at will";
// Preserve status/convoy transports. Only superseded infantry task orders stop.
if (unit.PendingTask) LostOrder(unit, now);
}
else if (changed == 2) detail = "contact quiet for 15 seconds; existing HQ search resumed";
m_Journal.Write("INFANTRY COMBAT", string.Format("Squad %1 (%2): %3", unit.Id, unit.ForceSourceId, detail));
}
protected int HomeAvailable(ERR_HQUnit candidate) { return m_HomeAvailable.Get(candidate.HomeId); }
protected ERR_HQHuntWindow Hunt(int beliefId)
@@ -1160,6 +1205,8 @@ class ERR_HQ : ERR_CommandService
if (!hunt || now < hunt.NextHunt) continue;
for (int slot = 0; slot < limit && assigned < limit && Assigned() < globalLimit; slot++)
{
// One mobile search squad for uncertain sound; never empty objectives for it.
if (belief.Kind == ERR_HQMemoryKind.GUNFIRE && slot > 0) break;
float age = (now - belief.ObservedAt) / 1000;
ERR_HQPursuitRole role = ERR_HQPursuitRules.Role(belief.Confidence, age, belief.Radius, belief.HasVelocity, slot);
vector destination = ERR_HQPursuitRules.Objective(belief.PredictedPosition, belief.Velocity, belief.HasVelocity, belief.Confidence, age, belief.Radius, role, slot);
@@ -1200,6 +1247,7 @@ class ERR_HQ : ERR_CommandService
best.PendingTask = task; best.PendingOrder = belief.Report.Snapshot(); best.Downlink = bestRoute;
bestRoute.MessageId = m_Journal.Message();
bestRoute.Payload = string.Format("group %1 %2; belief=%3 from report M%4; destination=%5", best.Id, task.Name(), task.BeliefId, belief.MessageId, destination);
if (belief.Kind == ERR_HQMemoryKind.GUNFIRE) bestRoute.Payload += "; investigate heard gunfire, enemy identity unknown";
m_Journal.Write("ORDER QUEUED", bestRoute.Payload, bestRoute.MessageId);
reserved.Insert(destination);
if (first < 0) first = bestBearing;
@@ -1242,10 +1290,11 @@ class ERR_HQ : ERR_CommandService
for (int i = m_Units.Count() - 1; i >= 0; i--)
{
ERR_HQUnit unit = m_Units[i];
UpdateInfantryCombat(unit, now);
if (!unit.Detection && unit.Utility && unit.Group)
{
unit.Detection = unit.Utility.m_Perception;
if (unit.Detection) unit.Detection.GetOnEnemyDetected().Insert(Detected);
if (unit.Detection) { unit.Detection.GetOnEnemyDetected().Insert(Detected); unit.Detection.ERR_BindAwareness(unit.LocalAwareness); }
}
if (unit.Uplink)
{
@@ -1289,7 +1338,11 @@ class ERR_HQ : ERR_CommandService
m_Towns.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);
foreach (ERR_HQUnit detector : m_Units)
{
detector.LocalAwareness.Clear();
if (detector.Detection) { detector.Detection.GetOnEnemyDetected().Remove(Detected); detector.Detection.ERR_BindAwareness(null); }
}
m_Airfield.Stop(); m_Recovery.Stop();
m_AirAssault.Stop();
m_Recon.Stop();
@@ -0,0 +1,111 @@
// Local evidence belongs to the observing squad. HQ sees it only after the
// existing physical radio route completes. No live target position is queried.
class ERR_HQLocalAwareness
{
protected ref array<ref ERR_HQReport> m_Visual = {};
protected ref ERR_HQReport m_Sound;
protected float m_NextSound;
static bool Fresh(float observedAt, float now)
{
return ERR_ForceRules.Finite(observedAt) && ERR_ForceRules.Finite(now)
&& observedAt >= 0 && now >= observedAt && now - observedAt <= 30000;
}
static bool Priority(float now, float nextContact, float nextStatus)
{
// Fresh contacts may precede routine status, but cannot starve overdue status.
return now >= nextContact && now < nextStatus + 15000;
}
static vector SoundArea(vector position)
{
// Do not distribute the engine's precise shot origin or the shooter's identity.
return Vector(Math.Floor(position[0] / 150) * 150 + 75, position[1], Math.Floor(position[2] / 150) * 150 + 75);
}
void Clear() { m_Visual.Clear(); m_Sound = null; m_NextSound = 0; }
void Prune(float now)
{
for (int i = m_Visual.Count() - 1; i >= 0; i--)
if (!m_Visual[i].Target || !Fresh(m_Visual[i].ObservedAt, now)) m_Visual.Remove(i);
if (m_Sound && !Fresh(m_Sound.ObservedAt, now)) m_Sound = null;
}
bool CaptureDue(IEntity target, float now)
{
foreach (ERR_HQReport known : m_Visual)
if (known.Target == target && now - known.ObservedAt < 1000) return false;
return true;
}
void Remember(ERR_HQReport report, float now)
{
if (!report || !report.Target || !Fresh(report.ObservedAt, now)) return;
Prune(now);
for (int i = 0; i < m_Visual.Count(); i++)
{
if (m_Visual[i].Target != report.Target) continue;
if (report.ObservedAt > m_Visual[i].ObservedAt) m_Visual[i] = report.Snapshot();
return;
}
if (m_Visual.Count() >= 16) m_Visual.Remove(0);
m_Visual.Insert(report.Snapshot());
}
void Hear(SCR_AIGroup source, vector position, float observedAt, float now)
{
if (!source || !Fresh(observedAt, now) || !ERR_HQMemoryRules.PositionValid(position)) return;
if (m_Sound && observedAt - m_Sound.ObservedAt < 1000) return;
m_Sound = new ERR_HQReport();
m_Sound.Source = source; m_Sound.Position = SoundArea(position);
m_Sound.ObservedAt = observedAt; m_Sound.TravelMode = "heard gunfire";
}
ERR_HQReport Peek(float now, ERR_HQObservationHistory history)
{
Prune(now);
foreach (ERR_HQReport report : m_Visual)
if (history.CanQueue(report.Target, report.ObservedAt, now)) return report.Snapshot();
if (m_Sound && now >= m_NextSound) return m_Sound.Snapshot();
return null;
}
void Queued(ERR_HQReport report, float now)
{
if (report.TravelMode == "heard gunfire") { m_Sound = null; m_NextSound = now + 30000; return; }
for (int i = m_Visual.Count() - 1; i >= 0; i--)
if (m_Visual[i].Target == report.Target && m_Visual[i].ObservedAt <= report.ObservedAt) m_Visual.Remove(i);
}
}
modded class SCR_AIGroupPerception
{
protected ERR_HQLocalAwareness m_ERRAwareness;
void ERR_BindAwareness(ERR_HQLocalAwareness awareness) { m_ERRAwareness = awareness; }
protected bool ERR_AwarenessActive()
{
ERR_GameMode mode = ERR_GameMode.Get();
return m_ERRAwareness && Replication.IsServer() && mode && mode.Active() && mode.Phase() > ERR_Phase.PRISON
&& m_Group && !m_Group.IsSlave() && m_Group.GetPlayerCount(true) == 0 && m_Group.GetFaction() == mode.EnemyFaction();
}
override SCR_AITargetInfo AddOrUpdateTarget(notnull BaseTarget target, out bool outNewTarget)
{
SCR_AITargetInfo result = super.AddOrUpdateTarget(target, outNewTarget);
if (!result || !ERR_AwarenessActive() || target.IsDisarmed()) return result;
float now = GetGame().GetWorld().GetWorldTime();
if (!m_ERRAwareness.CaptureDue(target.GetTargetEntity(), now)) return result;
ERR_HQVisualContact seen = ERR_HQObservation.Capture(target, now);
if (!seen) return result;
ERR_HQReport report = new ERR_HQReport();
report.Source = m_Group; report.Target = seen.Entity; report.Position = seen.Position; report.ObservedAt = seen.ObservedAt;
report.Engaged = result.m_bEndangering;
array<ref ERR_HQVisualContact> samples = {seen};
ERR_HQObservation.Describe(report, samples, now);
m_ERRAwareness.Remember(report, now);
return result;
}
override void AddOrUpdateGunshot(notnull IEntity shooter, vector worldPos, Faction faction, float timestamp, bool endangering)
{
super.AddOrUpdateGunshot(shooter, worldPos, faction, timestamp, endangering);
if (!ERR_AwarenessActive() || !faction || !m_Group.GetFaction().IsFactionEnemy(faction)) return;
PerceptionManager manager = GetGame().GetPerceptionManager();
if (!manager) return;
float now = GetGame().GetWorld().GetWorldTime();
float age = manager.GetTime() - timestamp;
if (age < 0 || age > 30) return;
m_ERRAwareness.Hear(m_Group, worldPos, now - age * 1000, now);
}
}
@@ -19,14 +19,14 @@ class ERR_HQLocalHandoff
}
// Only a genuinely newer original sighting may be attempted. Old watermarks
// can expire because the observation capture itself admits at most 500ms age.
// outlive the 30-second local evidence cache. Visual capture still requires 500ms freshness.
class ERR_HQObservationHistory
{
protected ref array<IEntity> m_Targets = {};
protected ref array<float> m_Times = {};
bool CanQueue(IEntity target, float observedAt, float now)
{
if (!target || !ERR_ForceRules.Finite(now) || !ERR_ForceRules.Finite(observedAt) || observedAt < 0 || now < observedAt || now - observedAt > 500) return false;
if (!target || !ERR_ForceRules.Finite(now) || !ERR_ForceRules.Finite(observedAt) || observedAt < 0 || now < observedAt || now - observedAt > 30000) return false;
for (int i = m_Targets.Count() - 1; i >= 0; i--)
{
if (!m_Targets[i] || now - m_Times[i] > 120000) { m_Targets.Remove(i); m_Times.Remove(i); }
@@ -4,7 +4,8 @@ enum ERR_HQMemoryKind
{
SIGHTING,
DISTRESS,
BODY
BODY,
GUNFIRE
}
class ERR_HQMemoryRules
@@ -22,6 +23,7 @@ class ERR_HQMemoryRules
}
static int Kind(ERR_HQReport report)
{
if (report.TravelMode == "heard gunfire") return ERR_HQMemoryKind.GUNFIRE;
if (report.Distress) return ERR_HQMemoryKind.DISTRESS;
if (report.TravelMode == "body" || report.TravelMode == "dead body") return ERR_HQMemoryKind.BODY;
return ERR_HQMemoryKind.SIGHTING;
@@ -33,18 +35,21 @@ class ERR_HQMemoryRules
}
static float BaseConfidence(int kind)
{
if (kind == ERR_HQMemoryKind.GUNFIRE) return 0.4;
if (kind == ERR_HQMemoryKind.DISTRESS) return 0.3;
if (kind == ERR_HQMemoryKind.BODY) return 0.2;
return 1;
}
static float HalfLife(int kind)
{
if (kind == ERR_HQMemoryKind.GUNFIRE) return 60;
if (kind == ERR_HQMemoryKind.DISTRESS) return 180;
if (kind == ERR_HQMemoryKind.BODY) return 300;
return 600;
}
static float RadiusFloor(int kind, string travel)
{
if (kind == ERR_HQMemoryKind.GUNFIRE) return 150;
if (kind == ERR_HQMemoryKind.DISTRESS) return 150;
if (kind == ERR_HQMemoryKind.BODY) return 75;
if (travel == "aircraft") return 120;
@@ -53,6 +58,7 @@ class ERR_HQMemoryRules
}
static float RadiusGrowth(int kind, string travel)
{
if (kind == ERR_HQMemoryKind.GUNFIRE) return 2;
if (kind == ERR_HQMemoryKind.DISTRESS) return 2;
if (kind == ERR_HQMemoryKind.BODY) return 1.2;
if (travel == "aircraft") return 8;
@@ -179,6 +185,12 @@ class ERR_HQMemoryTrack
belief.Confidence = ConfidenceAt(now);
belief.Radius = RadiusAt(now);
belief.PredictionSource = "delivered sighting; motion unknown";
if (Kind == ERR_HQMemoryKind.GUNFIRE)
{
belief.Target = null;
belief.PredictionSource = "heard gunfire; approximate search area, identity and motion unknown";
return belief;
}
if (Kind == ERR_HQMemoryKind.DISTRESS)
{
belief.Target = null;
@@ -191,7 +203,22 @@ class ERR_HQMemoryTrack
return belief;
}
if (Count() < 2) return belief;
ERR_HQReport previous = m_Samples[Count() - 2];
// Several observers can report almost simultaneously. Such a sample must
// not erase a useful motion baseline already present in bounded history.
// Stop at a mode change or the nearest sufficiently spaced observation;
// never search past an implausible pair for a more convenient estimate.
ERR_HQReport previous;
for (int i = Count() - 2; i >= 0; i--)
{
previous = m_Samples[i];
if (previous.TravelMode != last.TravelMode || last.ObservedAt - previous.ObservedAt >= 2000) break;
previous = null;
}
if (!previous)
{
belief.PredictionSource = "delivered sighting; motion samples too close together";
return belief;
}
float seconds = (last.ObservedAt - previous.ObservedAt) / 1000;
float distance = vector.DistanceXZ(previous.Position, last.Position);
if (!ERR_HQMemoryRules.MotionCredible(seconds, distance, previous.TravelMode, last.TravelMode))
@@ -265,7 +292,7 @@ class ERR_HQMemory
if (m_Seen.Count() >= 4096) return Refuse("delivered-report replay guard full");
int kind = ERR_HQMemoryRules.Kind(report);
IEntity key = report.Target;
if (kind == ERR_HQMemoryKind.DISTRESS) key = report.Source;
if (kind == ERR_HQMemoryKind.DISTRESS || kind == ERR_HQMemoryKind.GUNFIRE) key = report.Source;
if (!key) return Refuse("report has no observation identity");
int index = Find(key, kind);
if (index >= 0 && report.ObservedAt <= m_Tracks[index].Latest().ObservedAt)
@@ -324,7 +351,8 @@ class ERR_HQMemory
for (int i = 0; i < m_Tracks.Count(); i++)
{
ERR_HQMemoryTrack track = m_Tracks[i];
if (track.BeliefId != beliefId || track.Kind != ERR_HQMemoryKind.SIGHTING) continue;
if (track.BeliefId != beliefId) continue;
if (track.Kind != ERR_HQMemoryKind.SIGHTING && track.Kind != ERR_HQMemoryKind.GUNFIRE) continue;
if (track.BeliefRevision != revision || track.NegativeRevision == revision) return false;
track.SearchConfidence = 0.65;
track.NegativeRevision = revision;
@@ -20,11 +20,13 @@ class ERR_HQRadio
array<IEntity> items = {};
inventory.GetItems(items);
int result;
ERR_HQ hq = ERR_HQ.Get();
if (hq && hq.Hunters()) result = hq.Hunters().VehicleRadioCapability(character);
foreach (IEntity item : items)
{
if (!item || !item.FindComponent(BaseRadioComponent))
continue;
result = 1;
result = Math.Max(result, 1);
SCR_RadioComponent gadget = SCR_RadioComponent.Cast(item.FindComponent(SCR_RadioComponent));
if (gadget && gadget.ERR_IsHQManpack())
return 2;
@@ -105,6 +107,8 @@ class ERR_HQReport
string Description(float now, bool brief = false)
{
if (TravelMode == "heard gunfire")
return string.Format("Gunfire heard near X=%1 Z=%2; approximate area, enemy identity/count/equipment unknown (%3s ago)", Math.Round(Position[0]), Math.Round(Position[2]), Math.Round(Math.Max(0, now - ObservedAt) / 1000));
if (Distress)
return string.Format("Distress at friendly X=%1 Z=%2; enemy details unknown (%3s ago)", Math.Round(Position[0]), Math.Round(Position[2]), Math.Round(Math.Max(0, now - ObservedAt) / 1000));
string count = "count unknown";
@@ -0,0 +1,19 @@
// Local combat owns reactions. HQ uses delivered knowledge for travel only.
class ERR_HunterCombat
{
static bool Recent(int category, float age)
{
return age >= 0 && age <= 15 && (category == EAITargetInfoCategory.IDENTIFIED || category == EAITargetInfoCategory.DETECTED);
}
static bool Active(SCR_AIGroup group)
{
if (!group) return false;
SCR_AIGroupUtilityComponent utility = group.GetGroupUtilityComponent();
PerceptionManager perception = GetGame().GetPerceptionManager();
if (!utility || !utility.m_Perception || !perception) return false;
float now = perception.GetTime();
foreach (SCR_AITargetInfo target : utility.m_Perception.m_aTargets)
if (target && Recent(target.m_eCategory, now - target.m_fTimestamp)) return true;
return false;
}
}
@@ -1,3 +1,17 @@
// Complete only a task still owned by a live native utility. Dead actors and
// teardown can clear m_AIInfo before mission services release their references.
class ERR_VehicleBehaviorCleanup
{
static void Complete(SCR_AIVehicleBehavior behavior, IEntity actor)
{
if (!behavior || !actor) return;
SCR_ChimeraAIAgent agent = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(actor));
if (!agent || !agent.m_UtilityComponent || !agent.m_UtilityComponent.m_AIInfo) return;
array<ref AIActionBase> actions = {}; agent.m_UtilityComponent.GetActions(actions);
if (actions.Contains(behavior)) behavior.Complete();
}
}
// Preserve original hunter actors. Reboarding uses native AI tasks, never new
// crew, vehicle teleports, health restoration, or ammunition replacement.
class ERR_HunterCrewRecovery
@@ -13,7 +27,7 @@ class ERR_HunterCrewRecovery
void Stop()
{
m_Stopped = true;
if (m_Boarding) m_Boarding.Complete();
ERR_VehicleBehaviorCleanup.Complete(m_Boarding, m_BoardingActor);
m_Boarding = null; m_BoardingActor = null;
}
bool Lost(ERR_GameMode mode)
@@ -45,9 +59,15 @@ class ERR_HunterCrewRecovery
SCR_AIGroupUtilityComponent groupUtility = m_Unit.Group.GetGroupUtilityComponent();
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(m_Unit.Entity.FindComponent(SCR_AIVehicleUsageComponent));
if (groupUtility && groupUtility.m_VehicleMgr && usage) groupUtility.m_VehicleMgr.TryAddVehicle(usage);
if (m_Unit.CombatHeld || ERR_HunterCombat.Active(m_Unit.Group))
{
ERR_VehicleBehaviorCleanup.Complete(m_Boarding, m_BoardingActor);
m_Boarding = null; m_BoardingActor = null;
Note("local combat active; driver reboarding yields to native combat"); return;
}
if (m_Unit.CrewValid(mode))
{
if (m_Boarding) m_Boarding.Complete();
ERR_VehicleBehaviorCleanup.Complete(m_Boarding, m_BoardingActor);
m_Boarding = null; m_BoardingActor = null;
Note("driver seated and conscious; patrol can continue"); return;
}
@@ -68,7 +88,7 @@ class ERR_HunterCrewRecovery
if (m_Boarding && m_BoardingActor == m_Unit.Driver && actions.Contains(m_Boarding)) return;
if (now < m_NextAttempt) return;
m_NextAttempt = now + 10000;
if (m_Boarding) m_Boarding.Complete();
ERR_VehicleBehaviorCleanup.Complete(m_Boarding, m_BoardingActor);
m_BoardingActor = m_Unit.Driver;
IEntity aboard = CompartmentAccessComponent.GetVehicleIn(m_Unit.Driver);
if (aboard)
@@ -1,5 +1,7 @@
class ERR_HunterVehicle : ERR_ConvoyVehicle
{
bool RadioRequested;
ref ERR_EnemyEquipmentCallback RadioOperation;
string Source;
float Deadline;
float NextOrder;
@@ -8,7 +10,12 @@ class ERR_HunterVehicle : ERR_ConvoyVehicle
int SourceMessage;
float LastContact = -1;
float NextContactOrder;
vector ContactPosition;
vector DeliveredContactPosition;
bool DeliveredContact;
ref ERR_HunterCrewRecovery Recovery;
bool CombatHeld;
float CombatQuietUntil;
float NextMotionLog;
float ProgressAt;
vector ProgressPosition;
@@ -54,6 +61,43 @@ class ERR_HunterService : ERR_ConvoyService
foreach (ERR_HunterVehicle unit : m_Hunters) if (unit.Group == group) return true;
return false;
}
// A finite physical radio in the vehicle avoids replacing a medic's backpack
// or depending on a modded vest accepting a vanilla manpack. Only the original
// living crew currently inside their uncaptured vehicle can use this link.
int VehicleRadioCapability(IEntity actor)
{
if (!actor || !m_Mode || !m_Mode.Active() || m_Stopped) return 0;
foreach (ERR_HunterVehicle unit : m_Hunters)
{
if (unit.Released || !unit.Members.Contains(actor) || !unit.Entity || !ERR_Actors.Alive(unit.Entity)) continue;
if (CompartmentAccessComponent.GetVehicleIn(actor) != unit.Entity || !unit.CrewValid(m_Mode) || ERR_ConvoyRules.Captured(unit.Entity, unit.Group)) continue;
InventoryStorageManagerComponent storage = InventoryStorageManagerComponent.Cast(unit.Entity.FindComponent(InventoryStorageManagerComponent));
if (!storage) continue;
array<IEntity> items = {}; storage.GetItems(items);
foreach (IEntity item : items)
{
if (!item || !item.FindComponent(BaseRadioComponent)) continue;
SCR_RadioComponent radio = SCR_RadioComponent.Cast(item.FindComponent(SCR_RadioComponent));
if (radio && radio.ERR_IsHQManpack()) return 2;
}
}
return 0;
}
protected bool PrepareVehicleRadio(ERR_HunterVehicle unit)
{
if (!unit.RadioRequested)
{
unit.RadioRequested = true;
unit.RadioOperation = new ERR_EnemyEquipmentCallback();
InventoryStorageManagerComponent storage = InventoryStorageManagerComponent.Cast(unit.Entity.FindComponent(InventoryStorageManagerComponent));
if (!storage || !storage.TrySpawnPrefabToStorage(ERR_EnemyRadio.MANPACK, null, -1, EStoragePurpose.PURPOSE_DEPOSIT, unit.RadioOperation))
{
ERR_HQ.Event("HUNTER RADIO FAILED", unit.Source + ": vehicle refused its one physical HQ radio; no crew backpack is removed");
return false;
}
}
return unit.RadioOperation && unit.RadioOperation.Finished && unit.RadioOperation.Succeeded && VehicleRadioCapability(unit.Driver) == 2;
}
int Deployed() { return m_Hunters.Count(); }
int ActiveHunters()
{
@@ -93,8 +137,51 @@ class ERR_HunterService : ERR_ConvoyService
ERR_HQ.Event("HUNTER WAIT", "deployment deferred: friendly road obstructed, occupied, budget full or players within 400m");
return false;
}
// Hunter UAZs carry a full response team, unlike the two-person convoy escort.
override protected bool FillCrew(ERR_ConvoyVehicle unit)
{
ResourceName soldier = ERR_EnemyMedicalCatalog.Crew(m_Mode, false);
ResourceName medic = ERR_EnemyMedicalCatalog.Crew(m_Mode, true);
if (!ERR_EnemyLoadoutCatalog.Available(soldier) || !ERR_EnemyLoadoutCatalog.Available(medic))
{ ERR_HQ.Event("HUNTER FAILED", "selected faction has no usable soldier/medic profile"); return false; }
array<BaseCompartmentSlot> all = {}, cargo = {}, turrets = {}, drivers = {};
if (!ERR_ConvoyRules.CollectSeats(unit.Entity, all)) return false;
foreach (BaseCompartmentSlot seat : all)
{
if (!seat.IsCompartmentAccessible() || seat.GetOccupant()) continue;
if (seat.GetType() == ECompartmentType.PILOT) drivers.Insert(seat);
else if (seat.GetType() == ECompartmentType.TURRET) turrets.Insert(seat);
else if (seat.GetType() == ECompartmentType.CARGO) cargo.Insert(seat);
}
if (drivers.Count() != 1 || turrets.IsEmpty() || cargo.IsEmpty() || all.Count() > 12) return false;
unit.Seats.Insert(drivers[0]);
foreach (BaseCompartmentSlot turret : turrets) unit.Seats.Insert(turret);
int medicIndex = unit.Seats.Count();
foreach (BaseCompartmentSlot passenger : cargo) unit.Seats.Insert(passenger);
AIGroup nativeGroup = unit.Group;
for (int i = 0; i < unit.Seats.Count(); i++)
{
ResourceName selected = soldier;
if (i == medicIndex) selected = medic;
IEntity actor = unit.Seats[i].SpawnCharacterInCompartment(selected, nativeGroup, ERR_ConvoyResources.GroupPrefab());
if (actor) { unit.Members.Insert(actor); m_Mode.Own(actor); }
if (!actor || nativeGroup != unit.Group) return false;
}
unit.Driver = unit.Members[0];
ERR_HQ.Event("HUNTER TEAM", string.Format("crew=%1 including one medic; soldier=%2 medic=%3", unit.Members.Count(), soldier, medic));
return true;
}
protected bool SpawnHunter(ERR_HQ hq, float now, vector position, vector angles, string source)
{
// Missing faction data is a deferred deployment, not a lost physical team.
ResourceName soldier = ERR_EnemyMedicalCatalog.Crew(m_Mode, false);
ResourceName medic = ERR_EnemyMedicalCatalog.Crew(m_Mode, true);
if (!ERR_EnemyLoadoutCatalog.Available(soldier) || !ERR_EnemyLoadoutCatalog.Available(medic))
{
m_NextSpawn = now + 30000;
ERR_HQ.Event("HUNTER WAIT", string.Format("faction=%1 missing response crew; soldier=%2 medic=%3; no vehicle spawned or allowance consumed; configure ResponseSoldier/ResponseMedic", m_Mode.EnemyFaction().GetFactionKey(), soldier, medic));
return false;
}
ERR_HunterVehicle unit = new ERR_HunterVehicle();
unit.Source = "aggression-uaz-" + (m_Hunters.Count() + 1).ToString();
unit.Deadline = now + 120000; m_Hunters.Insert(unit);
@@ -115,20 +202,22 @@ class ERR_HunterService : ERR_ConvoyService
}
protected bool AdmitHunter(ERR_HunterVehicle unit, ERR_HQ hq, float now)
{
if (!unit.CrewValid(m_Mode)) return false;
if (!unit.CrewValid(m_Mode) || !PrepareVehicleRadio(unit)) return false;
array<AIAgent> 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;
for (int i = 0; i < unit.Seats.Count(); i++) if (unit.Seats[i].GetOccupant() != unit.Members[i]) return false;
utility.SetCombatMode(EAIGroupCombatMode.FIRE_AT_WILL);
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");
ERR_HQ.Event("HUNTER READY", unit.Source + " full response crew and medic seated; physical vehicle HQ radio stocked; fire at will; radio registration active");
return true;
}
protected void QueueDestination(ERR_HunterVehicle unit, ERR_HQ hq, float now)
@@ -148,14 +237,22 @@ class ERR_HunterService : ERR_ConvoyService
unit.NextOrder = now + 30000;
vector destination, angles;
string reason = "road patrol between friendly objectives";
unit.SourceMessage = 0;
if (best)
{
// Retain the driver's route for repeated reports from the same area.
// Re-projecting every sighting can alternate between nearby roads and
// repeatedly restart native navigation even for a stationary target.
if (unit.Waypoint && unit.DeliveredContact && vector.DistanceXZ(best.PredictedPosition, unit.DeliveredContactPosition) < 75)
{
unit.LastContact = best.ObservedAt; unit.NextContactOrder = now + 5000;
return;
}
if (!ERR_SupplyConvoySystem.ApproachDirected(best.PredictedPosition, destination, angles, 250)) return;
unit.SourceMessage = best.MessageId; reason = "intercept last delivered sighting; confidence=" + best.Confidence.ToString();
}
else
{
unit.SourceMessage = 0;
if (unit.Waypoint && !unit.Waypoint.IsWithinCompletionRadius(unit.Group)) return;
bool found;
for (int i = 0; i < m_Mode.Sites().Count(); i++)
@@ -171,9 +268,26 @@ class ERR_HunterService : ERR_ConvoyService
if (best)
{
unit.NextContactOrder = now + 5000;
if (unit.Downlink) unit.LastContact = best.ObservedAt;
if (unit.Downlink) { unit.LastContact = best.ObservedAt; unit.ContactPosition = best.PredictedPosition; }
}
}
protected void UpdateCombat(ERR_HunterVehicle unit, float now)
{
if (unit.Registered && ERR_HunterCombat.Active(unit.Group))
{
unit.CombatQuietUntil = now + 15000;
if (!unit.CombatHeld)
{
unit.CombatHeld = true; unit.ClearOrder(); unit.Downlink = null;
ERR_HQ.Event("HUNTER COMBAT", unit.Source + ": local contact; travel and forced reboarding suspended, native combat takes priority");
}
}
if (unit.CombatHeld && now >= unit.CombatQuietUntil)
{
unit.CombatHeld = false; unit.NextOrder = 0;
ERR_HQ.Event("HUNTER COMBAT", unit.Source + ": contact quiet for 15 seconds; HQ travel may resume");
}
}
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;
@@ -208,7 +322,9 @@ class ERR_HunterService : ERR_ConvoyService
string loss = unit.Recovery.Reason;
unit.Release(m_Mode); ERR_HQ.Event("HUNTER RELEASE", unit.Source + ": " + loss + "; surviving actors and vehicle retained"); continue;
}
UpdateCombat(unit, now);
if (unit.Recovery) unit.Recovery.Update(m_Mode, now);
if (unit.CombatHeld) continue;
if (!unit.Registered)
{
if (AdmitHunter(unit, hq, now)) continue;
@@ -227,6 +343,13 @@ class ERR_HunterService : ERR_ConvoyService
{
if (delivered > 0)
{
unit.DeliveredContact = unit.SourceMessage > 0;
unit.DeliveredContactPosition = unit.ContactPosition;
if (unit.Waypoint && vector.DistanceXZ(unit.Waypoint.GetOrigin(), unit.Destination) < 50)
{
ERR_HQ.Event("HUNTER ORDER RETAINED", unit.Source + ": updated report confirms current destination; native route preserved", unit.SourceMessage);
unit.Downlink = null; continue;
}
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); }
@@ -243,8 +366,9 @@ class ERR_HunterService : ERR_ConvoyService
vector position = unit.Entity.GetOrigin();
if (unit.ProgressAt == 0 || vector.DistanceXZ(position, unit.ProgressPosition) >= 15)
{ unit.ProgressAt = now; unit.ProgressPosition = position; unit.Repaths = 0; }
SCR_AIGroupUtilityComponent utility = unit.Group.GetGroupUtilityComponent();
bool combat = utility && utility.m_Perception && !utility.m_Perception.m_aTargets.IsEmpty();
// Old entries remain in native perception after contact ends. They must
// not suppress stuck-route recovery indefinitely.
bool combat = ERR_HunterCombat.Active(unit.Group);
bool travelling = unit.Waypoint && !unit.Waypoint.IsWithinCompletionRadius(unit.Group);
if (!travelling || combat) unit.ProgressAt = now;
if (now >= unit.NextMotionLog)
@@ -0,0 +1,51 @@
// Suspend only the HQ-owned mobile search. Defensive/home waypoints, perception
// and individual native combat actions remain owned by their existing systems.
class ERR_InfantryCombat
{
bool Held;
float QuietUntil;
protected AIWaypoint m_PausedWaypoint;
protected ref ERR_HQTask m_PausedTask;
static bool Mobile(ERR_HQTask task)
{
return task && !task.ReleaseOnly && !task.RoadIntercept && task.SecuritySite.IsEmpty();
}
// Contact is supplied from native group perception by HQ, never player positions.
// Return 1 on contact, 2 when search resumes, 3 when combat ends without resuming.
int Update(ERR_HQUnit unit, bool eligible, bool contact, float now)
{
if (!eligible)
{
// A logistics reservation, player takeover or vehicle entry must not cause
// us to insert a waypoint. Retain identity until normal HQ cleanup or return.
return 0;
}
if (contact)
{
QuietUntil = now + 15000;
if (Held) return 0;
Held = true;
if (unit.Assignment && unit.Waypoint && Mobile(unit.Task))
{
m_PausedWaypoint = unit.Waypoint; m_PausedTask = unit.Task;
unit.Group.RemoveWaypoint(m_PausedWaypoint);
unit.Utility.SetCombatMode(EAIGroupCombatMode.FIRE_AT_WILL);
}
return 1;
}
if (!Held || now < QuietUntil) return 0;
Held = false;
int result = 3;
// An expired/released/replaced task cannot be resurrected after a fight.
if (m_PausedWaypoint && unit.Waypoint == m_PausedWaypoint && unit.Task == m_PausedTask
&& unit.Assignment && Mobile(unit.Task) && now < unit.Task.ExpiresAt)
{
unit.Group.AddWaypointAt(m_PausedWaypoint, 0);
result = 2;
}
m_PausedWaypoint = null; m_PausedTask = null;
return result;
}
bool Paused() { return m_PausedWaypoint != null; }
}
@@ -259,6 +259,15 @@ modded class SCR_MortarMuzzleComponent
// re-aim). Each salvo shell needs its own spread point and fresh authorization.
modded class SCR_AIStaticArtilleryBehavior
{
void SCR_AIStaticArtilleryBehavior(SCR_AIUtilityComponent utility, SCR_AIActivityBase groupActivity, IEntity artilleryEntity, vector targetPos, SCR_EAIArtilleryAmmoType ammoType, float priorityLevel = PRIORITY_LEVEL_NORMAL)
{
ERR_MortarSystem system = ERR_MortarSystem.Get();
// Only mission-owned AI batteries use the rapid loop. Its zero fixed
// delays still wait on our physical projectile and ammunition gates.
if (system && system.FindGun(artilleryEntity))
m_sBehaviorTree = "{D0BDF32990D2C687}AI/BehaviorTrees/ERR/MortarRapidFire.bt";
}
ERR_MortarBattery ERR_GetMortarBattery()
{
ERR_MortarSystem system = ERR_MortarSystem.Get();
@@ -301,6 +310,10 @@ modded class SCR_AIStaticArtilleryBehavior_ShootingLogic
ENodeResult result = super.EOnTaskSimulate(owner, dt);
if (battery && result == ENodeResult.SUCCESS)
{
// Super counts the confirmed last shot and emits native completion.
// Hold here until HQ observes that event; a zero-delay repeater must
// not begin a ninth aim and turn successful completion into rejection.
if (battery.OrderShots >= ERR_MortarSalvo.ROUNDS) return ENodeResult.RUNNING;
SetVariableOut(PORT_AIMING_NEEDED, true);
}
return result;
@@ -28,7 +28,7 @@ class ERR_MortarStation
{
if (m_Stopped) return;
m_Stopped = true;
if (m_Mount) m_Mount.Complete();
ERR_VehicleBehaviorCleanup.Complete(m_Mount, m_Gunner);
m_Mount = null;
foreach (SCR_CharacterControllerComponent controller : m_Controllers)
if (controller) controller.ERR_ClearMortarStation(this);
@@ -65,7 +65,7 @@ class ERR_MortarStation
}
if (!Holds(m_Gunner) || !ERR_Actors.CanFight(m_Gunner))
{
if (m_Mount) m_Mount.Complete();
ERR_VehicleBehaviorCleanup.Complete(m_Mount, m_Gunner);
m_Mount = null; m_Gunner = null;
foreach (AIAgent candidate : agents)
{
@@ -304,7 +304,9 @@ class ERR_MortarSystem
if ((battery.Rejected || battery.RefreshRejected) && !battery.Released)
{
battery.Cancel("native aim or firing authorization rejected");
battery.NextOrder = now + 10000;
// Do not churn another ballistic/crew task for the same unsuitable area
// every twenty seconds. All exact per-shot checks remain authoritative.
battery.NextOrder = now + 60000;
}
battery.Rejected = false;
battery.Update(now);
@@ -327,6 +329,13 @@ class ERR_MortarSystem
if (!battery.CanRequest(report, now)) continue;
float candidate = vector.DistanceXZ(battery.Position, report.Position);
if (candidate < 350 || candidate > 4000 || candidate >= distance || !OutsideSettlements(report.Position)) continue;
vector predicted;
if (!ERR_MortarPrediction.Predict(report, now, 30, predicted)) continue;
if (!Safe(battery, predicted, 30))
{
battery.Trace("HQ HOLD", SafetyReason, "no firing order issued; reported impact=" + predicted.ToString());
continue;
}
selected = battery;
target = report;
distance = candidate;
@@ -1,6 +1,41 @@
#ifdef WORKBENCH
// Arrange a ground-response shortfall, without changing airborne policy,
// contacts, radio delivery, artillery safety or the drop order itself.
modded class ERR_HQ
{
bool ERR_AirFixtureSearchAssigned(SCR_AIGroup group, ERR_AirDropOrder order)
{
if (!group || !order) return false;
foreach (ERR_HQUnit unit : m_Units)
{
if (unit.Group != group) continue;
// Contact can remove the waypoint from the native queue while preserving
// this exact HQ assignment for resumption after combat.
return unit.Task && unit.Task.BeliefId == order.BeliefId && unit.Assignment && unit.Waypoint
&& vector.DistanceXZ(unit.Waypoint.GetOrigin(), order.Target) < 1;
}
return false;
}
void ERR_AirFixtureReserveGround()
{
foreach (ERR_HQUnit unit : m_Units)
if (unit.Group && !unit.ReservedForSupport) ReserveGroup(unit.Group);
}
}
modded class ERR_AirAssaultLoad
{
ERR_AirDropOrder ERR_TestOrder() { return m_Order; }
int ERR_TestSearchingTeams()
{
if (!m_Order) return 0;
int count;
foreach (ERR_AirAssaultTeam team : m_Teams)
{
if (!team.Released || !team.Group) continue;
if (ERR_HQ.Get().ERR_AirFixtureSearchAssigned(team.Group, m_Order)) count++;
}
return count;
}
int ERR_TestGrounded()
{
int count;
@@ -24,7 +59,9 @@ modded class ERR_GameMode
protected float m_ERRAirDeadline;
protected ERR_AirAssaultLoad m_ERRAirLoad;
protected Vehicle m_ERRAirframe;
protected bool m_ERRAirStopped;
protected bool m_ERRAirStopped, m_ERRAirPlayerDamage;
protected IEntity m_ERRAirPlayer;
protected float m_ERRAirNextStatus;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
@@ -36,6 +73,11 @@ modded class ERR_GameMode
}
protected void ERR_AirTestStop() { m_ERRAirStopped = true;
GetGame().GetCallqueue().Remove(ERR_AirTestTick);
if (m_ERRAirPlayer)
{
DamageManagerComponent damage = DamageManagerComponent.Cast(m_ERRAirPlayer.FindComponent(DamageManagerComponent));
if (damage) damage.EnableDamageHandling(m_ERRAirPlayerDamage);
}
}
protected bool ERR_AirCheck(bool result, string detail)
{
@@ -59,6 +101,9 @@ modded class ERR_GameMode
ERR_Actors.Players(players, true);
if (players.IsEmpty()) return;
if (!UnlockPrison(players[0])) return;
m_ERRAirPlayer = players[0];
DamageManagerComponent playerDamage = DamageManagerComponent.Cast(m_ERRAirPlayer.FindComponent(DamageManagerComponent));
m_ERRAirPlayerDamage = playerDamage.IsDamageHandlingEnabled(); playerDamage.EnableDamageHandling(false);
if (hq.SupplyConvoys()) hq.SupplyConvoys().Stop();
hq.Recon().ERR_TestDeparture(now + 3000);
m_ERRAirStage = 1;
@@ -74,20 +119,26 @@ modded class ERR_GameMode
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;
// Controlled visible runway encounter; no fabricated reports, policy
// overrides or direct Begin call. Native perception and radio feed HQ.
ChimeraCharacter encounter = ChimeraCharacter.Cast(m_ERRAirPlayer);
vector transform[4]; encounter.GetWorldTransform(transform);
transform[3] = ERR_Actors.Ground(flight.Home() + Vector(0, 0, 400)); encounter.Teleport(transform);
Print("ERR AIR FIXTURE: ground squads reserved for other duties; native sighting, radio and HQ airborne decision remain unchanged");
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;
hq.ERR_AirFixtureReserveGround();
if (now >= m_ERRAirNextStatus)
{
m_ERRAirNextStatus = now + 15000;
Print("ERR AIR HQ WAIT: " + hq.AirAssault().Status());
}
if (!m_ERRAirLoad.Used()) return;
ERR_AirDropOrder order = m_ERRAirLoad.ERR_TestOrder();
if (!ERR_AirCheck(order && order.BeliefId > 0 && order.MessageId > 0 && order.Report && order.Report.Target == m_ERRAirPlayer,
"automatic HQ policy and physical radio downlink authorized drop from native player sightings")) return;
m_ERRAirStage = 3;
return;
}
@@ -95,8 +146,9 @@ modded class ERR_GameMode
{
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;
if (!ERR_AirCheck(m_ERRAirLoad.ERR_TestSearchingTeams() == 2, "both landed squads received native search waypoints for the carried HQ target")) return;
vector center;
if (!ERR_AirCheck(m_ERRAirLoad.Cover(now, center) && vector.DistanceXZ(center, flight.Home()) < 1, "post-drop cover active")) return;
if (!ERR_AirCheck(m_ERRAirLoad.Cover(now, center) && vector.DistanceXZ(center, m_ERRAirLoad.ERR_TestOrder().Target) < 1, "post-drop cover active")) return;
m_ERRAirLoad.ERR_TestCoverEnd(now);
m_ERRAirStage = 4;
return;
@@ -12,14 +12,35 @@ modded class ERR_AirReconSystem
}
modded class ERR_ReconFlightComponent
{
protected float m_ERRFlightTraceAt;
override void EOnSimulate(IEntity owner, float timeSlice)
{
string fixture;
if (System.GetCLIParam("errSmokeOutcome", fixture) && fixture == "air-recon" && m_Initialized && !m_Logistics)
{
float now = GetGame().GetWorld().GetWorldTime();
Physics physics = owner.GetPhysics();
if (physics && now >= m_ERRFlightTraceAt)
{
m_ERRFlightTraceAt = now + 1000;
PrintFormat("ERR RECON MOTION: t=%1 phase=%2 position=%3 velocity=%4 angular=%5 angles=%6 pilot=%7 alive=%8", now, m_Phase, owner.GetOrigin(), physics.GetVelocity(), physics.GetAngularVelocity(), owner.GetYawPitchRoll(), LivingPilot(), ERR_Actors.Alive(owner));
}
}
super.EOnSimulate(owner, timeSlice);
}
int ERR_TestCrew() { return m_OriginalCrew.Count(); }
bool ERR_TestPilot() { return LivingPilot(); }
float ERR_TestCommandSpeed() { return m_Velocity.Length(); }
bool ERR_TestCloseContact(IEntity actor)
{
return m_Observation && m_Observation.Target == actor && vector.DistanceXZ(m_Aircraft.GetOrigin(), m_Observation.Position) < 180;
}
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")
if (System.GetCLIParam("errSmokeOutcome", fixture) && (fixture == "air-recon" || fixture == "air-assault"))
m_Center = m_Home + Vector(0, 0, 800); // Controlled runway-sector encounter, native perception unchanged.
}
}
@@ -29,7 +50,7 @@ modded class ERR_GameMode
protected float m_ERRReconDeadline, m_ERRReconDue, m_ERRReconWait;
protected Vehicle m_ERRReconFirst;
protected IEntity m_ERRReconPlayer;
protected bool m_ERRReconDamage, m_ERRReconTakeoverOnly;
protected bool m_ERRReconDamage, m_ERRReconTakeoverOnly, m_ERRReconCloseSeen;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
@@ -79,6 +100,7 @@ modded class ERR_GameMode
{
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 (flight.Lost() || flight.Phase() == ERR_ReconPhase.RELEASED) { ERR_ReconCheck(false, "crew preparation: " + flight.Status()); 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();
@@ -100,6 +122,29 @@ modded class ERR_GameMode
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;
// A new close sighting must not add an escape impulse every simulation
// frame. Move only the fixture player; perception/radio/aircraft stay native.
vector heading = flight.Aircraft().GetPhysics().GetVelocity(); heading[1] = 0; heading.Normalize();
ChimeraCharacter closeEncounter = ChimeraCharacter.Cast(m_ERRReconPlayer);
vector closeTransform[4]; closeEncounter.GetWorldTransform(closeTransform);
closeTransform[3] = ERR_Actors.Ground(flight.Aircraft().GetOrigin() + heading * 100);
closeEncounter.Teleport(closeTransform);
m_ERRReconWait = now + 60000; m_ERRReconStage = 11; return;
}
if (m_ERRReconStage == 11)
{
if (!flight || flight.Lost() || flight.Phase() == ERR_ReconPhase.RELEASED) { ERR_ReconCheck(false, "close-contact flight lost: " + system.Status()); return; }
float commanded = flight.ERR_TestCommandSpeed();
float measured = flight.Aircraft().GetPhysics().GetVelocity().Length();
if (commanded > 70 || measured > 80)
{ ERR_ReconCheck(false, string.Format("close-contact overspeed commanded=%1 m/s measured=%2 m/s", commanded, measured)); return; }
if (!m_ERRReconCloseSeen && flight.ERR_TestCloseContact(m_ERRReconPlayer))
{
m_ERRReconCloseSeen = true;
Print("ERR SMOKE PASS: air recon native close sighting exercised the 180-metre standoff controller");
}
if (now < m_ERRReconWait) return;
if (!ERR_ReconCheck(m_ERRReconCloseSeen && ERR_Actors.Alive(flight.Aircraft()) && flight.ERR_TestPilot(), "60 seconds of bounded close-contact flight with original living pilot")) return;
system.Return();
if (!ERR_ReconCheck(flight.Phase() == ERR_ReconPhase.RETURNING, "production return-home request accepted")) return;
m_ERRReconStage = 3; return;
@@ -92,7 +92,10 @@ class ERR_NativeMortarSalvoRules
}
static bool QueueUpdate(bool accepted, bool pending, int shots, float observedAt, float orderedAt, float now)
{
return accepted && !pending && shots < ERR_MortarSalvo.ROUNDS / 2 && now >= observedAt + 20000 && orderedAt >= 0 && now >= orderedAt && now - orderedAt < 90000;
// Keep observing through the sixth round's preparation. Stopping updates at
// half a salvo can expire the last report before the final native reload.
// Leave two rounds for the last real downlink to complete before closure.
return accepted && !pending && shots < ERR_MortarSalvo.ROUNDS - 2 && now >= observedAt + 20000 && orderedAt >= 0 && now >= orderedAt && now - orderedAt < 90000;
}
}
@@ -434,7 +437,10 @@ class ERR_NativeMortarSalvoProbe
{
float observed;
int messageId = hq.ERR_QueueMortarRefreshReport(m_Group, m_OriginalRTO, m_Target, m_Impact, observed);
if (!Check(messageId > 0 && AddObservation(messageId, observed), "next distinct actual-clock synthetic observation queued only after previous acceptance")) return -1;
// Ordinary crew/status traffic can occupy the real uplink. Zero means
// no report was queued; wait within the existing deadline, without
// fabricating an observation or replacing the delivered report.
if (messageId > 0 && !Check(AddObservation(messageId, observed), "next distinct actual-clock synthetic observation queued only after previous acceptance")) return -1;
}
}
if (m_Battery.CommandOpen || m_Battery.Activity) return 0;
@@ -40,12 +40,22 @@ modded class ERR_HQ
}
return false;
}
bool ERR_TestPatrolCombat(SCR_AIGroup group, AIWaypoint search, bool contact, float now, int expected)
{
foreach (ERR_HQUnit unit : m_Units)
{
if (unit.Group != group || unit.Waypoint != search) continue;
return unit.InfantryCombat.Update(unit, unit.Eligible(m_Mode), contact, now) == expected;
}
return false;
}
}
class ERR_NativePatrolHQProbe
{
protected ERR_GameMode m_Mode;
protected ERR_SiteRuntime m_Site;
protected SCR_AIGroup m_Group;
protected AIWaypoint m_Search;
protected ref array<AIWaypoint> m_Baseline = {};
protected int m_Step;
@@ -64,25 +74,35 @@ class ERR_NativePatrolHQProbe
if (m_Step == 0)
{
m_Site = m_Mode.Sites().Find("everon-outpost-1");
if (!Check(m_Site && m_Site.Group && m_Site.PatrolCycle, "existing outpost route available")) return -1;
m_Site.Group.GetWaypoints(m_Baseline);
if (!Check(!m_Baseline.IsEmpty() && hq.ERR_TestPatrolSearch(m_Site.Group, m_Search), "real delivered-order method assigns existing squad")) return -1;
if (!Check(m_Site && m_Site.Responders && m_Site.Responders.Group, "existing mobile outpost responders available")) return -1;
m_Group = m_Site.Responders.Group;
m_Group.GetWaypoints(m_Baseline);
if (!Check(!m_Baseline.IsEmpty() && hq.ERR_TestPatrolSearch(m_Group, m_Search), "real delivered-order method assigns existing squad")) return -1;
m_Step = 1; m_Wait = now + 750; return 0;
}
if (m_Step == 1)
{
array<AIWaypoint> queue = {};
m_Site.Group.GetWaypoints(queue);
if (!Check(m_Search && !queue.IsEmpty() && queue[0] == m_Search && m_Site.Group.GetCurrentWaypoint() == m_Search, "Search preempts native patrol")) return -1;
m_Group.GetWaypoints(queue);
if (!Check(m_Search && !queue.IsEmpty() && queue[0] == m_Search && m_Group.GetCurrentWaypoint() == m_Search, "Search preempts native patrol")) return -1;
foreach (AIWaypoint waypoint : m_Baseline)
if (!Check(queue.Contains(waypoint), "preempted baseline route remains queued")) return -1;
if (!Check(hq.ERR_TestPatrolRelease(m_Site.Group, m_Search), "real HQ release removes only its assignment")) return -1;
if (!Check(hq.ERR_TestPatrolCombat(m_Group, m_Search, true, now, 1), "injected local contact pauses actual HQ search")) return -1;
queue.Clear(); m_Group.GetWaypoints(queue);
if (!Check(!queue.Contains(m_Search), "combat removes HQ search from native queue without deleting it")) return -1;
foreach (AIWaypoint home : m_Baseline)
if (!Check(queue.Contains(home), "combat preserves baseline patrol ownership")) return -1;
if (!Check(hq.ERR_TestPatrolCombat(m_Group, m_Search, false, now + 14999, 0), "quiet grace period prevents immediate marching")) return -1;
if (!Check(hq.ERR_TestPatrolCombat(m_Group, m_Search, false, now + 15000, 2), "quiet period resumes the same unexpired assignment")) return -1;
queue.Clear(); m_Group.GetWaypoints(queue);
if (!Check(!queue.IsEmpty() && queue[0] == m_Search, "resumed search is queued exactly once at the front")) return -1;
if (!Check(hq.ERR_TestPatrolRelease(m_Group, m_Search), "real HQ release removes only its assignment")) return -1;
m_Step = 2; m_Wait = now + 750; return 0;
}
if (m_Step == 2)
{
AIWaypoint current = m_Site.Group.GetCurrentWaypoint();
if (!Check(current && (current == m_Site.PatrolCycle || m_Site.PatrolWaypoints.Contains(current)), "native patrol resumes after Search release")) return -1;
AIWaypoint current = m_Group.GetCurrentWaypoint();
if (!Check(current && m_Baseline.Contains(current), "native home defence returns after Search release")) return -1;
m_Step = 3; return 1;
}
return 1;
@@ -90,7 +110,7 @@ class ERR_NativePatrolHQProbe
void Stop()
{
ERR_HQ hq = ERR_HQ.Get();
if (hq && m_Site && m_Search) hq.ERR_TestPatrolRelease(m_Site.Group, m_Search);
if (hq && m_Site && m_Search) hq.ERR_TestPatrolRelease(m_Group, m_Search);
}
void ~ERR_NativePatrolHQProbe() { Stop(); }
}
@@ -21,9 +21,13 @@ class ERR_ReconFlightRules
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);
// This is a minimum outward speed, not an impulse. The result is stored
// in m_Velocity and reused next frame: adding the full correction on every
// simulation tick accelerated close encounters to hundreds of m/s.
float radial = vector.Dot(velocity, away);
float minimum = Math.Clamp((180 - distance) * 0.5, 0, 25);
if (radial < minimum) velocity = velocity + away * (minimum - radial);
return velocity;
}
}
[ComponentEditorProps(category: "Reintroduced", description: "Airport reconnaissance helicopter")]
@@ -113,22 +117,55 @@ class ERR_ReconFlightComponent : ScriptComponent
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; }
m_Crew.ActivateAI();
// Native compartments/simulation are not ready in the airframe spawn callback.
// Update owns one bounded allocation after initialization, including passengers.
}
protected bool PrepareCrew(float now)
{
if (!m_OriginalCrew.IsEmpty()) return true;
if (now - m_Started < 500) return false;
HelicopterControllerComponent controller = HelicopterControllerComponent.Cast(m_Aircraft.FindComponent(HelicopterControllerComponent));
VehicleHelicopterSimulation simulation;
if (controller) simulation = controller.GetSimulation();
FuelManagerComponent fuel = FuelManagerComponent.Cast(m_Aircraft.FindComponent(FuelManagerComponent));
if (!simulation || !simulation.IsValid() || !fuel || !fuel.HasFuel()) { m_Reason = "waiting for native airframe simulation and fuel"; return false; }
m_Seats.Clear(); Seats(m_Aircraft);
if (m_Seats.Count() < 2 || m_Seats.Count() > 8) { m_Reason = "waiting for pilot/gunner seat layout"; return false; }
foreach (BaseCompartmentSlot candidate : m_Seats)
{
if (candidate.IsOccupied()) { Fail("crew seat occupied before allocation; no replacement created"); return false; }
if (!candidate.IsCompartmentAccessible() || candidate.IsReserved() || candidate.IsGetInLocked())
{ m_Reason = "waiting for accessible crew seat " + candidate.GetCompartmentName(); return false; }
}
ResourceName pilot = "{C31F210ABE078197}Prefabs/ERR/ReconPilotEast.et";
string faction = mode.EnemyFaction().GetFactionKey();
string faction = m_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; }
// Native SpawnCharacterInCompartment boards before assigning the group.
// Its Soviet/US prefab affiliation can therefore reject an ION airframe.
// Assign the selected faction and original group BEFORE native boarding.
vector staging = ERR_Actors.Ground(m_Home + Vector(20 + m_OriginalCrew.Count() * 2, 0, 0));
IEntity initialCrew = ERR_Actors.Spawn(pilot, staging);
if (!initialCrew) { Fail("pilot prefab allocation failed; no retry"); return false; }
m_Mode.Own(initialCrew); m_OriginalCrew.Insert(initialCrew);
AIControlComponent control = AIControlComponent.Cast(initialCrew.FindComponent(AIControlComponent));
CompartmentAccessComponent access = CompartmentAccessComponent.Cast(initialCrew.FindComponent(CompartmentAccessComponent));
if (!control || !access || !ERR_Actors.SetFaction(initialCrew, m_Mode.EnemyFaction()))
{ Fail("crew faction/control unavailable; original actor retained"); return false; }
control.ActivateAI();
m_Crew.AddAgent(control.GetControlAIAgent());
if (!access.GetInVehicle(seat.GetOwner(), seat, true, -1, ECloseDoorAfterActions.INVALID, false))
{ Fail("native crew boarding refused at " + seat.GetCompartmentName() + "; faction=" + faction + "; original actor retained, no retry"); return false; }
}
m_Crew.ActivateAI(); Locks(true);
if (!logistics) m_Assault = ERR_HQ.Get().AirAssault().Load(this, pad, serial);
if (!m_Logistics) m_Assault = ERR_HQ.Get().AirAssault().Load(this, m_Home, m_Serial);
m_Reason = "waiting for original crew seating and radio loadouts";
ERR_HQ.Event("HELICOPTER CREW", string.Format("airframe initialized; allocated %1 original pilot/gunner seats once", m_OriginalCrew.Count()));
return true;
}
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;
@@ -172,11 +209,12 @@ class ERR_ReconFlightComponent : ScriptComponent
}
protected bool FinishCrew(float now)
{
if (now - m_Started > 45000) { Fail("crew preparation timed out: " + m_Reason + "; partial spawn retained"); return false; }
if (!PrepareCrew(now)) return false;
m_Crew.ActivateAI();
array<AIAgent> 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.
@@ -0,0 +1,233 @@
// One travel owner for the whole column. Native AI still steers and avoids
// obstacles; followers rendezvous with the preceding physical vehicle instead
// of independently racing to the same base marker.
class ERR_ConvoyColumnRules
{
static bool WaitForRear(float largestGap, bool waiting)
{
if (waiting) return largestGap > 90;
return largestGap > 160;
}
static bool TooClose(float gap, bool waiting)
{
if (waiting) return gap < 65;
return gap < 40;
}
static float Speed(float gap)
{
if (gap > 110) return 45;
if (gap > 70) return 35;
return 22;
}
}
class ERR_ConvoyDriving
{
static bool Restore(ERR_GameMode mode, ERR_ConvoyVehicle vehicle)
{
if (!mode || !vehicle || vehicle.Released || !vehicle.CrewValid(mode) || !ERR_ConvoyMountedCrew.Retain(vehicle.Driver, vehicle.Entity)) return false;
SCR_AIGroupUtilityComponent utility = vehicle.Group.GetGroupUtilityComponent();
if (!utility || !utility.m_VehicleMgr || utility.IsUsableVehicle(vehicle.Entity)) return false;
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(vehicle.Entity.FindComponent(SCR_AIVehicleUsageComponent));
if (!usage || !usage.CanBePiloted()) return false;
// Native RemoveStuckVehicle removes this association after a failed path.
// Our retained seated crew cannot resume merely by receiving another waypoint.
utility.AddUsableVehicle(usage);
return utility.IsUsableVehicle(vehicle.Entity);
}
}
class ERR_ConvoyColumnVehicle
{
bool Holding;
vector Progress;
float LastProgress = -1;
float NextRetry;
float NextTarget;
float Speed = -1;
int Ahead = -1;
}
class ERR_SupplyConvoyColumn
{
protected ERR_GameMode m_Mode;
protected ERR_SupplyConvoyTask m_Task;
protected ref array<ref ERR_ConvoyColumnVehicle> m_State = {};
protected ref array<int> m_Order = {};
protected int m_Leader = -1;
protected int m_RouteIndex;
protected bool m_RearWait;
protected float m_NextLog;
void ERR_SupplyConvoyColumn(ERR_GameMode mode, ERR_SupplyConvoyTask task)
{
m_Mode = mode; m_Task = task;
for (int i = 0; i < task.Vehicles.Count(); i++)
{ m_State.Insert(new ERR_ConvoyColumnVehicle()); m_Order.Insert(i); }
}
protected void Speed(ERR_ConvoyVehicle vehicle, ERR_ConvoyColumnVehicle state, float speed)
{
if (state.Speed == speed) return;
AICarMovementComponent movement = AICarMovementComponent.Cast(vehicle.Entity.FindComponent(AICarMovementComponent));
if (movement) movement.SetCruiseSpeed(speed);
state.Speed = speed;
}
// Called before tactics take ownership and after every new base leg. A fresh
// progress clock prevents combat, reboarding and supply pauses counting as stuck.
protected float DistanceToTarget(int index, vector target)
{
ERR_ConvoyVehicle vehicle = m_Task.Vehicles[index];
if (vehicle.Released || !vehicle.Entity) return 1e20;
return vector.DistanceXZ(vehicle.Entity.GetOrigin(), target);
}
protected void Reorder()
{
vector target = m_Task.Route.At(Math.Min(m_RouteIndex, m_Task.Route.Count() - 1));
// Sort only at a new leg or after combat, never every tick. Keep the canonical
// vehicle array/roles intact; travel order follows current physical positions.
for (int i = 0; i < m_Order.Count(); i++)
for (int j = i + 1; j < m_Order.Count(); j++)
if (DistanceToTarget(m_Order[j], target) < DistanceToTarget(m_Order[i], target))
{ int swap = m_Order[i]; m_Order[i] = m_Order[j]; m_Order[j] = swap; }
string order;
foreach (int index : m_Order)
if (!m_Task.Vehicles[index].Released) order += (index + 1).ToString() + " ";
ERR_HQ.Event("CONVOY FORMATION", m_Task.Receipt.SourceId + ": travel order by physical approach to destination: " + order);
}
void Reset(float now, bool newLeg = false, bool reform = false)
{
if (newLeg) m_RouteIndex = 0;
if (newLeg || reform) { Reorder(); m_Leader = -1; }
m_RearWait = false;
for (int i = 0; i < m_State.Count(); i++)
{
ERR_ConvoyColumnVehicle state = m_State[i];
state.Speed = -1; // Tactics may have slowed this driver around dismounted infantry.
state.LastProgress = -1; state.Holding = false; state.Ahead = -1; state.NextTarget = now;
ERR_ConvoyVehicle vehicle = m_Task.Vehicles[i];
if (!vehicle.Released && vehicle.Entity) Speed(vehicle, state, 35);
}
}
protected void Hold(ERR_ConvoyVehicle vehicle, ERR_ConvoyColumnVehicle state, float now)
{
vehicle.ClearOrder(); state.Holding = true;
state.Progress = vehicle.Entity.GetOrigin(); state.LastProgress = now; state.NextRetry = now + 45000;
}
protected bool Progress(ERR_ConvoyVehicle vehicle, ERR_ConvoyColumnVehicle state, float now)
{
vector position = vehicle.Entity.GetOrigin();
if ((state.LastProgress < 0 || now >= state.NextRetry) && ERR_ConvoyDriving.Restore(m_Mode, vehicle))
ERR_HQ.Event("CONVOY DRIVER RECOVERY", string.Format("%1 vehicle %2: restored native vehicle registration for its healthy original seated driver after a failed movement task", m_Task.Receipt.SourceId, vehicle.Index + 1));
if (state.LastProgress < 0 || vector.DistanceXZ(position, state.Progress) >= 5)
{
state.Progress = position; state.LastProgress = now; state.NextRetry = now + 45000;
}
if (now - state.LastProgress >= 180000)
{
ERR_HQ.Event("CONVOY STRANDED", string.Format("%1 vehicle %2 at %3: no 5 m movement in three minutes of commanded travel. Released from column; vehicle and crew retained, no replacement", m_Task.Receipt.SourceId, vehicle.Index + 1, position));
vehicle.Release(m_Mode); return false;
}
if (now >= state.NextRetry)
{
vehicle.ClearOrder(); state.NextRetry = now + 45000;
ERR_HQ.Event("CONVOY ROUTE RETRY", string.Format("%1 vehicle %2 at %3: stationary for %4 seconds; asking native driver to plan again", m_Task.Receipt.SourceId, vehicle.Index + 1, position, Math.Round((now - state.LastProgress) / 1000)));
}
return true;
}
bool Tick(float now)
{
int leader = -1, previous = -1; float largestGap; bool supplyHold;
for (int slot = 0; slot < m_Order.Count(); slot++)
{
int i = m_Order[slot];
ERR_ConvoyVehicle scan = m_Task.Vehicles[i];
if (scan.Released || !scan.Entity) continue;
if (leader < 0) leader = i;
if (previous >= 0) largestGap = Math.Max(largestGap, vector.DistanceXZ(scan.Entity.GetOrigin(), m_Task.Vehicles[previous].Entity.GetOrigin()));
if (scan.SupplyHeld) supplyHold = true;
previous = i;
}
if (leader < 0) return false;
if (m_Leader != leader)
{
ERR_ConvoyVehicle promoted = m_Task.Vehicles[leader]; promoted.ClearOrder(); promoted.Arrived = false;
promoted.RouteIndex = m_RouteIndex;
m_Leader = leader;
ERR_HQ.Event("CONVOY LEADER", string.Format("%1: vehicle %2 leads surviving column", m_Task.Receipt.SourceId, leader + 1));
}
bool rearWait = ERR_ConvoyColumnRules.WaitForRear(largestGap, m_RearWait);
if (rearWait != m_RearWait)
ERR_HQ.Event("CONVOY SPACING", string.Format("%1: leader waiting=%2; largest gap between surviving neighbours=%3 m", m_Task.Receipt.SourceId, rearWait, Math.Round(largestGap)));
m_RearWait = rearWait;
previous = -1;
for (int ordinal = 0; ordinal < m_Order.Count(); ordinal++)
{
int index = m_Order[ordinal];
ERR_ConvoyVehicle vehicle = m_Task.Vehicles[index];
if (vehicle.Released || !vehicle.Entity) continue;
ERR_ConvoyColumnVehicle state = m_State[index];
if (vehicle.SupplyHeld || vehicle.Combat.Held)
{
// The external owner controls its own waypoint. Only suspend our watchdog.
state.LastProgress = -1; previous = index; continue;
}
if (previous < 0)
{
// A former leader may have finished this route before fighting elsewhere,
// or its successor may still be short of the endpoint. Rejoin it physically.
if (!vehicle.Arrived && vehicle.RouteIndex >= m_Task.Route.Count() && vector.DistanceXZ(vehicle.Entity.GetOrigin(), m_Task.Route.At(m_Task.Route.Count() - 1)) > 35)
vehicle.RouteIndex = m_Task.Route.Count() - 1;
m_RouteIndex = vehicle.RouteIndex;
if (vehicle.Arrived) { Hold(vehicle, state, now); previous = index; continue; }
if (m_RearWait || supplyHold) { Hold(vehicle, state, now); previous = index; continue; }
state.Holding = false; Speed(vehicle, state, 35);
// Mounted groups can retain their spawn origin: arrival is always physical.
while (vehicle.RouteIndex < m_Task.Route.Count() && vector.DistanceXZ(vehicle.Entity.GetOrigin(), m_Task.Route.At(vehicle.RouteIndex)) <= 35)
{ vehicle.ClearOrder(); vehicle.RouteIndex++; }
m_RouteIndex = vehicle.RouteIndex;
if (vehicle.RouteIndex >= m_Task.Route.Count())
{ vehicle.Arrived = true; Hold(vehicle, state, now); previous = index; continue; }
if (!Progress(vehicle, state, now)) break; // promote once on the next tick
if (!vehicle.Waypoint)
{
// Leave tolerance between native completion and our vehicle-origin check.
// A seated driver's position is not exactly the vehicle origin.
vehicle.Waypoint = ERR_SupplyConvoyTactics.Move(m_Task.Route.At(vehicle.RouteIndex), 15);
if (vehicle.Waypoint) vehicle.Group.AddWaypoint(vehicle.Waypoint);
}
}
else
{
ERR_ConvoyVehicle ahead = m_Task.Vehicles[previous];
float gap = vector.DistanceXZ(vehicle.Entity.GetOrigin(), ahead.Entity.GetOrigin());
vehicle.Arrived = ahead.Arrived && gap <= 75;
if (vehicle.Arrived || ERR_ConvoyColumnRules.TooClose(gap, state.Holding)) Hold(vehicle, state, now);
else
{
state.Holding = false; Speed(vehicle, state, ERR_ConvoyColumnRules.Speed(gap));
if (Progress(vehicle, state, now))
{
// Keep an existing target through small movements. Replanning every tick
// repeatedly cancels native manoeuvres, particularly around junctions.
bool changed = state.Ahead != previous;
if (vehicle.Waypoint && vector.DistanceXZ(vehicle.Waypoint.GetOrigin(), ahead.Entity.GetOrigin()) >= 60 && now >= state.NextTarget) changed = true;
if (vehicle.Waypoint && vector.DistanceXZ(vehicle.Entity.GetOrigin(), vehicle.Waypoint.GetOrigin()) <= 20) changed = true;
if (changed) vehicle.ClearOrder();
if (!vehicle.Waypoint)
{
vehicle.Waypoint = ERR_SupplyConvoyTactics.Move(ahead.Entity.GetOrigin(), 15);
if (vehicle.Waypoint) vehicle.Group.AddWaypoint(vehicle.Waypoint);
state.Ahead = previous; state.NextTarget = now + 8000;
}
}
}
}
if (!vehicle.Released) previous = index;
}
if (now >= m_NextLog)
{
ERR_HQ.Event("CONVOY TRAVEL", string.Format("%1: leg %2, leader vehicle %3, largest neighbour gap %4 m, waiting for rear=%5, supplying infantry=%6", m_Task.Receipt.SourceId, m_Task.Legs, m_Leader + 1, Math.Round(largestGap), m_RearWait, supplyHold));
m_NextLog = now + 60000;
}
return true;
}
}
@@ -90,6 +90,7 @@ class ERR_SupplyConvoyService : ERR_ConvoyService
team.Group.SetPermanentLOD(0); team.Group.ActivateAI();
foreach (AIAgent active : agents) { active.SetPermanentLOD(0); active.ActivateAI(); }
}
supply.Column = new ERR_SupplyConvoyColumn(m_Mode, supply);
supply.Tactics = new ERR_SupplyConvoyTactics(m_Mode, supply);
supply.TravelDeadline = now + 600000;
return true;
@@ -166,16 +167,10 @@ class ERR_SupplyConvoyService : ERR_ConvoyService
}
protected void Circuit(ERR_SupplyConvoyTask task, float now)
{
bool surviving, pending;
if (!task.Column || !task.Column.Tick(now)) { task.Stop(m_Mode, "no surviving drivable convoy vehicles"); return; }
bool 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 (!vehicle.Released && (!vehicle.Arrived || vehicle.SupplyHeld || vehicle.Combat.Held)) pending = true;
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;
@@ -189,6 +184,7 @@ class ERR_SupplyConvoyService : ERR_ConvoyService
if (departure.Released) continue;
departure.ClearOrder(); departure.RouteIndex = 0; departure.Arrived = false;
}
task.Column.Reset(now, true);
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)
@@ -99,7 +99,7 @@ class ERR_SupplyConvoySystem
if (attempt == 0) seed = m_Bases[(m_Attempts + m_Service.Count()) % m_Bases.Count()];
seed = ERR_Actors.Ground(seed);
array<vector> positions, angles; ERR_ForceRoute route;
if (!ERR_ConvoyRoadPlan.Build(seed, positions, angles, route) || !ERR_SupplyConvoyRules.Separated(positions[0], m_Starts, separation)) continue;
if (!ERR_ConvoyRoadPlan.Build(seed, positions, angles, route, m_Starts, separation) || !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; }
@@ -123,7 +123,7 @@ class ERR_SupplyConvoySystem
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");
if (m_Attempts == 120 && m_Service.Count() < m_Target) ERR_HQ.Event("CONVOY STARTUP", string.Format("placement survey finished: %1/%2 convoys deployed; remaining slots could not find a clear separated road formation. Existing crews retained; no replacements", m_Service.Count(), m_Target));
}
string Status()
{
@@ -8,6 +8,8 @@ class ERR_SupplyTeam
bool Registered;
bool Released;
vector OrderedContact;
ref ERR_ConvoyBoardingProgress BoardingProgress;
float BoardingRetryAt;
void Clear()
{
foreach (AIWaypoint waypoint : Orders)
@@ -36,6 +38,17 @@ class ERR_SupplyTeam
array<AIAgent> agents = {}; Group.GetAgents(agents);
foreach (AIAgent agent : agents) if (agent) agent.SetPermanentLOD(-1);
}
int BoardingCount(Vehicle truck, out float remainingDistance)
{
int seated; remainingDistance = 0;
foreach (IEntity member : Members)
{
if (!ERR_Actors.CanFight(member) || ERR_Actors.Player(member) || ERR_Actors.Group(member) != Group) continue;
if (CompartmentAccessComponent.GetVehicleIn(member) == truck) seated++;
else remainingDistance = Math.Max(remainingDistance, vector.DistanceXZ(member.GetOrigin(), truck.GetOrigin()));
}
return seated;
}
bool Living()
{
foreach (IEntity member : Members)
@@ -125,6 +138,7 @@ class ERR_SupplyConvoyTask : ERR_ConvoyTask
{
ref array<ref ERR_SupplyTeam> Teams = {};
ref ERR_SupplyConvoyTactics Tactics;
ref ERR_SupplyConvoyColumn Column;
ref array<vector> Bases = {};
ref array<vector> Starts = {};
int LastBase = -1;
@@ -154,6 +168,8 @@ class ERR_SupplyConvoyTactics
protected vector m_Left;
protected float m_LastContact = -1;
protected float m_NextOrders;
protected vector m_LastOrderContact;
protected bool m_OrdersIssued;
protected float m_BoardDeadline;
protected bool m_TruckWithdrawn;
protected float m_NextDismount;
@@ -302,9 +318,10 @@ class ERR_SupplyConvoyTactics
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);
// Mounted escorts engage using native vehicle combat after reaching cover.
// Defend is an infantry occupation task; deleting it during regroup could
// leave ActivityDefend executing with an already deleted waypoint.
// Do not assign it to a crew whose role requires remaining in the vehicle.
}
protected void Clear()
{
@@ -313,13 +330,18 @@ class ERR_SupplyConvoyTactics
}
protected void Orders(bool dismount, float now)
{
m_NextOrders = now + 30000;
if (m_OrdersIssued && vector.DistanceXZ(m_LastOrderContact, m_Contact) < 40) return;
m_LastOrderContact = m_Contact; m_OrdersIssued = true;
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;
Clear(); m_State = 1; m_Contact = contact; m_TruckWithdrawn = false; m_OrdersIssued = false;
if (m_Task.Column) m_Task.Column.Reset(now);
m_Task.Waiting = false;
vector origin;
foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles)
{
@@ -339,13 +361,62 @@ class ERR_SupplyConvoyTactics
Clear(); m_State = 0;
foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles)
{
vehicle.Combat.Held = false;
vehicle.Combat.Held = false; vehicle.Arrived = false;
// Combat time does not consume the driving deadline.
vehicle.LegDeadline = now + 600000;
}
m_Task.TravelDeadline = now + 600000;
if (m_Task.Column) m_Task.Column.Reset(now, false, true);
ERR_HQ.Event("CONVOY REGROUP", m_Task.Receipt.SourceId + ": surviving convoy resumes; no replacements");
}
protected float InfantryDistance(IEntity vehicle)
{
float distance = 1e20;
if (!vehicle) return distance;
foreach (ERR_SupplyTeam team : m_Task.Teams)
{
if (team.Released) continue;
foreach (IEntity member : team.Members)
{
if (!ERR_Actors.CanFight(member) || CompartmentAccessComponent.GetVehicleIn(member)) continue;
distance = Math.Min(distance, vector.DistanceXZ(member.GetOrigin(), vehicle.GetOrigin()));
}
}
return distance;
}
protected void ProtectInfantry()
{
foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles)
{
if (vehicle.Released || !vehicle.Entity) continue;
AICarMovementComponent movement = AICarMovementComponent.Cast(vehicle.Entity.FindComponent(AICarMovementComponent));
if (!movement) continue;
float speed = 35;
if (InfantryDistance(vehicle.Entity) < 45) speed = 8;
movement.SetCruiseSpeed(speed);
}
}
protected void RefreshBoarding(float now)
{
foreach (ERR_SupplyTeam team : m_Task.Teams)
{
if (team.Released || team.Aboard(m_Task.Vehicles[2].Entity, true)) continue;
if (team.BoardingRetryAt > 0)
{
if (now < team.BoardingRetryAt) continue;
team.BoardingRetryAt = 0;
team.Add(Board(m_Task.Vehicles[2].Entity, true));
continue;
}
if (!team.BoardingProgress) team.BoardingProgress = new ERR_ConvoyBoardingProgress();
float distance; int seated = team.BoardingCount(m_Task.Vehicles[2].Entity, distance);
if (!team.BoardingProgress.Retry(seated, distance, now)) continue;
// Let the old native activity release reservations before asking for seats
// again. Occupants remain seated; no direct compartment move is performed.
team.Clear(); team.BoardingRetryAt = now + 1000;
ERR_HQ.Event("CONVOY BOARDING RETRY", string.Format("%1: team has %2 aboard; farthest remaining soldier %3 m from truck. No approach/seat progress for 30 s; retry %4/3", m_Task.Receipt.SourceId, seated, Math.Round(distance), team.BoardingProgress.Retries));
}
}
bool Tick(float now)
{
if (m_Stopped) return false;
@@ -367,10 +438,10 @@ class ERR_SupplyConvoyTactics
if (found) Report(observed, now, now);
if (m_State == 1)
{
if (!m_TruckWithdrawn && InfantryAboard(false)) { m_TruckWithdrawn = true; Support(2, false); }
if (!m_TruckWithdrawn && InfantryAboard(false) && InfantryDistance(m_Task.Vehicles[2].Entity) >= 25) { 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)
if (!m_TruckWithdrawn && !InfantryAboard(false) && Usable(2) && now >= m_NextDismount && m_DismountAttempts < 6)
{
m_DismountAttempts++; m_NextDismount = now + 10000;
for (int retry = 0; retry < m_Task.Teams.Count(); retry++)
@@ -387,17 +458,23 @@ class ERR_SupplyConvoyTactics
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;
if (!boarding.Released && boarding.Living())
{
boarding.BoardingProgress = new ERR_ConvoyBoardingProgress(); boarding.BoardingRetryAt = 0;
boarding.Add(Board(m_Task.Vehicles[2].Entity, true));
}
m_State = 2; m_BoardDeadline = now + 300000;
ERR_HQ.Event("CONVOY BOARDING", m_Task.Receipt.SourceId + ": both teams return under escort cover");
}
}
if (m_State == 2 && Usable(2) && now < m_BoardDeadline) RefreshBoarding(now);
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);
}
if (m_State != 0) ProtectInfantry();
return m_State != 0;
}
void Stop() { if (m_Stopped) return; m_Stopped = true; Clear(); }
@@ -24,6 +24,12 @@ modded class ERR_HQ
if (!route.Nodes[i] || !route.Nodes[i].Valid(i == route.Nodes.Count() - 1)) return false;
return true;
}
SCR_AIGroup ERR_AwarenessFixtureSource()
{
if (!ERR_NativeHQMemoryProbe.Enabled()) return null;
foreach (ERR_HQUnit unit : m_Units) if (unit.Group && unit.Utility) return unit.Group;
return null;
}
ERR_HQRoute ERR_MemoryFixtureQueue(ERR_HQReport report)
{
if (!ERR_NativeHQMemoryProbe.Enabled()) return null;
@@ -34,10 +40,15 @@ modded class ERR_HQ
ERR_HQRoute route = m_Network.Route(unit.Radio);
if (!ERR_MemoryFixtureHealthy(route)) continue;
report.Source = unit.Group;
route.MessageId = report.MessageId;
route.Payload = "FIXTURE synthetic observed position; " + report.Description(report.ObservedAt);
unit.PendingReport = report.Snapshot(); unit.Uplink = route;
return route;
// Arrange an isolated inbox so natural contacts cannot replace this fixture
// payload before it receives its message ID. Radio delivery remains native.
unit.LocalAwareness.Clear();
unit.LocalAwareness.Remember(report, report.ObservedAt);
unit.NextContact = 0;
ContactReport(unit, GetGame().GetWorld().GetWorldTime());
if (!unit.Uplink || !unit.PendingReport || unit.PendingReport.Target != report.Target) return null;
report.MessageId = unit.PendingReport.MessageId;
return unit.Uplink;
}
return null;
}
@@ -95,7 +106,7 @@ class ERR_NativeHQMemoryProbe
{
string enabled, outcome;
return Replication.IsServer() && System.GetCLIParam("errSmoke", enabled) && enabled == "1"
&& System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "hq-memory";
&& System.GetCLIParam("errSmokeOutcome", outcome) && (outcome == "hq-memory" || outcome == "hq-awareness");
}
void ERR_NativeHQMemoryProbe(ERR_GameMode mode) { m_Mode = mode; }
protected bool Check(bool condition, string description)
@@ -187,6 +198,47 @@ class ERR_NativeHQMemoryProbe
}
return Check(false, "bounded road fixture found no suitable native candidate; no fallback or force mutation");
}
protected bool LocalAwarenessPolicy(ERR_HQ hq, float now)
{
ERR_HQLocalAwareness cache = new ERR_HQLocalAwareness();
ERR_HQObservationHistory history = new ERR_HQObservationHistory();
SCR_AIGroup group = hq.ERR_AwarenessFixtureSource();
if (!Check(group != null, "awareness fixture has a native registered source squad")) return false;
ERR_HQReport visual = new ERR_HQReport();
visual.Target = m_Mode; visual.Source = group; visual.ObservedAt = now;
visual.Position = "123 4 456"; visual.SeenWeapons = "rifle";
cache.Remember(visual, now); visual.Position = "999 0 999";
ERR_HQReport delayed = cache.Peek(now + 2000, history);
if (!Check(delayed && delayed.Position == "123 4 456" && delayed.ObservedAt == now && delayed.SeenWeapons == "rifle",
"brief sighting keeps original position/time/equipment after two seconds")) return false;
history.Queued(delayed.Target, delayed.ObservedAt, now + 2000); cache.Queued(delayed, now + 2000);
if (!Check(!cache.Peek(now + 3000, history), "sent cached sighting cannot replay")) return false;
cache.Remember(visual, now);
if (!Check(!cache.Peek(now + 30001, new ERR_HQObservationHistory()), "local evidence expires after thirty seconds")) return false;
cache.Clear();
SCR_AIGroupUtilityComponent utility = SCR_AIGroupUtilityComponent.Cast(group.FindComponent(SCR_AIGroupUtilityComponent));
// An isolated native perception object exercises the actual callback without
// injecting the fake shooter into the real squad's combat targets.
SCR_AIGroupPerception perception = new SCR_AIGroupPerception(utility, group);
perception.ERR_BindAwareness(cache);
perception.AddOrUpdateGunshot(m_Mode, "123 4 456", m_Mode.PlayerFaction(), GetGame().GetPerceptionManager().GetTime(), false);
ERR_HQReport sound = cache.Peek(GetGame().GetWorld().GetWorldTime(), history);
if (!Check(sound && !sound.Target && sound.Source == group && sound.Position == "75 4 525"
&& sound.TravelMode == "heard gunfire" && sound.SeenWeapons == "unknown" && sound.SeenEscapists == -1,
"native heard-fire callback captures only a coarse area and original time")) return false;
sound.MessageId = 1;
ERR_HQMemory memory = new ERR_HQMemory();
if (!Check(memory.Ingest(sound, GetGame().GetWorld().GetWorldTime()), "heard-fire value report accepted separately from visual identity")) return false;
ERR_HQBelief noise = memory.At(0);
if (!Check(noise.Kind == ERR_HQMemoryKind.GUNFIRE && !noise.Target && !noise.HasVelocity && !noise.Actionable()
&& noise.Confidence <= 0.4 && noise.Radius >= 150, "sound permits uncertain search, not precision targeting or motion inference")) return false;
if (!Check(ERR_HQPursuitRules.Role(noise.Confidence, 0, noise.Radius, noise.HasVelocity, 0) == ERR_HQPursuitRole.SEARCH,
"heard gunfire selects a search role")) return false;
cache.Clear(); perception.ERR_BindAwareness(null);
perception.AddOrUpdateGunshot(m_Mode, "123 4 456", m_Mode.PlayerFaction(), GetGame().GetPerceptionManager().GetTime(), false);
if (!Check(!cache.Peek(GetGame().GetWorld().GetWorldTime(), history), "unbound perception cannot retain mission reports")) return false;
return true;
}
protected bool LocalPolicies()
{
ERR_ForceLedger ledger = new ERR_ForceLedger();
@@ -292,18 +344,27 @@ class ERR_NativeHQMemoryProbe
if (now > m_Deadline) { Check(false, "fixture deadline"); return -1; }
if (m_Step == 0)
{
if (!hq.ERR_ProbeSquadsReady()) return 0;
bool automaticWasChecked = m_AutoChecked;
int automatic = AutomaticConvoy(hq);
if (automatic <= 0) return automatic;
// Startup admission and native crew readiness have their own bounded wait.
// Start the original memory/radio deadline once, after automatic proof.
if (!automaticWasChecked) m_Deadline = now + 120000;
string outcome;
System.GetCLIParam("errSmokeOutcome", outcome);
bool awarenessOnly = outcome == "hq-awareness";
if (!awarenessOnly)
{
if (!hq.ERR_ProbeSquadsReady()) return 0;
bool automaticWasChecked = m_AutoChecked;
int automatic = AutomaticConvoy(hq);
if (automatic <= 0) return automatic;
// Legacy combined convoy/memory qualification retains its original checks.
if (!automaticWasChecked) m_Deadline = now + 120000;
}
if (!m_ValueChecked)
{
if (!Check(hq.ERR_MemoryFixtureRegisteredForces() == 6,
"actual six site groups are registered once in the Core force ledger")) return -1;
if (!LocalPolicies() || !LocalRoadPolicy()) return -1;
if (!awarenessOnly)
{
if (!Check(hq.ERR_MemoryFixtureRegisteredForces() == 6,
"actual six site groups are registered once in the Core force ledger")) return -1;
if (!LocalPolicies() || !LocalRoadPolicy()) return -1;
}
if (!LocalAwarenessPolicy(hq, now)) return -1;
m_ValueChecked = true;
}
m_FirstPosition = m_Mode.PrisonPosition() + "300 0 300";
@@ -19,6 +19,7 @@ modded class ERR_GameMode
protected bool m_ERRTestRadioNetwork;
protected bool m_ERRTestHQResponse;
protected bool m_ERRTestHQMemory;
protected bool m_ERRTestHQAwareness;
protected bool m_ERRTestLocalHandoff;
protected ref ERR_NativeLocalHandoffProbe m_ERRLocalHandoff;
protected ERR_PlayerLife m_ERRLocalHandoffLife;
@@ -74,7 +75,8 @@ modded class ERR_GameMode
m_ERRTestLostHelicopter = System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "lost-helicopter";
m_ERRTestRadioNetwork = outcome == "radio-network";
m_ERRTestHQResponse = outcome == "hq-response";
m_ERRTestHQMemory = outcome == "hq-memory";
m_ERRTestHQAwareness = outcome == "hq-awareness";
m_ERRTestHQMemory = outcome == "hq-memory" || m_ERRTestHQAwareness;
m_ERRTestLocalHandoff = outcome == "hq-local-handoff";
m_ERRTestHQBody = outcome == "hq-body";
m_ERRTestInfantryTransfer = outcome == "infantry-transfer";
@@ -323,6 +325,24 @@ modded class ERR_GameMode
Print("ERR SMOKE COMPLETE: native HQ patrol preemption and physical resumption");
return;
}
if (m_ERRTestHQAwareness)
{
if (!ERR_Check(current == ERR_Phase.DEFEAT && m_ERRSmokeStep == 291, "awareness and radio checks completed before explicit fixture reset")) return;
m_ERRSmokeDone = true;
GetGame().GetCallqueue().Remove(ERR_SmokeTick);
ERR_StopHQPatrol();
Print("ERR SMOKE COMPLETE: captured awareness, native heard-fire callback, physical radio delivery and memory; explicit fixture reset");
return;
}
if (m_ERRTestConvoy)
{
if (!ERR_Check(current == ERR_Phase.DEFEAT && m_ERRSmokeStep == 1061, "convoy checks completed before explicit fixture reset")) return;
m_ERRSmokeDone = true;
GetGame().GetCallqueue().Remove(ERR_SmokeTick);
ERR_StopHQPatrol();
Print("ERR SMOKE COMPLETE: native convoy readiness, cargo and road movement; explicit fixture reset");
return;
}
if (m_ERRTestPlayerDeath)
{
if (!ERR_Check(current == ERR_Phase.DEFEAT && m_ERRSmokeStep == 91, "death exhausts round life and reaches defeat after spectator grace")) return;
@@ -522,6 +542,13 @@ modded class ERR_GameMode
if (m_ERRTestMortarSalvo)
{
DamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
// Keep the isolated firing range clear of automatic UAZ responders.
// Otherwise they pursue the inert test report into its impact area.
if (ERR_HQ.Get().Hunters()) ERR_HQ.Get().Hunters().Stop();
if (!ERR_Check(UnlockPrison(m_ERRSmokePlayer), "mortar fixture unlocks prison to start normal crew AI")) return;
// The player is not this fixture's target. Keep the newly released
// prisoner away from the battery while its inert report is tested.
m_ERRSmokePlayer.SetOrigin(ERR_Actors.Ground("4500 0 11500"));
m_ERRMortarSalvo = new ERR_NativeMortarSalvoProbe(this);
m_ERRSmokeStep = 190;
return;
@@ -586,6 +613,12 @@ modded class ERR_GameMode
if (convoyOutcome == "supply-convoys")
{
if (!ERR_Check(UnlockPrison(m_ERRSmokePlayer), "supply fixture unlocks prison to start normal mission AI")) return;
if (ERR_HQ.Get().Hunters()) ERR_HQ.Get().Hunters().Stop();
m_ERRSmokePlayer.SetOrigin(ERR_Actors.Ground("8800 0 2700"));
PerceivableComponent convoyObserver = PerceivableComponent.Cast(m_ERRSmokePlayer.FindComponent(PerceivableComponent));
if (!ERR_Check(convoyObserver != null, "convoy fixture observer has native perception component")) return;
convoyObserver.SetPerceivedFactionOverride(EnemyFaction());
Print("ERR SUPPLY FIXTURE: observer perceived as convoy-friendly; only the labelled contact injection exercises this fixture's combat cycle");
m_ERRConvoyProbe = new ERR_NativeSupplyConvoyProbe(this);
}
else m_ERRConvoyProbe = new ERR_NativeConvoyProbe(this);
@@ -836,18 +869,20 @@ modded class ERR_GameMode
int result = m_ERRConvoyProbe.Tick(ERR_HQ.Cast(Command()), now);
if (result == 0) return;
if (!ERR_Check(result == 1, "native six-vehicle convoy readiness, cargo and road movement")) return;
m_ERRSmokeStep = 9;
SCR_CharacterDamageManagerComponent damage;
damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent));
damage.EnableDamageHandling(true);
damage.Kill(Instigator.CreateInstigator(m_ERRSmokePlayer));
m_ERRSmokeWait = now + 4000;
m_ERRSmokeStep = 1061;
Transition(ERR_Phase.DEFEAT);
}
else if (m_ERRSmokeStep == 105)
{
int result = m_ERRHQMemoryProbe.Tick(ERR_HQ.Cast(Command()), now);
if (result == 0) return;
if (!ERR_Check(result == 1, "native HQ memory delivery and search lineage")) return;
if (m_ERRTestHQAwareness)
{
m_ERRSmokeStep = 291;
Transition(ERR_Phase.DEFEAT);
return;
}
m_ERRSmokeStep = 9;
SCR_CharacterDamageManagerComponent damage;
damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent));
@@ -52,9 +52,22 @@ class ERR_NativeSupplyConvoyProbe : ERR_NativeConvoyProbe
}
if (m_CombatTask)
{
for (int car = 0; car < m_CombatTask.Vehicles.Count(); car++)
{
ERR_ConvoyVehicle diagnosticCar = m_CombatTask.Vehicles[car];
vector target; if (diagnosticCar.Waypoint) target = diagnosticCar.Waypoint.GetOrigin();
PrintFormat("ERR SUPPLY COLUMN: car=%1 released=%2 arrived=%3 position=%4 target=%5", car + 1, diagnosticCar.Released, diagnosticCar.Arrived, diagnosticCar.Entity.GetOrigin(), target);
if (diagnosticCar.Waypoint) PrintFormat("ERR SUPPLY WAYPOINT: car=%1 distance=%2 nativeCurrent=%3 nativeComplete=%4 routeIndex=%5", car + 1, vector.DistanceXZ(diagnosticCar.Entity.GetOrigin(), target), diagnosticCar.Group.GetCurrentWaypoint() == diagnosticCar.Waypoint, diagnosticCar.Waypoint.IsWithinCompletionRadius(diagnosticCar.Group), diagnosticCar.RouteIndex);
}
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());
SCR_AIGroupUtilityComponent driverGroup = truck.Group.GetGroupUtilityComponent();
SCR_ChimeraAIAgent truckDriver = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(truck.Driver));
string groupAction = "unavailable", driverAction = "unavailable"; bool registeredVehicle;
if (driverGroup) { groupAction = MovementAction(driverGroup.GetCurrentAction()); registeredVehicle = driverGroup.IsUsableVehicle(truck.Entity); }
if (truckDriver && truckDriver.m_UtilityComponent) driverAction = MovementAction(truckDriver.m_UtilityComponent.GetCurrentBehavior());
PrintFormat("ERR SUPPLY DRIVER: registeredVehicle=%1 groupAction=%2 driverAction=%3", registeredVehicle, groupAction, driverAction);
foreach (ERR_SupplyTeam diagnostic : m_CombatTask.Teams)
{
int aboard;
@@ -62,9 +75,13 @@ class ERR_NativeSupplyConvoyProbe : ERR_NativeConvoyProbe
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)
{
if (!remaining) { Print("ERR SUPPLY SEAT: original actor removed"); continue; }
AIControlComponent control = AIControlComponent.Cast(remaining.FindComponent(AIControlComponent));
SCR_ChimeraAIAgent agent = SCR_ChimeraAIAgent.Cast(control.GetControlAIAgent());
SCR_ChimeraAIAgent agent;
if (control) agent = SCR_ChimeraAIAgent.Cast(control.GetControlAIAgent());
CompartmentAccessComponent access = CompartmentAccessComponent.Cast(remaining.FindComponent(CompartmentAccessComponent));
if (!agent || !agent.m_UtilityComponent || !access)
{ PrintFormat("ERR SUPPLY SEAT: original actor has no live AI/compartment utility; alive=%1 fit=%2", ERR_Actors.Alive(remaining), ERR_Actors.CanFight(remaining)); continue; }
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());
}
}
@@ -110,12 +127,13 @@ class ERR_NativeSupplyConvoyProbe : ERR_NativeConvoyProbe
if (original.Starts.Count() != 6) return -1;
foreach (vector start : original.Starts) { m_StartPositions.Insert(start); m_Moved.Insert(false); }
}
m_CombatTask = ERR_SupplyConvoyTask.Cast(hq.Convoys().At(0));
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;
if (!m_AllMoved) 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);
@@ -137,18 +155,29 @@ class ERR_NativeSupplyConvoyProbe : ERR_NativeConvoyProbe
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");
Print("ERR SUPPLY FIXTURE: waiting for physical column arrival, production pause and next base leg; no forced timeout arrival");
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_Phase >= 3) return Departure();
return 0;
}
protected int Departure()
{
if (m_CombatTask.Stopped || m_CombatTask.Vehicles[2].Released || m_CombatTask.Bases.Count() < 2) return -1;
if (m_Phase == 3 && m_CombatTask.Legs > m_TestLegs)
{
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_RouteResumePosition = m_CombatTask.Vehicles[2].Entity.GetOrigin();
m_Phase = 4;
Print("ERR SUPPLY FIXTURE: next leg started; record original truck position now, excluding all earlier regroup movement");
return 0;
}
if (m_Phase == 4 && m_AllMoved) return 1;
if (m_Phase == 4 && vector.DistanceXZ(m_CombatTask.Vehicles[2].Entity.GetOrigin(), m_RouteResumePosition) >= 20)
{
Print("ERR SUPPLY PASS: original truck drives twenty metres AFTER departure on the new native base leg; no replacement convoy");
m_Phase = 5;
}
if (m_Phase == 5 && m_AllMoved) return 1;
return 0;
}
override void Stop() {}
+3
View File
@@ -11,6 +11,9 @@ GameProject {
}
Configurations {
GameProjectConfig PC {
InputManagerSettings InputManagerSettings "{50A48858902D5F64}" {
Default "{A7B19F416E541000}Configs/ERR/ObserverInput.conf"
}
MenuManagerSettings MenuManagerSettings "{50C890C996B0A6FC}" {
MenuConfigs {
"{C747AFB6B750CE9A}Configs/System/chimeraMenus.conf"