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" Parent "rhs"
DefaultEnabled 1 DefaultEnabled 1
NativeFactionKey "RHS_USAF" 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" GuardGroup "{19843E954790DF28}Prefabs/Groups/BLUFOR/RHS_USAF/RHS_USAF_USMC_MEF/Group_USAF_USMC_MEF_FireTeam.et"
} }
ERR_ContentOption { ERR_ContentOption {
@@ -20,6 +22,8 @@ ERR_ContentDefinition {
Parent "rhs" Parent "rhs"
DefaultEnabled 1 DefaultEnabled 1
NativeFactionKey "RHS_AFRF" 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" GuardGroup "{AB905EF799E0A0AE}Prefabs/Groups/OPFOR/RHS_AFRF/MSV/Flora/Group_RHS_RF_MSV_Flora_FireGroup.et"
} }
ERR_ContentOption { ERR_ContentOption {
@@ -28,6 +32,8 @@ ERR_ContentDefinition {
Parent "rhs" Parent "rhs"
DefaultEnabled 1 DefaultEnabled 1
NativeFactionKey "RHS_ION" 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" 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 { SCR_ManualCamera {
ID "634904BCEDA1A285" ID "634904BCEDA1A285"
m_bInputEnabled 1 m_bInputEnabled 1
m_sActionContext "ERR_ObserverContext"
m_bStartAtEntity 1 m_bStartAtEntity 1
m_aComponents { m_aComponents {
ERR_FollowSurvivorCameraComponent "{965C2E8B2339B926}" {} ERR_FollowSurvivorCameraComponent "{965C2E8B2339B926}" {}
@@ -11,6 +11,10 @@ class ERR_ContentOption
string NativeFactionKey; string NativeFactionKey;
[Attribute("", UIWidgets.ResourceNamePicker, "Physical guard group belonging to this faction", "et")] [Attribute("", UIWidgets.ResourceNamePicker, "Physical guard group belonging to this faction", "et")]
ResourceName GuardGroup; 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")] [Attribute("", UIWidgets.ResourceNamePicker, "Optional faction MANPADS override; must spawn loaded and usable. Otherwise FIM-92 is used.", "et")]
ResourceName AntiAirLauncher; ResourceName AntiAirLauncher;
[Attribute("", UIWidgets.EditBox, "Exact resource GUIDs for optional equipment owned by this pack")] [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 (!Active()) return;
if (m_Recovery.DefeatDue(now)) 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); ERR_HQ.Event("MISSION FAILED", m_EndReason);
Transition(ERR_Phase.DEFEAT); Transition(ERR_Phase.DEFEAT);
return; return;
@@ -120,6 +120,24 @@ class ERR_GeneratedSites
} }
return true; 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() ERR_PrepareResult Tick()
{ {
if (m_Result != ERR_PrepareResult.PENDING) return m_Result; if (m_Result != ERR_PrepareResult.PENDING) return m_Result;
@@ -145,9 +163,16 @@ class ERR_GeneratedSites
} }
else if (fallback) else if (fallback)
{ {
m_Error = "No safe terrain or valid authored fallback for " + site.Id; 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; m_Result = ERR_PrepareResult.FAILED; return m_Result;
} }
}
if (System.GetTickCount() - tick >= 8) return ERR_PrepareResult.PENDING; if (System.GetTickCount() - tick >= 8) return ERR_PrepareResult.PENDING;
} }
m_Result = ERR_PrepareResult.READY; return m_Result; m_Result = ERR_PrepareResult.READY; return m_Result;
@@ -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 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 == "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 == "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 == "INTEL COMPROMISE") return "HQ learned that intel is missing: " + Short(message);
if (kind == "OBJECTIVE DEFENCE") return "Objective security: " + Short(message); if (kind == "OBJECTIVE DEFENCE") return "Objective security: " + Short(message);
if (kind == "HELICOPTER LAUNCH") return "Search helicopter launched: " + Short(message); if (kind == "HELICOPTER LAUNCH") return "Search helicopter launched: " + Short(message);
@@ -54,7 +54,8 @@ modded class ERR_GameMode
string enabled; string enabled;
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errBaseSmoke", enabled) || enabled != "1") return; if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errBaseSmoke", enabled) || enabled != "1") return;
string nativeEndCase; 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_NativeObjectiveProbe.BeginRound();
ERR_NativeProgressionIdentityProbe.BeginRound(); ERR_NativeProgressionIdentityProbe.BeginRound();
string outcome; 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(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; 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; return;
} }
if (m_ERRExtractionCase == "objective-ui") 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 ERR_GameMode m_Mode;
protected IEntity m_Casualty; protected IEntity m_Casualty;
protected IEntity m_Healer; protected IEntity m_Healer;
protected IEntity m_Epinephrine;
protected float m_BeforeEpi;
protected IEntity m_Dressing; protected IEntity m_Dressing;
protected IEntity m_Saline; protected IEntity m_Saline;
protected IEntity m_SpareSaline; 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"); 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(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; 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_Wait = now + 1000;
m_Step = 1; m_Step = 1;
return 0; 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"); Check(false, "owned casualty or healer died unexpectedly");
return -1; return -1;
@@ -127,6 +129,7 @@ class ERR_NativeMedicalProbe
m_SalineStorage = SCR_SalineStorageComponent.Cast(m_Casualty.FindComponent(SCR_SalineStorageComponent)); m_SalineStorage = SCR_SalineStorageComponent.Cast(m_Casualty.FindComponent(SCR_SalineStorageComponent));
m_Inventory = SCR_InventoryStorageManagerComponent.Cast(m_Healer.FindComponent(SCR_InventoryStorageManagerComponent)); 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; 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(); 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_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; 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_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; 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_ConsumableItemComponent saline = SCR_ConsumableItemComponent.Cast(m_Saline.FindComponent(SCR_ConsumableItemComponent));
SCR_ConsumableSalineBag effect = SCR_ConsumableSalineBag.Cast(saline.GetConsumableEffect()); SCR_ConsumableSalineBag effect = SCR_ConsumableSalineBag.Cast(saline.GetConsumableEffect());
SCR_EConsumableFailReason reason; SCR_EConsumableFailReason reason;
@@ -209,11 +224,39 @@ class ERR_NativeMedicalProbe
if (m_Saline || AttachedBags() != 0 || SalineEffects() != 0 || m_Damage.GetGroupSalineBagged(ECharacterHitZoneGroup.RIGHTARM)) return 0; 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); 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_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(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; if (!Check(m_SpareSaline != null, "unused spare survives the completed infusion")) return -1;
InventoryItemComponent spareItem = InventoryItemComponent.Cast(m_SpareSaline.FindComponent(InventoryItemComponent)); InventoryItemComponent spareItem = InventoryItemComponent.Cast(m_SpareSaline.FindComponent(InventoryItemComponent));
if (!Check(spareItem && spareItem.GetParentSlot(), "rejected spare bag remains a physical inventory item")) return -1; 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; m_Result = 1;
Stop(); Stop();
Print("ERR MEDICAL RESULT: native blood loss, finite treatment and recovery; not manual action or multiplayer acceptance"); 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 (now < m_Wait) return 0;
if (m_Step == 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); m_Damage.EnableDamageHandling(true);
// Each exceeds full native health, including blood loss and a head injury. m_Damage.ForceUnconsciousness(); HaltPassiveRecovery();
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_Wait = now + 1000; m_Step = 1; return 0; m_Wait = now + 1000; m_Step = 1; return 0;
} }
if (m_Step == 1) 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. // Reset fixture injuries, then exercise treatment of an isolated knockout.
m_Damage.FullHeal(); m_Damage.ForceUnconsciousness(); HaltPassiveRecovery(); m_Damage.FullHeal(); m_Damage.ForceUnconsciousness(); HaltPassiveRecovery();
m_Damage.SetGroupIsBeingHealed(ECharacterHitZoneGroup.RIGHTARM, true); m_Damage.SetGroupIsBeingHealed(ECharacterHitZoneGroup.RIGHTARM, true);
@@ -16,6 +16,10 @@ class ERR_ObserverRules
if (right && !previousRight) return 1; if (right && !previousRight) return 1;
return 0; 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) static bool Casualty(bool active, bool currentBody, bool alive, bool conscious)
{ {
return active && currentBody && alive && !conscious; return active && currentBody && alive && !conscious;
@@ -48,7 +52,7 @@ class ERR_ObserverGuidance
} }
if (!survivorAvailable) if (!survivorAvailable)
return message + " No survivor is currently available to follow. The view will follow a survivor when one becomes available."; 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) 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_Yaw;
protected float m_Pitch = 20; protected float m_Pitch = 20;
protected float m_Distance = 5; 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 bool EOnCameraInit() { return true; }
override void EOnCameraFrame(SCR_ManualCameraParam param) override void EOnCameraFrame(SCR_ManualCameraParam param)
{ {
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController()); SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
if (!controller) return; if (!controller) return;
bool left = (Debug.KeyState(KeyCode.KC_LEFT) & 32768) != 0; m_InputContextActive = GetInputManager().IsContextActive("ERR_ObserverContext");
bool right = (Debug.KeyState(KeyCode.KC_RIGHT) & 32768) != 0; Input(param.isManualInputEnabled, GetInputManager().GetActionValue("ERR_ObserverPrevious") > 0.5, GetInputManager().GetActionValue("ERR_ObserverNext") > 0.5, GetInputManager().GetActionValue("ERR_ObserverView") > 0.5);
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);
IEntity target = controller.ERR_ObservedEntity(); 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 aim = controller.ERR_ObserverAnchor() + "0 1.5 0";
vector position = aim + "0 4 -6"; vector position = aim + "0 4 -6";
IEntity exclude = target; IEntity exclude = target;
@@ -147,6 +188,8 @@ modded class SCR_PlayerController
protected ERR_ObserverReason m_ERRShownObserverReason; protected ERR_ObserverReason m_ERRShownObserverReason;
protected bool m_ERRShownObserverAvailable; protected bool m_ERRShownObserverAvailable;
protected float m_ERRLastCycle = -1000; protected float m_ERRLastCycle = -1000;
protected IEntity m_ERRRecoveryBody;
protected float m_ERRRecoveryDeadline = -1;
ERR_ObserverReason ERR_ObserverState() { return m_ERRObserverReason; } ERR_ObserverReason ERR_ObserverState() { return m_ERRObserverReason; }
vector ERR_ObserverAnchor() { return m_ERRObserverAnchor; } vector ERR_ObserverAnchor() { return m_ERRObserverAnchor; }
@@ -266,6 +309,7 @@ modded class SCR_PlayerController
protected void ERR_UpdateObserverClient() protected void ERR_UpdateObserverClient()
{ {
ERR_GameMode mode = ERR_GameMode.Get(); 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())); bool observe = mode && ERR_ObserverRules.View(m_ERRObserverReason, mode.Active(), ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity()));
if (!observe) if (!observe)
{ {
@@ -299,6 +343,31 @@ modded class SCR_PlayerController
m_ERRShownObserverAvailable = survivorAvailable; 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() protected void ERR_CloseObserverCamera()
{ {
m_ERRObserverReady = false; m_ERRObserverReady = false;
@@ -19,6 +19,7 @@ modded class SCR_CharacterDamageManagerComponent
if (!Replication.IsServer()) return; if (!Replication.IsServer()) return;
m_ERRRevivablePlayer = true; m_ERRRevivablePlayer = true;
SetPermitUnconsciousness(true, true); SetPermitUnconsciousness(true, true);
SetRegenScale(0.25, true);
} }
bool ERR_IsRevivablePlayer() { return m_ERRRevivablePlayer; } bool ERR_IsRevivablePlayer() { return m_ERRRevivablePlayer; }
@@ -44,11 +45,34 @@ modded class SCR_CharacterDamageManagerComponent
if (!m_ERRRevivablePlayer) { super.Kill(instigator); return; } if (!m_ERRRevivablePlayer) { super.Kill(instigator); return; }
ERR_RequestKnockout(); 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) override protected bool IsIndefinitelyUnconscious(bool onlyPlayers = true)
{ {
if (m_ERRRevivablePlayer) return false; if (m_ERRRevivablePlayer) return false;
return super.IsIndefinitelyUnconscious(onlyPlayers); return super.IsIndefinitelyUnconscious(onlyPlayers);
} }
override float GetResilienceRegenScale()
{
if (m_ERRRevivablePlayer && IsBleeding()) return 0;
return super.GetResilienceRegenScale();
}
bool ERR_TreatmentInProgress() bool ERR_TreatmentInProgress()
{ {
if (m_aBeingHealedGroup && !m_aBeingHealedGroup.IsEmpty()) return true; if (m_aBeingHealedGroup && !m_aBeingHealedGroup.IsEmpty()) return true;
@@ -56,8 +80,7 @@ modded class SCR_CharacterDamageManagerComponent
foreach (SCR_PersistentDamageEffect effect : effects) foreach (SCR_PersistentDamageEffect effect : effects)
{ {
DotDamageEffect dot = DotDamageEffect.Cast(effect); DotDamageEffect dot = DotDamageEffect.Cast(effect);
// Includes saline and ACE epinephrine. Passive regeneration is checked by // Includes saline and epinephrine; passive regeneration is evaluated separately.
// observed progress, so a stalled regeneration effect cannot block defeat.
if (dot && dot.GetDamageType() == EDamageType.HEALING && dot.GetDPS() < 0) return true; if (dot && dot.GetDamageType() == EDamageType.HEALING && dot.GetDPS() < 0) return true;
} }
return false; return false;
@@ -77,3 +100,16 @@ modded class SCR_HitZone
return allowed; 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 float m_StalledSince = -1;
protected ref map<IEntity, ref ERR_RecoverySample> m_Samples = new map<IEntity, ref ERR_RecoverySample>(); protected ref map<IEntity, ref ERR_RecoverySample> m_Samples = new map<IEntity, ref ERR_RecoverySample>();
protected string m_Message; protected string m_Message;
protected float m_NextMedicalLog;
protected string m_MedicalDetails;
protected bool m_DefeatLogged;
protected ref ScriptInvoker m_OnChanged = new ScriptInvoker(); protected ref ScriptInvoker m_OnChanged = new ScriptInvoker();
void ERR_RecoveryService(ERR_GameMode mode) { m_Mode = mode; } void ERR_RecoveryService(ERR_GameMode mode) { m_Mode = mode; }
ScriptInvoker OnChanged() { return m_OnChanged; } ScriptInvoker OnChanged() { return m_OnChanged; }
@@ -46,11 +49,29 @@ class ERR_RecoveryService
int Incapacitated() { return m_Incapacitated; } int Incapacitated() { return m_Incapacitated; }
int Reserved() { return m_Reserved; } int Reserved() { return m_Reserved; }
string Message() { return m_Message; } 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() void Tick()
{ {
if (!Replication.IsServer() || m_Stopped || !m_Mode || !m_Mode.Active()) return; 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; int conscious, incapacitated, reserved, reservedRescuers, recovering;
map<IEntity, ref ERR_RecoverySample> nextSamples = new map<IEntity, ref ERR_RecoverySample>(); map<IEntity, ref ERR_RecoverySample> nextSamples = new map<IEntity, ref ERR_RecoverySample>();
array<ref ERR_PlayerLife> lives = {}; array<ref ERR_PlayerLife> lives = {};
@@ -76,22 +97,30 @@ class ERR_RecoveryService
if (medical.GetResilienceHitZone()) sample.Resilience = medical.GetResilienceHitZone().GetHealth(); if (medical.GetResilienceHitZone()) sample.Resilience = medical.GetResilienceHitZone().GetHealth();
ERR_RecoverySample previous = m_Samples.Get(life.Actor); ERR_RecoverySample previous = m_Samples.Get(life.Actor);
bool improving = previous && (sample.Blood > previous.Blood + 0.001 || sample.Resilience > previous.Resilience + 0.001); 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); nextSamples.Insert(life.Actor, sample);
} }
m_MedicalDetails = medicalDetails;
m_Samples = nextSamples; m_Samples = nextSamples;
m_Conscious = conscious; m_Conscious = conscious;
m_Incapacitated = incapacitated; m_Incapacitated = incapacitated;
m_Reserved = reserved; m_Reserved = reserved;
float now = GetGame().GetWorld().GetWorldTime();
bool stalled = ERR_RecoveryRules.Stalled(conscious, incapacitated, reservedRescuers, recovering, GetGame().GetPlayerManager().GetPlayerCount() > 0); bool stalled = ERR_RecoveryRules.Stalled(conscious, incapacitated, reservedRescuers, recovering, GetGame().GetPlayerManager().GetPlayerCount() > 0);
if (!stalled) m_StalledSince = -1; if (!stalled) m_StalledSince = -1;
else if (m_StalledSince < 0 || now < m_StalledSince) m_StalledSince = now; 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; string message;
if (ERR_RecoveryRules.NeedsRescuer(conscious, incapacitated)) if (ERR_RecoveryRules.NeedsRescuer(conscious, incapacitated))
{ {
message = "No conscious prisoner is currently available to help."; 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 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."; 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; m_Started = true;
#ifdef WORKBENCH #ifdef WORKBENCH
string historicalFixture; 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"; m_Definition = "{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf";
#endif #endif
m_RuntimeDefinition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab(m_Definition)); m_RuntimeDefinition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab(m_Definition));
@@ -38,7 +38,7 @@ class ERR_ScenarioComponent : ScriptComponent
#ifdef WORKBENCH #ifdef WORKBENCH
// Existing deterministic probes intentionally use authored coordinates. // Existing deterministic probes intentionally use authored coordinates.
string fixture; 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; foreach (ERR_SiteDefinition fixedSite : m_RuntimeDefinition.Sites) fixedSite.RandomClear = false;
#endif #endif
m_Generated = new ERR_GeneratedSites(); 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; if (m_ERRCameraProbeTarget && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "unconscious-camera") return m_ERRCameraProbeTarget;
return super.ERR_ObservedEntity(); 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() bool ERR_ProbeCameraActive()
{ {
return m_ERRObserverCamera && GetGame().GetCameraManager().CurrentCamera() == m_ERRObserverCamera; return m_ERRObserverCamera && GetGame().GetCameraManager().CurrentCamera() == m_ERRObserverCamera;
@@ -24,6 +29,7 @@ modded class ERR_GameMode
protected float m_ERRCameraWait; protected float m_ERRCameraWait;
protected IEntity m_ERRCameraBody, m_ERRCameraTarget; protected IEntity m_ERRCameraBody, m_ERRCameraTarget;
protected SCR_CharacterDamageManagerComponent m_ERRCameraDamage; protected SCR_CharacterDamageManagerComponent m_ERRCameraDamage;
protected SCR_ManualCamera m_ERRCameraOther;
protected override void EOnInit(IEntity owner) protected override void EOnInit(IEntity owner)
{ {
super.EOnInit(owner); super.EOnInit(owner);
@@ -93,6 +99,7 @@ modded class ERR_GameMode
if (m_ERRCameraStage == 2) if (m_ERRCameraStage == 2)
{ {
vector camera = GetGame().GetCameraManager().CurrentCamera().GetOrigin(); 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; 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_ERRCameraTarget.SetOrigin(ERR_Actors.Ground(m_ERRCameraTarget.GetOrigin() + "15 0 0"));
m_ERRCameraStage = 3; m_ERRCameraWait = now + 1500; return; m_ERRCameraStage = 3; m_ERRCameraWait = now + 1500; return;
@@ -100,6 +107,39 @@ modded class ERR_GameMode
if (m_ERRCameraStage == 3) if (m_ERRCameraStage == 3)
{ {
if (!ERR_CameraCheck(vector.Distance(GetGame().GetCameraManager().CurrentCamera().GetOrigin(), m_ERRCameraTarget.GetOrigin()) < 13, "camera tracks a moving target")) return; 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; pc.ERR_ProbeCameraTarget(null); ERR_Actors.DeleteSpawned(m_ERRCameraTarget); m_ERRCameraTarget = null;
m_ERRCameraDamage.SetGroupIsBeingHealed(ECharacterHitZoneGroup.RIGHTARM, false); m_ERRCameraDamage.SetGroupIsBeingHealed(ECharacterHitZoneGroup.RIGHTARM, false);
m_ERRCameraDamage.FullHeal(); m_ERRCameraStage = 4; m_ERRCameraWait = now + 4000; return; 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(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 (!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(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; if (!ERR_CameraCheck(Settings().Set("keepWeaponOnUnconscious", 0), "retention disabled through persistent setting")) return;
ERR_CameraKnockout(); m_ERRCameraStage = 5; m_ERRCameraWait = now + 3000; return; ERR_CameraKnockout(); m_ERRCameraStage = 5; m_ERRCameraWait = now + 3000; return;
@@ -22,7 +22,7 @@ class ERR_AirSupportPolicy
float ConsideringSince = -1; float ConsideringSince = -1;
float NextDrop; float NextDrop;
string Reason = "waiting for delivered intelligence"; 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 (!s || !s.AircraftReady) return "no ready helicopter passenger load";
if (!ERR_HQMemoryRules.Finite(s.Age) || !ERR_HQMemoryRules.Finite(s.Confidence) || !ERR_HQMemoryRules.Finite(s.Radius) || !ERR_HQMemoryRules.Finite(s.Speed)) return "invalid memory sample"; if (!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); int needed = Math.Clamp(s.Spotted * 2, 4, 12);
if (s.CommittedFit >= needed) return "ground forces already committed in sufficient strength"; 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.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 ""; return "";
} }
bool Consider(ERR_AirSupportSituation s, float now) bool Consider(ERR_AirSupportSituation s, float now)
{ {
if (!ERR_HQMemoryRules.Finite(now) || now < 0) return false; 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; if (!refusal.IsEmpty()) { Candidate = 0;
ConsideringSince = -1; ConsideringSince = -1;
Reason = refusal; Reason = refusal;
@@ -49,6 +52,9 @@ class ERR_AirSupportPolicy
if (Candidate != s.BeliefId || ConsideringSince < 0) { Candidate = s.BeliefId; if (Candidate != s.BeliefId || ConsideringSince < 0) { Candidate = s.BeliefId;
ConsideringSince = now; ConsideringSince = now;
} }
if (s.ArtilleryBusy) { Reason = Refusal(s);
return false;
}
float delay = 45000; float delay = 45000;
if (s.ConfirmedLosses >= 2) delay = 20000; if (s.ConfirmedLosses >= 2) delay = 20000;
if (now - ConsideringSince < delay) { Reason = "confirming persistent contact and ground-force shortfall"; 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(); 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; positions = {}; angles = {}; route = null;
if (!Replication.IsServer() || !ERR_ForceRules.FiniteVector(seed)) return false; if (!Replication.IsServer() || !ERR_ForceRules.FiniteVector(seed)) return false;
@@ -144,6 +144,10 @@ class ERR_ConvoyRoadPlan
ERR_ForceRoute polyline; ERR_ForceRoute polyline;
float distance; float distance;
if (!FromPolyline(points, seed, candidatePositions, candidateAngles, polyline, distance, candidateIndex)) continue; 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]); for (int i = 0; i < candidatePositions.Count(); i++) candidatePositions[i] = ERR_Actors.Ground(candidatePositions[i]);
ERR_ForceRoute grounded = new ERR_ForceRoute(); ERR_ForceRoute grounded = new ERR_ForceRoute();
bool valid = true; bool valid = true;
@@ -80,17 +80,21 @@ class ERR_EnemyEquipmentEntry
ref ERR_EnemyOptics Optics; ref ERR_EnemyOptics Optics;
ref ERR_EnemyLauncherGrant Launcher = new ERR_EnemyLauncherGrant(); ref ERR_EnemyLauncherGrant Launcher = new ERR_EnemyLauncherGrant();
ref ERR_EnemyMineGrant Mines = new ERR_EnemyMineGrant(); ref ERR_EnemyMineGrant Mines = new ERR_EnemyMineGrant();
ref ERR_EnemyMedicine Medicine = new ERR_EnemyMedicine();
int SkillChecks; int SkillChecks;
bool Released; bool Released;
void Check(ERR_GameMode mode) 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); Launcher.Check(mode, Group, Actor);
Mines.Check(mode, Group, Actor); Mines.Check(mode, Group, Actor);
} }
void Stop(ERR_GameMode mode) void Stop(ERR_GameMode mode)
{ {
Launcher.Stop(); Mines.Stop(); Launcher.Stop(); Mines.Stop(); Medicine.Stop();
Released = true; Released = true;
if (Optics) Optics.Stop(); if (Optics) Optics.Stop();
if (Combat) Combat.ERR_ReleaseEnemySkill(mode); 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); array<IEntity> items = {}; inventory.GetItems(items);
if (items.Count() > 256) return -1; if (items.Count() > 256) return -1;
int capability; int capability;
ERR_HQ hq = ERR_HQ.Get();
if (hq && hq.Hunters()) capability = hq.Hunters().VehicleRadioCapability(actor);
foreach (IEntity item : items) foreach (IEntity item : items)
{ {
if (!item || !item.FindComponent(BaseRadioComponent)) continue; if (!item || !item.FindComponent(BaseRadioComponent)) continue;
capability = 1; capability = Math.Max(capability, 1);
SCR_RadioComponent radio = SCR_RadioComponent.Cast(item.FindComponent(SCR_RadioComponent)); SCR_RadioComponent radio = SCR_RadioComponent.Cast(item.FindComponent(SCR_RadioComponent));
if (radio && radio.ERR_IsHQManpack()) return 2; if (radio && radio.ERR_IsHQManpack()) return 2;
} }
@@ -30,9 +30,10 @@ modded class SCR_AIGetAimErrorOffset
{ {
override float GetRandomFactor(EAISkill skill, float mu) override float GetRandomFactor(EAISkill skill, float mu)
{ {
// CYLON normally eliminates aim error. Keep the ordinary REGULAR distribution // Maximum decision skill, with REGULAR aim dispersion tightened by 10%.
// for these soldiers while leaving skill-dependent decisions at maximum. // Preserve the requested mean; only the random error around it is reduced.
if (m_CombatComponent && m_CombatComponent.ERR_EnhancedEnemy()) skill = EAISkill.REGULAR; if (m_CombatComponent && m_CombatComponent.ERR_EnhancedEnemy())
return mu + (super.GetRandomFactor(EAISkill.REGULAR, mu) - mu) * 0.9;
return super.GetRandomFactor(skill, mu); return super.GetRandomFactor(skill, mu);
} }
} }
+61 -8
View File
@@ -5,6 +5,9 @@ class ERR_HQUnit
SCR_AIGroupUtilityComponent Utility; SCR_AIGroupUtilityComponent Utility;
SCR_AIGroupPerception Detection; SCR_AIGroupPerception Detection;
float NextDetectionLog; float NextDetectionLog;
ref ERR_HQLocalAwareness LocalAwareness = new ERR_HQLocalAwareness();
float NextContact;
ref ERR_InfantryCombat InfantryCombat = new ERR_InfantryCombat();
IEntity Radio; IEntity Radio;
float NextReport; float NextReport;
ref ERR_HQRoute Uplink; ref ERR_HQRoute Uplink;
@@ -458,7 +461,7 @@ class ERR_HQ : ERR_CommandService
SCR_AIGroup group = unit.Group; SCR_AIGroup group = unit.Group;
string factionKey; string factionKey;
if (group.GetFaction()) factionKey = group.GetFaction().GetFactionKey(); 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); array<AIAgent> auditAgents = {}; group.GetAgents(auditAgents);
foreach (AIAgent auditAgent : auditAgents) foreach (AIAgent auditAgent : auditAgents)
{ {
@@ -523,7 +526,8 @@ class ERR_HQ : ERR_CommandService
foreach (ERR_HQUnit unit : m_Units) foreach (ERR_HQUnit unit : m_Units)
{ {
if (unit.Group != group) continue; 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()); ReleaseUnit(unit, GetGame().GetWorld().GetWorldTime());
unit.Group = null; unit.Group = null;
unit.Utility = null; unit.Utility = null;
@@ -663,12 +667,32 @@ class ERR_HQ : ERR_CommandService
unit.AmmoReporting.Queued(now); unit.AmmoReporting.Queued(now);
m_Journal.Write("STATUS REPORT", route.Payload, route.MessageId); m_Journal.Write("STATUS REPORT", route.Payload, route.MessageId);
} }
// Due routine status and requested check-ins get the next free uplink. // One captured contact every five seconds at most. Existing transmissions
// Positive observations precede a negative search report. // 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) protected void QueueReports(ERR_HQUnit unit, float now)
{ {
if (OriginOccupied(unit.Radio, unit)) { if (unit.Task) unit.Task.ArrivedAt = -1; return; } 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); StatusReport(unit, now);
ContactReport(unit, now);
Observe(unit, now); Observe(unit, now);
ObserveBody(unit, now); ObserveBody(unit, now);
Sweep(unit, now); Sweep(unit, now);
@@ -783,6 +807,7 @@ class ERR_HQ : ERR_CommandService
} }
if (!m_Memory.Ingest(report, now)) return; if (!m_Memory.Ingest(report, now)) return;
if (ERR_HQMemoryRules.Kind(report) == ERR_HQMemoryKind.SIGHTING) m_Coverage.Forget(report.Position); 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); 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. // A reported corpse or distress call can justify investigation, never fire.
if (ERR_HQMemoryRules.Kind(report) != ERR_HQMemoryKind.SIGHTING) return; if (ERR_HQMemoryRules.Kind(report) != ERR_HQMemoryKind.SIGHTING) return;
@@ -804,10 +829,30 @@ class ERR_HQ : ERR_CommandService
m_Contacts.Insert(report.Snapshot()); 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) 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 int HomeAvailable(ERR_HQUnit candidate) { return m_HomeAvailable.Get(candidate.HomeId); }
protected ERR_HQHuntWindow Hunt(int beliefId) protected ERR_HQHuntWindow Hunt(int beliefId)
@@ -1160,6 +1205,8 @@ class ERR_HQ : ERR_CommandService
if (!hunt || now < hunt.NextHunt) continue; if (!hunt || now < hunt.NextHunt) continue;
for (int slot = 0; slot < limit && assigned < limit && Assigned() < globalLimit; slot++) 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; float age = (now - belief.ObservedAt) / 1000;
ERR_HQPursuitRole role = ERR_HQPursuitRules.Role(belief.Confidence, age, belief.Radius, belief.HasVelocity, slot); 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); 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; best.PendingTask = task; best.PendingOrder = belief.Report.Snapshot(); best.Downlink = bestRoute;
bestRoute.MessageId = m_Journal.Message(); 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); 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); m_Journal.Write("ORDER QUEUED", bestRoute.Payload, bestRoute.MessageId);
reserved.Insert(destination); reserved.Insert(destination);
if (first < 0) first = bestBearing; if (first < 0) first = bestBearing;
@@ -1242,10 +1290,11 @@ class ERR_HQ : ERR_CommandService
for (int i = m_Units.Count() - 1; i >= 0; i--) for (int i = m_Units.Count() - 1; i >= 0; i--)
{ {
ERR_HQUnit unit = m_Units[i]; ERR_HQUnit unit = m_Units[i];
UpdateInfantryCombat(unit, now);
if (!unit.Detection && unit.Utility && unit.Group) if (!unit.Detection && unit.Utility && unit.Group)
{ {
unit.Detection = unit.Utility.m_Perception; 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) if (unit.Uplink)
{ {
@@ -1289,7 +1338,11 @@ class ERR_HQ : ERR_CommandService
m_Towns.Stop(); m_Towns.Stop();
foreach (SCR_AICombatComponent specialSkill : m_SpecialSkills) if (specialSkill) specialSkill.ERR_ReleaseEnemySkill(m_Mode); foreach (SCR_AICombatComponent specialSkill : m_SpecialSkills) if (specialSkill) specialSkill.ERR_ReleaseEnemySkill(m_Mode);
m_SpecialSkills.Clear(); 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_Airfield.Stop(); m_Recovery.Stop();
m_AirAssault.Stop(); m_AirAssault.Stop();
m_Recon.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 // 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 class ERR_HQObservationHistory
{ {
protected ref array<IEntity> m_Targets = {}; protected ref array<IEntity> m_Targets = {};
protected ref array<float> m_Times = {}; protected ref array<float> m_Times = {};
bool CanQueue(IEntity target, float observedAt, float now) 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--) 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); } 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, SIGHTING,
DISTRESS, DISTRESS,
BODY BODY,
GUNFIRE
} }
class ERR_HQMemoryRules class ERR_HQMemoryRules
@@ -22,6 +23,7 @@ class ERR_HQMemoryRules
} }
static int Kind(ERR_HQReport report) static int Kind(ERR_HQReport report)
{ {
if (report.TravelMode == "heard gunfire") return ERR_HQMemoryKind.GUNFIRE;
if (report.Distress) return ERR_HQMemoryKind.DISTRESS; if (report.Distress) return ERR_HQMemoryKind.DISTRESS;
if (report.TravelMode == "body" || report.TravelMode == "dead body") return ERR_HQMemoryKind.BODY; if (report.TravelMode == "body" || report.TravelMode == "dead body") return ERR_HQMemoryKind.BODY;
return ERR_HQMemoryKind.SIGHTING; return ERR_HQMemoryKind.SIGHTING;
@@ -33,18 +35,21 @@ class ERR_HQMemoryRules
} }
static float BaseConfidence(int kind) 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.DISTRESS) return 0.3;
if (kind == ERR_HQMemoryKind.BODY) return 0.2; if (kind == ERR_HQMemoryKind.BODY) return 0.2;
return 1; return 1;
} }
static float HalfLife(int kind) static float HalfLife(int kind)
{ {
if (kind == ERR_HQMemoryKind.GUNFIRE) return 60;
if (kind == ERR_HQMemoryKind.DISTRESS) return 180; if (kind == ERR_HQMemoryKind.DISTRESS) return 180;
if (kind == ERR_HQMemoryKind.BODY) return 300; if (kind == ERR_HQMemoryKind.BODY) return 300;
return 600; return 600;
} }
static float RadiusFloor(int kind, string travel) 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.DISTRESS) return 150;
if (kind == ERR_HQMemoryKind.BODY) return 75; if (kind == ERR_HQMemoryKind.BODY) return 75;
if (travel == "aircraft") return 120; if (travel == "aircraft") return 120;
@@ -53,6 +58,7 @@ class ERR_HQMemoryRules
} }
static float RadiusGrowth(int kind, string travel) 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.DISTRESS) return 2;
if (kind == ERR_HQMemoryKind.BODY) return 1.2; if (kind == ERR_HQMemoryKind.BODY) return 1.2;
if (travel == "aircraft") return 8; if (travel == "aircraft") return 8;
@@ -179,6 +185,12 @@ class ERR_HQMemoryTrack
belief.Confidence = ConfidenceAt(now); belief.Confidence = ConfidenceAt(now);
belief.Radius = RadiusAt(now); belief.Radius = RadiusAt(now);
belief.PredictionSource = "delivered sighting; motion unknown"; 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) if (Kind == ERR_HQMemoryKind.DISTRESS)
{ {
belief.Target = null; belief.Target = null;
@@ -191,7 +203,22 @@ class ERR_HQMemoryTrack
return belief; return belief;
} }
if (Count() < 2) 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 seconds = (last.ObservedAt - previous.ObservedAt) / 1000;
float distance = vector.DistanceXZ(previous.Position, last.Position); float distance = vector.DistanceXZ(previous.Position, last.Position);
if (!ERR_HQMemoryRules.MotionCredible(seconds, distance, previous.TravelMode, last.TravelMode)) 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"); if (m_Seen.Count() >= 4096) return Refuse("delivered-report replay guard full");
int kind = ERR_HQMemoryRules.Kind(report); int kind = ERR_HQMemoryRules.Kind(report);
IEntity key = report.Target; 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"); if (!key) return Refuse("report has no observation identity");
int index = Find(key, kind); int index = Find(key, kind);
if (index >= 0 && report.ObservedAt <= m_Tracks[index].Latest().ObservedAt) 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++) for (int i = 0; i < m_Tracks.Count(); i++)
{ {
ERR_HQMemoryTrack track = m_Tracks[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; if (track.BeliefRevision != revision || track.NegativeRevision == revision) return false;
track.SearchConfidence = 0.65; track.SearchConfidence = 0.65;
track.NegativeRevision = revision; track.NegativeRevision = revision;
@@ -20,11 +20,13 @@ class ERR_HQRadio
array<IEntity> items = {}; array<IEntity> items = {};
inventory.GetItems(items); inventory.GetItems(items);
int result; int result;
ERR_HQ hq = ERR_HQ.Get();
if (hq && hq.Hunters()) result = hq.Hunters().VehicleRadioCapability(character);
foreach (IEntity item : items) foreach (IEntity item : items)
{ {
if (!item || !item.FindComponent(BaseRadioComponent)) if (!item || !item.FindComponent(BaseRadioComponent))
continue; continue;
result = 1; result = Math.Max(result, 1);
SCR_RadioComponent gadget = SCR_RadioComponent.Cast(item.FindComponent(SCR_RadioComponent)); SCR_RadioComponent gadget = SCR_RadioComponent.Cast(item.FindComponent(SCR_RadioComponent));
if (gadget && gadget.ERR_IsHQManpack()) if (gadget && gadget.ERR_IsHQManpack())
return 2; return 2;
@@ -105,6 +107,8 @@ class ERR_HQReport
string Description(float now, bool brief = false) 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) 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)); 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"; 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 // Preserve original hunter actors. Reboarding uses native AI tasks, never new
// crew, vehicle teleports, health restoration, or ammunition replacement. // crew, vehicle teleports, health restoration, or ammunition replacement.
class ERR_HunterCrewRecovery class ERR_HunterCrewRecovery
@@ -13,7 +27,7 @@ class ERR_HunterCrewRecovery
void Stop() void Stop()
{ {
m_Stopped = true; m_Stopped = true;
if (m_Boarding) m_Boarding.Complete(); ERR_VehicleBehaviorCleanup.Complete(m_Boarding, m_BoardingActor);
m_Boarding = null; m_BoardingActor = null; m_Boarding = null; m_BoardingActor = null;
} }
bool Lost(ERR_GameMode mode) bool Lost(ERR_GameMode mode)
@@ -45,9 +59,15 @@ class ERR_HunterCrewRecovery
SCR_AIGroupUtilityComponent groupUtility = m_Unit.Group.GetGroupUtilityComponent(); SCR_AIGroupUtilityComponent groupUtility = m_Unit.Group.GetGroupUtilityComponent();
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(m_Unit.Entity.FindComponent(SCR_AIVehicleUsageComponent)); SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(m_Unit.Entity.FindComponent(SCR_AIVehicleUsageComponent));
if (groupUtility && groupUtility.m_VehicleMgr && usage) groupUtility.m_VehicleMgr.TryAddVehicle(usage); 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_Unit.CrewValid(mode))
{ {
if (m_Boarding) m_Boarding.Complete(); ERR_VehicleBehaviorCleanup.Complete(m_Boarding, m_BoardingActor);
m_Boarding = null; m_BoardingActor = null; m_Boarding = null; m_BoardingActor = null;
Note("driver seated and conscious; patrol can continue"); return; 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 (m_Boarding && m_BoardingActor == m_Unit.Driver && actions.Contains(m_Boarding)) return;
if (now < m_NextAttempt) return; if (now < m_NextAttempt) return;
m_NextAttempt = now + 10000; m_NextAttempt = now + 10000;
if (m_Boarding) m_Boarding.Complete(); ERR_VehicleBehaviorCleanup.Complete(m_Boarding, m_BoardingActor);
m_BoardingActor = m_Unit.Driver; m_BoardingActor = m_Unit.Driver;
IEntity aboard = CompartmentAccessComponent.GetVehicleIn(m_Unit.Driver); IEntity aboard = CompartmentAccessComponent.GetVehicleIn(m_Unit.Driver);
if (aboard) if (aboard)
@@ -1,5 +1,7 @@
class ERR_HunterVehicle : ERR_ConvoyVehicle class ERR_HunterVehicle : ERR_ConvoyVehicle
{ {
bool RadioRequested;
ref ERR_EnemyEquipmentCallback RadioOperation;
string Source; string Source;
float Deadline; float Deadline;
float NextOrder; float NextOrder;
@@ -8,7 +10,12 @@ class ERR_HunterVehicle : ERR_ConvoyVehicle
int SourceMessage; int SourceMessage;
float LastContact = -1; float LastContact = -1;
float NextContactOrder; float NextContactOrder;
vector ContactPosition;
vector DeliveredContactPosition;
bool DeliveredContact;
ref ERR_HunterCrewRecovery Recovery; ref ERR_HunterCrewRecovery Recovery;
bool CombatHeld;
float CombatQuietUntil;
float NextMotionLog; float NextMotionLog;
float ProgressAt; float ProgressAt;
vector ProgressPosition; vector ProgressPosition;
@@ -54,6 +61,43 @@ class ERR_HunterService : ERR_ConvoyService
foreach (ERR_HunterVehicle unit : m_Hunters) if (unit.Group == group) return true; foreach (ERR_HunterVehicle unit : m_Hunters) if (unit.Group == group) return true;
return false; 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 Deployed() { return m_Hunters.Count(); }
int ActiveHunters() 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"); ERR_HQ.Event("HUNTER WAIT", "deployment deferred: friendly road obstructed, occupied, budget full or players within 400m");
return false; 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) 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(); ERR_HunterVehicle unit = new ERR_HunterVehicle();
unit.Source = "aggression-uaz-" + (m_Hunters.Count() + 1).ToString(); unit.Source = "aggression-uaz-" + (m_Hunters.Count() + 1).ToString();
unit.Deadline = now + 120000; m_Hunters.Insert(unit); 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) 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); array<AIAgent> agents = {}; unit.Group.GetAgents(agents);
if (agents.Count() != unit.Members.Count()) return false; 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; 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_AIGroupUtilityComponent utility = unit.Group.GetGroupUtilityComponent();
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(unit.Entity.FindComponent(SCR_AIVehicleUsageComponent)); SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(unit.Entity.FindComponent(SCR_AIVehicleUsageComponent));
if (!utility || !utility.m_VehicleMgr || !usage || !utility.m_VehicleMgr.TryAddVehicle(usage)) return false; 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); 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; if (!record) return false;
record.Pinned = true; unit.Registered = true; record.Pinned = true; unit.Registered = true;
m_Mode.RegisterGroup(unit.Group); hq.ReserveGroup(unit.Group); m_Mode.RegisterGroup(unit.Group); hq.ReserveGroup(unit.Group);
utility.AddUsableVehicle(usage); unit.Group.SetPermanentLOD(0); unit.Group.ActivateAI(); utility.AddUsableVehicle(usage); unit.Group.SetPermanentLOD(0); unit.Group.ActivateAI();
foreach (AIAgent active : agents) { active.SetPermanentLOD(0); active.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; return true;
} }
protected void QueueDestination(ERR_HunterVehicle unit, ERR_HQ hq, float now) protected void QueueDestination(ERR_HunterVehicle unit, ERR_HQ hq, float now)
@@ -148,14 +237,22 @@ class ERR_HunterService : ERR_ConvoyService
unit.NextOrder = now + 30000; unit.NextOrder = now + 30000;
vector destination, angles; vector destination, angles;
string reason = "road patrol between friendly objectives"; string reason = "road patrol between friendly objectives";
unit.SourceMessage = 0;
if (best) 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; if (!ERR_SupplyConvoySystem.ApproachDirected(best.PredictedPosition, destination, angles, 250)) return;
unit.SourceMessage = best.MessageId; reason = "intercept last delivered sighting; confidence=" + best.Confidence.ToString(); unit.SourceMessage = best.MessageId; reason = "intercept last delivered sighting; confidence=" + best.Confidence.ToString();
} }
else else
{ {
unit.SourceMessage = 0;
if (unit.Waypoint && !unit.Waypoint.IsWithinCompletionRadius(unit.Group)) return; if (unit.Waypoint && !unit.Waypoint.IsWithinCompletionRadius(unit.Group)) return;
bool found; bool found;
for (int i = 0; i < m_Mode.Sites().Count(); i++) for (int i = 0; i < m_Mode.Sites().Count(); i++)
@@ -171,7 +268,24 @@ class ERR_HunterService : ERR_ConvoyService
if (best) if (best)
{ {
unit.NextContactOrder = now + 5000; 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) override void Tick(float now)
@@ -208,7 +322,9 @@ class ERR_HunterService : ERR_ConvoyService
string loss = unit.Recovery.Reason; string loss = unit.Recovery.Reason;
unit.Release(m_Mode); ERR_HQ.Event("HUNTER RELEASE", unit.Source + ": " + loss + "; surviving actors and vehicle retained"); continue; 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.Recovery) unit.Recovery.Update(m_Mode, now);
if (unit.CombatHeld) continue;
if (!unit.Registered) if (!unit.Registered)
{ {
if (AdmitHunter(unit, hq, now)) continue; if (AdmitHunter(unit, hq, now)) continue;
@@ -227,6 +343,13 @@ class ERR_HunterService : ERR_ConvoyService
{ {
if (delivered > 0) 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.ClearOrder();
unit.Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), unit.Destination)); unit.Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), unit.Destination));
if (unit.Waypoint) { unit.Waypoint.SetCompletionRadius(50); unit.Group.AddWaypoint(unit.Waypoint); } 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(); vector position = unit.Entity.GetOrigin();
if (unit.ProgressAt == 0 || vector.DistanceXZ(position, unit.ProgressPosition) >= 15) if (unit.ProgressAt == 0 || vector.DistanceXZ(position, unit.ProgressPosition) >= 15)
{ unit.ProgressAt = now; unit.ProgressPosition = position; unit.Repaths = 0; } { unit.ProgressAt = now; unit.ProgressPosition = position; unit.Repaths = 0; }
SCR_AIGroupUtilityComponent utility = unit.Group.GetGroupUtilityComponent(); // Old entries remain in native perception after contact ends. They must
bool combat = utility && utility.m_Perception && !utility.m_Perception.m_aTargets.IsEmpty(); // not suppress stuck-route recovery indefinitely.
bool combat = ERR_HunterCombat.Active(unit.Group);
bool travelling = unit.Waypoint && !unit.Waypoint.IsWithinCompletionRadius(unit.Group); bool travelling = unit.Waypoint && !unit.Waypoint.IsWithinCompletionRadius(unit.Group);
if (!travelling || combat) unit.ProgressAt = now; if (!travelling || combat) unit.ProgressAt = now;
if (now >= unit.NextMotionLog) 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. // re-aim). Each salvo shell needs its own spread point and fresh authorization.
modded class SCR_AIStaticArtilleryBehavior 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_MortarBattery ERR_GetMortarBattery()
{ {
ERR_MortarSystem system = ERR_MortarSystem.Get(); ERR_MortarSystem system = ERR_MortarSystem.Get();
@@ -301,6 +310,10 @@ modded class SCR_AIStaticArtilleryBehavior_ShootingLogic
ENodeResult result = super.EOnTaskSimulate(owner, dt); ENodeResult result = super.EOnTaskSimulate(owner, dt);
if (battery && result == ENodeResult.SUCCESS) 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); SetVariableOut(PORT_AIMING_NEEDED, true);
} }
return result; return result;
@@ -28,7 +28,7 @@ class ERR_MortarStation
{ {
if (m_Stopped) return; if (m_Stopped) return;
m_Stopped = true; m_Stopped = true;
if (m_Mount) m_Mount.Complete(); ERR_VehicleBehaviorCleanup.Complete(m_Mount, m_Gunner);
m_Mount = null; m_Mount = null;
foreach (SCR_CharacterControllerComponent controller : m_Controllers) foreach (SCR_CharacterControllerComponent controller : m_Controllers)
if (controller) controller.ERR_ClearMortarStation(this); if (controller) controller.ERR_ClearMortarStation(this);
@@ -65,7 +65,7 @@ class ERR_MortarStation
} }
if (!Holds(m_Gunner) || !ERR_Actors.CanFight(m_Gunner)) 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; m_Mount = null; m_Gunner = null;
foreach (AIAgent candidate : agents) foreach (AIAgent candidate : agents)
{ {
@@ -304,7 +304,9 @@ class ERR_MortarSystem
if ((battery.Rejected || battery.RefreshRejected) && !battery.Released) if ((battery.Rejected || battery.RefreshRejected) && !battery.Released)
{ {
battery.Cancel("native aim or firing authorization rejected"); 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.Rejected = false;
battery.Update(now); battery.Update(now);
@@ -327,6 +329,13 @@ class ERR_MortarSystem
if (!battery.CanRequest(report, now)) continue; if (!battery.CanRequest(report, now)) continue;
float candidate = vector.DistanceXZ(battery.Position, report.Position); float candidate = vector.DistanceXZ(battery.Position, report.Position);
if (candidate < 350 || candidate > 4000 || candidate >= distance || !OutsideSettlements(report.Position)) continue; 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; selected = battery;
target = report; target = report;
distance = candidate; distance = candidate;
@@ -1,6 +1,41 @@
#ifdef WORKBENCH #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 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 ERR_TestGrounded()
{ {
int count; int count;
@@ -24,7 +59,9 @@ modded class ERR_GameMode
protected float m_ERRAirDeadline; protected float m_ERRAirDeadline;
protected ERR_AirAssaultLoad m_ERRAirLoad; protected ERR_AirAssaultLoad m_ERRAirLoad;
protected Vehicle m_ERRAirframe; 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) protected override void EOnInit(IEntity owner)
{ {
super.EOnInit(owner); super.EOnInit(owner);
@@ -36,6 +73,11 @@ modded class ERR_GameMode
} }
protected void ERR_AirTestStop() { m_ERRAirStopped = true; protected void ERR_AirTestStop() { m_ERRAirStopped = true;
GetGame().GetCallqueue().Remove(ERR_AirTestTick); 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) protected bool ERR_AirCheck(bool result, string detail)
{ {
@@ -59,6 +101,9 @@ modded class ERR_GameMode
ERR_Actors.Players(players, true); ERR_Actors.Players(players, true);
if (players.IsEmpty()) return; if (players.IsEmpty()) return;
if (!UnlockPrison(players[0])) 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(); if (hq.SupplyConvoys()) hq.SupplyConvoys().Stop();
hq.Recon().ERR_TestDeparture(now + 3000); hq.Recon().ERR_TestDeparture(now + 3000);
m_ERRAirStage = 1; m_ERRAirStage = 1;
@@ -74,20 +119,26 @@ modded class ERR_GameMode
m_ERRAirLoad = flight.Assault(); m_ERRAirLoad = flight.Assault();
m_ERRAirframe = flight.Aircraft(); 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; 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; m_ERRAirStage = 2;
} }
if (m_ERRAirStage == 2) if (m_ERRAirStage == 2)
{ {
if (!m_ERRAirLoad.Ready()) return; hq.ERR_AirFixtureReserveGround();
ERR_AirDropOrder order = new ERR_AirDropOrder(); if (now >= m_ERRAirNextStatus)
order.ExpiresAt = now + 180000; {
order.Target = flight.Home(); m_ERRAirNextStatus = now + 15000;
order.Report = new ERR_HQReport(); Print("ERR AIR HQ WAIT: " + hq.AirAssault().Status());
order.Report.Position = order.Target; }
order.Report.ObservedAt = now; if (!m_ERRAirLoad.Used()) return;
// Controlled geometry/transfer fixture; policy and physical radio authorization ERR_AirDropOrder order = m_ERRAirLoad.ERR_TestOrder();
// are verified independently. This does not fabricate a live sighting result. if (!ERR_AirCheck(order && order.BeliefId > 0 && order.MessageId > 0 && order.Report && order.Report.Target == m_ERRAirPlayer,
if (!ERR_AirCheck(m_ERRAirLoad.Begin(order, now), "controlled order finds real clear landing corridor")) return; "automatic HQ policy and physical radio downlink authorized drop from native player sightings")) return;
m_ERRAirStage = 3; m_ERRAirStage = 3;
return; return;
} }
@@ -95,8 +146,9 @@ modded class ERR_GameMode
{ {
if (m_ERRAirLoad.ERR_TestGrounded() < 8 || m_ERRAirLoad.ERR_TestReleased() < 2) return; 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(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; 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_ERRAirLoad.ERR_TestCoverEnd(now);
m_ERRAirStage = 4; m_ERRAirStage = 4;
return; return;
@@ -12,14 +12,35 @@ modded class ERR_AirReconSystem
} }
modded class ERR_ReconFlightComponent 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(); } int ERR_TestCrew() { return m_OriginalCrew.Count(); }
bool ERR_TestPilot() { return LivingPilot(); } 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; } bool ERR_TestSeen(IEntity actor) { return m_Observation && m_Observation.Target == actor; }
protected override void Search(float now) protected override void Search(float now)
{ {
super.Search(now); super.Search(now);
string fixture; 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. 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 float m_ERRReconDeadline, m_ERRReconDue, m_ERRReconWait;
protected Vehicle m_ERRReconFirst; protected Vehicle m_ERRReconFirst;
protected IEntity m_ERRReconPlayer; protected IEntity m_ERRReconPlayer;
protected bool m_ERRReconDamage, m_ERRReconTakeoverOnly; protected bool m_ERRReconDamage, m_ERRReconTakeoverOnly, m_ERRReconCloseSeen;
protected override void EOnInit(IEntity owner) protected override void EOnInit(IEntity owner)
{ {
super.EOnInit(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 (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 || 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(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; 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(); m_ERRReconFirst = flight.Aircraft();
@@ -100,6 +122,29 @@ modded class ERR_GameMode
if (!delivered) return; 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(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; 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(); system.Return();
if (!ERR_ReconCheck(flight.Phase() == ERR_ReconPhase.RETURNING, "production return-home request accepted")) return; if (!ERR_ReconCheck(flight.Phase() == ERR_ReconPhase.RETURNING, "production return-home request accepted")) return;
m_ERRReconStage = 3; 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) 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; float observed;
int messageId = hq.ERR_QueueMortarRefreshReport(m_Group, m_OriginalRTO, m_Target, m_Impact, 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; if (m_Battery.CommandOpen || m_Battery.Activity) return 0;
@@ -40,12 +40,22 @@ modded class ERR_HQ
} }
return false; 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 class ERR_NativePatrolHQProbe
{ {
protected ERR_GameMode m_Mode; protected ERR_GameMode m_Mode;
protected ERR_SiteRuntime m_Site; protected ERR_SiteRuntime m_Site;
protected SCR_AIGroup m_Group;
protected AIWaypoint m_Search; protected AIWaypoint m_Search;
protected ref array<AIWaypoint> m_Baseline = {}; protected ref array<AIWaypoint> m_Baseline = {};
protected int m_Step; protected int m_Step;
@@ -64,25 +74,35 @@ class ERR_NativePatrolHQProbe
if (m_Step == 0) if (m_Step == 0)
{ {
m_Site = m_Mode.Sites().Find("everon-outpost-1"); 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; if (!Check(m_Site && m_Site.Responders && m_Site.Responders.Group, "existing mobile outpost responders available")) return -1;
m_Site.Group.GetWaypoints(m_Baseline); m_Group = m_Site.Responders.Group;
if (!Check(!m_Baseline.IsEmpty() && hq.ERR_TestPatrolSearch(m_Site.Group, m_Search), "real delivered-order method assigns existing squad")) return -1; 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; m_Step = 1; m_Wait = now + 750; return 0;
} }
if (m_Step == 1) if (m_Step == 1)
{ {
array<AIWaypoint> queue = {}; array<AIWaypoint> queue = {};
m_Site.Group.GetWaypoints(queue); m_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; 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) foreach (AIWaypoint waypoint : m_Baseline)
if (!Check(queue.Contains(waypoint), "preempted baseline route remains queued")) return -1; 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; m_Step = 2; m_Wait = now + 750; return 0;
} }
if (m_Step == 2) if (m_Step == 2)
{ {
AIWaypoint current = m_Site.Group.GetCurrentWaypoint(); AIWaypoint current = m_Group.GetCurrentWaypoint();
if (!Check(current && (current == m_Site.PatrolCycle || m_Site.PatrolWaypoints.Contains(current)), "native patrol resumes after Search release")) return -1; if (!Check(current && m_Baseline.Contains(current), "native home defence returns after Search release")) return -1;
m_Step = 3; return 1; m_Step = 3; return 1;
} }
return 1; return 1;
@@ -90,7 +110,7 @@ class ERR_NativePatrolHQProbe
void Stop() void Stop()
{ {
ERR_HQ hq = ERR_HQ.Get(); 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(); } void ~ERR_NativePatrolHQProbe() { Stop(); }
} }
@@ -21,9 +21,13 @@ class ERR_ReconFlightRules
if (distance >= 180) return velocity; if (distance >= 180) return velocity;
if (distance < 1) away = "1 0 0"; if (distance < 1) away = "1 0 0";
away.Normalize(); away.Normalize();
float inward = vector.Dot(velocity, away); // This is a minimum outward speed, not an impulse. The result is stored
if (inward < 0) velocity -= away * inward; // in m_Velocity and reused next frame: adding the full correction on every
return velocity + away * Math.Clamp((180 - distance) * 0.5, 0, 25); // 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")] [ComponentEditorProps(category: "Reintroduced", description: "Airport reconnaissance helicopter")]
@@ -113,22 +117,55 @@ class ERR_ReconFlightComponent : ScriptComponent
if (!m_Crew) { Fail("crew group unavailable"); return; } if (!m_Crew) { Fail("crew group unavailable"); return; }
mode.Own(m_Crew); m_Crew.SetFaction(mode.EnemyFaction()); m_Crew.SetPermanentLOD(0); mode.Own(m_Crew); m_Crew.SetFaction(mode.EnemyFaction()); m_Crew.SetPermanentLOD(0);
m_Crew.GetGroupUtilityComponent().SetCombatMode(EAIGroupCombatMode.FIRE_AT_WILL); m_Crew.GetGroupUtilityComponent().SetCombatMode(EAIGroupCombatMode.FIRE_AT_WILL);
Seats(m_Aircraft); m_Crew.ActivateAI();
if (m_Seats.Count() < 2 || m_Seats.Count() > 8) { Fail("unsupported pilot/gunner seat layout"); return; } // 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"; 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"; if (faction == "US" || faction == "RHS_USAF") pilot = "{C31F210ABE078199}Prefabs/ERR/ReconPilotWest.et";
foreach (BaseCompartmentSlot seat : m_Seats) foreach (BaseCompartmentSlot seat : m_Seats)
{ {
if (seat.IsOccupied() || !seat.IsCompartmentAccessible()) { Fail("crew seat obstructed"); return; } // Native SpawnCharacterInCompartment boards before assigning the group.
AIGroup nativeCrew = m_Crew; // Its Soviet/US prefab affiliation can therefore reject an ION airframe.
IEntity initialCrew = seat.SpawnCharacterInCompartment(pilot, nativeCrew, "{C31F210ABE078195}Prefabs/ERR/ReconCrew.et"); // Assign the selected faction and original group BEFORE native boarding.
if (initialCrew) m_OriginalCrew.Insert(initialCrew); vector staging = ERR_Actors.Ground(m_Home + Vector(20 + m_OriginalCrew.Count() * 2, 0, 0));
if (nativeCrew != m_Crew) { Fail("native boarding changed crew identity"); return; } 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); 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() 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; 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) 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(); m_Crew.ActivateAI();
array<AIAgent> boardingAgents = {}; m_Crew.GetAgents(boardingAgents); array<AIAgent> boardingAgents = {}; m_Crew.GetAgents(boardingAgents);
foreach (AIAgent boardingAgent : boardingAgents) { boardingAgent.SetPermanentLOD(0); boardingAgent.ActivateAI(); } foreach (AIAgent boardingAgent : boardingAgents) { boardingAgent.SetPermanentLOD(0); boardingAgent.ActivateAI(); }
if (m_Assault && m_Assault.Preparing()) return false; 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) foreach (BaseCompartmentSlot seat : m_Seats)
if (!seat || !seat.GetOccupant() || !ERR_Actors.CanFight(seat.GetOccupant()) || ERR_Actors.Group(seat.GetOccupant()) != m_Crew) return false; 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. // 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(); team.Group.SetPermanentLOD(0); team.Group.ActivateAI();
foreach (AIAgent active : agents) { active.SetPermanentLOD(0); active.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.Tactics = new ERR_SupplyConvoyTactics(m_Mode, supply);
supply.TravelDeadline = now + 600000; supply.TravelDeadline = now + 600000;
return true; return true;
@@ -166,16 +167,10 @@ class ERR_SupplyConvoyService : ERR_ConvoyService
} }
protected void Circuit(ERR_SupplyConvoyTask task, float now) 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) foreach (ERR_ConvoyVehicle vehicle : task.Vehicles)
{ if (!vehicle.Released && (!vehicle.Arrived || vehicle.SupplyHeld || vehicle.Combat.Held)) pending = true;
if (!vehicle.Released && now >= task.TravelDeadline && task.TravelDeadline > 0) { vehicle.ClearOrder(); vehicle.Arrived = true; }
if (!vehicle.Released && task.Legs > 0 && vector.Distance(vehicle.Entity.GetOrigin(), task.Bases[task.LastBase]) <= 250)
{ vehicle.ClearOrder(); vehicle.Arrived = true; }
vehicle.Tick(m_Mode, task.Route, now);
if (!vehicle.Released) { surviving = true; if (!vehicle.Arrived) pending = true; }
}
if (!surviving) { task.Stop(m_Mode, "no surviving drivable convoy vehicles"); return; }
if (pending) return; if (pending) return;
if (!task.Waiting) { task.Waiting = true; task.Departure = now + Math.RandomFloat(45000, 90000); return; } if (!task.Waiting) { task.Waiting = true; task.Departure = now + Math.RandomFloat(45000, 90000); return; }
if (now < task.Departure || task.Bases.Count() < 2) return; if (now < task.Departure || task.Bases.Count() < 2) return;
@@ -189,6 +184,7 @@ class ERR_SupplyConvoyService : ERR_ConvoyService
if (departure.Released) continue; if (departure.Released) continue;
departure.ClearOrder(); departure.RouteIndex = 0; departure.Arrived = false; 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])); 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) 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()]; if (attempt == 0) seed = m_Bases[(m_Attempts + m_Service.Count()) % m_Bases.Count()];
seed = ERR_Actors.Ground(seed); seed = ERR_Actors.Ground(seed);
array<vector> positions, angles; ERR_ForceRoute route; 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(); string source = "supply-convoy-" + (m_Service.Count() + 1).ToString();
ERR_ConvoyReceipt receipt = m_Pool.Reserve(source); ERR_ConvoyReceipt receipt = m_Pool.Reserve(source);
if (!receipt || receipt.Consumed) { m_Reason = "configured convoy capacity or shared force budget unavailable"; return false; } 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; } if (!admitted) { m_Reason = "waiting for existing force admission"; return; }
m_Attempts++; m_Attempts++;
StartOne(); 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() string Status()
{ {
@@ -8,6 +8,8 @@ class ERR_SupplyTeam
bool Registered; bool Registered;
bool Released; bool Released;
vector OrderedContact; vector OrderedContact;
ref ERR_ConvoyBoardingProgress BoardingProgress;
float BoardingRetryAt;
void Clear() void Clear()
{ {
foreach (AIWaypoint waypoint : Orders) foreach (AIWaypoint waypoint : Orders)
@@ -36,6 +38,17 @@ class ERR_SupplyTeam
array<AIAgent> agents = {}; Group.GetAgents(agents); array<AIAgent> agents = {}; Group.GetAgents(agents);
foreach (AIAgent agent : agents) if (agent) agent.SetPermanentLOD(-1); 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() bool Living()
{ {
foreach (IEntity member : Members) foreach (IEntity member : Members)
@@ -125,6 +138,7 @@ class ERR_SupplyConvoyTask : ERR_ConvoyTask
{ {
ref array<ref ERR_SupplyTeam> Teams = {}; ref array<ref ERR_SupplyTeam> Teams = {};
ref ERR_SupplyConvoyTactics Tactics; ref ERR_SupplyConvoyTactics Tactics;
ref ERR_SupplyConvoyColumn Column;
ref array<vector> Bases = {}; ref array<vector> Bases = {};
ref array<vector> Starts = {}; ref array<vector> Starts = {};
int LastBase = -1; int LastBase = -1;
@@ -154,6 +168,8 @@ class ERR_SupplyConvoyTactics
protected vector m_Left; protected vector m_Left;
protected float m_LastContact = -1; protected float m_LastContact = -1;
protected float m_NextOrders; protected float m_NextOrders;
protected vector m_LastOrderContact;
protected bool m_OrdersIssued;
protected float m_BoardDeadline; protected float m_BoardDeadline;
protected bool m_TruckWithdrawn; protected bool m_TruckWithdrawn;
protected float m_NextDismount; protected float m_NextDismount;
@@ -302,9 +318,10 @@ class ERR_SupplyConvoyTactics
if (SupportPosition(index, desired, chosen)) support = chosen; if (SupportPosition(index, desired, chosen)) support = chosen;
if (vector.DistanceXZ(vehicle.Entity.GetOrigin(), support) > 20) orders.Add(Move(support)); if (vector.DistanceXZ(vehicle.Entity.GetOrigin(), support) > 20) orders.Add(Move(support));
if (index == 1 || index == 2) return; if (index == 1 || index == 2) return;
AIWaypoint defend = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.DefendWaypoint(), support)); // Mounted escorts engage using native vehicle combat after reaching cover.
if (defend) defend.SetCompletionRadius(20); // Defend is an infantry occupation task; deleting it during regroup could
orders.Add(defend); // 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() protected void Clear()
{ {
@@ -313,13 +330,18 @@ class ERR_SupplyConvoyTactics
} }
protected void Orders(bool dismount, float now) 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.Vehicles.Count(); i++) Support(i, !m_TruckWithdrawn);
for (int i = 0; i < m_Task.Teams.Count(); i++) Flank(i, dismount); for (int i = 0; i < m_Task.Teams.Count(); i++) Flank(i, dismount);
m_NextOrders = now + 30000; m_NextOrders = now + 30000;
} }
protected void Start(vector contact, float now) 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; vector origin;
foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles) foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles)
{ {
@@ -339,13 +361,62 @@ class ERR_SupplyConvoyTactics
Clear(); m_State = 0; Clear(); m_State = 0;
foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles) 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. // Combat time does not consume the driving deadline.
vehicle.LegDeadline = now + 600000; vehicle.LegDeadline = now + 600000;
} }
m_Task.TravelDeadline = 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"); 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) bool Tick(float now)
{ {
if (m_Stopped) return false; if (m_Stopped) return false;
@@ -367,10 +438,10 @@ class ERR_SupplyConvoyTactics
if (found) Report(observed, now, now); if (found) Report(observed, now, now);
if (m_State == 1) 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. // 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. // 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; m_DismountAttempts++; m_NextDismount = now + 10000;
for (int retry = 0; retry < m_Task.Teams.Count(); retry++) 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; } 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); for (int i = 0; i < m_Task.Vehicles.Count(); i++) Support(i, true);
foreach (ERR_SupplyTeam boarding : m_Task.Teams) foreach (ERR_SupplyTeam boarding : m_Task.Teams)
if (!boarding.Released && boarding.Living()) boarding.Add(Board(m_Task.Vehicles[2].Entity, true)); if (!boarding.Released && boarding.Living())
m_State = 2; m_BoardDeadline = now + 120000; {
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"); 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)) if (m_State == 2 && (!Usable(2) || InfantryAboard(true) || now >= m_BoardDeadline))
{ {
foreach (ERR_SupplyTeam stranded : m_Task.Teams) foreach (ERR_SupplyTeam stranded : m_Task.Teams)
if (!stranded.Aboard(m_Task.Vehicles[2].Entity, true)) stranded.Release(m_Mode); if (!stranded.Aboard(m_Task.Vehicles[2].Entity, true)) stranded.Release(m_Mode);
Finish(now); Finish(now);
} }
if (m_State != 0) ProtectInfantry();
return m_State != 0; return m_State != 0;
} }
void Stop() { if (m_Stopped) return; m_Stopped = true; Clear(); } 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; if (!route.Nodes[i] || !route.Nodes[i].Valid(i == route.Nodes.Count() - 1)) return false;
return true; 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) ERR_HQRoute ERR_MemoryFixtureQueue(ERR_HQReport report)
{ {
if (!ERR_NativeHQMemoryProbe.Enabled()) return null; if (!ERR_NativeHQMemoryProbe.Enabled()) return null;
@@ -34,10 +40,15 @@ modded class ERR_HQ
ERR_HQRoute route = m_Network.Route(unit.Radio); ERR_HQRoute route = m_Network.Route(unit.Radio);
if (!ERR_MemoryFixtureHealthy(route)) continue; if (!ERR_MemoryFixtureHealthy(route)) continue;
report.Source = unit.Group; report.Source = unit.Group;
route.MessageId = report.MessageId; // Arrange an isolated inbox so natural contacts cannot replace this fixture
route.Payload = "FIXTURE synthetic observed position; " + report.Description(report.ObservedAt); // payload before it receives its message ID. Radio delivery remains native.
unit.PendingReport = report.Snapshot(); unit.Uplink = route; unit.LocalAwareness.Clear();
return route; 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; return null;
} }
@@ -95,7 +106,7 @@ class ERR_NativeHQMemoryProbe
{ {
string enabled, outcome; string enabled, outcome;
return Replication.IsServer() && System.GetCLIParam("errSmoke", enabled) && enabled == "1" 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; } void ERR_NativeHQMemoryProbe(ERR_GameMode mode) { m_Mode = mode; }
protected bool Check(bool condition, string description) 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"); 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() protected bool LocalPolicies()
{ {
ERR_ForceLedger ledger = new ERR_ForceLedger(); ERR_ForceLedger ledger = new ERR_ForceLedger();
@@ -291,19 +343,28 @@ class ERR_NativeHQMemoryProbe
if (m_Deadline == 0) m_Deadline = now + 150000; if (m_Deadline == 0) m_Deadline = now + 150000;
if (now > m_Deadline) { Check(false, "fixture deadline"); return -1; } if (now > m_Deadline) { Check(false, "fixture deadline"); return -1; }
if (m_Step == 0) if (m_Step == 0)
{
string outcome;
System.GetCLIParam("errSmokeOutcome", outcome);
bool awarenessOnly = outcome == "hq-awareness";
if (!awarenessOnly)
{ {
if (!hq.ERR_ProbeSquadsReady()) return 0; if (!hq.ERR_ProbeSquadsReady()) return 0;
bool automaticWasChecked = m_AutoChecked; bool automaticWasChecked = m_AutoChecked;
int automatic = AutomaticConvoy(hq); int automatic = AutomaticConvoy(hq);
if (automatic <= 0) return automatic; if (automatic <= 0) return automatic;
// Startup admission and native crew readiness have their own bounded wait. // Legacy combined convoy/memory qualification retains its original checks.
// Start the original memory/radio deadline once, after automatic proof.
if (!automaticWasChecked) m_Deadline = now + 120000; if (!automaticWasChecked) m_Deadline = now + 120000;
}
if (!m_ValueChecked) if (!m_ValueChecked)
{
if (!awarenessOnly)
{ {
if (!Check(hq.ERR_MemoryFixtureRegisteredForces() == 6, if (!Check(hq.ERR_MemoryFixtureRegisteredForces() == 6,
"actual six site groups are registered once in the Core force ledger")) return -1; "actual six site groups are registered once in the Core force ledger")) return -1;
if (!LocalPolicies() || !LocalRoadPolicy()) return -1; if (!LocalPolicies() || !LocalRoadPolicy()) return -1;
}
if (!LocalAwarenessPolicy(hq, now)) return -1;
m_ValueChecked = true; m_ValueChecked = true;
} }
m_FirstPosition = m_Mode.PrisonPosition() + "300 0 300"; m_FirstPosition = m_Mode.PrisonPosition() + "300 0 300";
@@ -19,6 +19,7 @@ modded class ERR_GameMode
protected bool m_ERRTestRadioNetwork; protected bool m_ERRTestRadioNetwork;
protected bool m_ERRTestHQResponse; protected bool m_ERRTestHQResponse;
protected bool m_ERRTestHQMemory; protected bool m_ERRTestHQMemory;
protected bool m_ERRTestHQAwareness;
protected bool m_ERRTestLocalHandoff; protected bool m_ERRTestLocalHandoff;
protected ref ERR_NativeLocalHandoffProbe m_ERRLocalHandoff; protected ref ERR_NativeLocalHandoffProbe m_ERRLocalHandoff;
protected ERR_PlayerLife m_ERRLocalHandoffLife; protected ERR_PlayerLife m_ERRLocalHandoffLife;
@@ -74,7 +75,8 @@ modded class ERR_GameMode
m_ERRTestLostHelicopter = System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "lost-helicopter"; m_ERRTestLostHelicopter = System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "lost-helicopter";
m_ERRTestRadioNetwork = outcome == "radio-network"; m_ERRTestRadioNetwork = outcome == "radio-network";
m_ERRTestHQResponse = outcome == "hq-response"; 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_ERRTestLocalHandoff = outcome == "hq-local-handoff";
m_ERRTestHQBody = outcome == "hq-body"; m_ERRTestHQBody = outcome == "hq-body";
m_ERRTestInfantryTransfer = outcome == "infantry-transfer"; m_ERRTestInfantryTransfer = outcome == "infantry-transfer";
@@ -323,6 +325,24 @@ modded class ERR_GameMode
Print("ERR SMOKE COMPLETE: native HQ patrol preemption and physical resumption"); Print("ERR SMOKE COMPLETE: native HQ patrol preemption and physical resumption");
return; 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 (m_ERRTestPlayerDeath)
{ {
if (!ERR_Check(current == ERR_Phase.DEFEAT && m_ERRSmokeStep == 91, "death exhausts round life and reaches defeat after spectator grace")) return; 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) if (m_ERRTestMortarSalvo)
{ {
DamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false); 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_ERRMortarSalvo = new ERR_NativeMortarSalvoProbe(this);
m_ERRSmokeStep = 190; m_ERRSmokeStep = 190;
return; return;
@@ -586,6 +613,12 @@ modded class ERR_GameMode
if (convoyOutcome == "supply-convoys") if (convoyOutcome == "supply-convoys")
{ {
if (!ERR_Check(UnlockPrison(m_ERRSmokePlayer), "supply fixture unlocks prison to start normal mission AI")) return; 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); m_ERRConvoyProbe = new ERR_NativeSupplyConvoyProbe(this);
} }
else m_ERRConvoyProbe = new ERR_NativeConvoyProbe(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); int result = m_ERRConvoyProbe.Tick(ERR_HQ.Cast(Command()), now);
if (result == 0) return; if (result == 0) return;
if (!ERR_Check(result == 1, "native six-vehicle convoy readiness, cargo and road movement")) return; if (!ERR_Check(result == 1, "native six-vehicle convoy readiness, cargo and road movement")) return;
m_ERRSmokeStep = 9; m_ERRSmokeStep = 1061;
SCR_CharacterDamageManagerComponent damage; Transition(ERR_Phase.DEFEAT);
damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent));
damage.EnableDamageHandling(true);
damage.Kill(Instigator.CreateInstigator(m_ERRSmokePlayer));
m_ERRSmokeWait = now + 4000;
} }
else if (m_ERRSmokeStep == 105) else if (m_ERRSmokeStep == 105)
{ {
int result = m_ERRHQMemoryProbe.Tick(ERR_HQ.Cast(Command()), now); int result = m_ERRHQMemoryProbe.Tick(ERR_HQ.Cast(Command()), now);
if (result == 0) return; if (result == 0) return;
if (!ERR_Check(result == 1, "native HQ memory delivery and search lineage")) 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; m_ERRSmokeStep = 9;
SCR_CharacterDamageManagerComponent damage; SCR_CharacterDamageManagerComponent damage;
damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent)); damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent));
@@ -52,9 +52,22 @@ class ERR_NativeSupplyConvoyProbe : ERR_NativeConvoyProbe
} }
if (m_CombatTask) 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]; ERR_ConvoyVehicle truck = m_CombatTask.Vehicles[2];
SCR_VehicleDamageManagerComponent health = SCR_VehicleDamageManagerComponent.Cast(truck.Entity.FindComponent(SCR_VehicleDamageManagerComponent)); 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()); 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) foreach (ERR_SupplyTeam diagnostic : m_CombatTask.Teams)
{ {
int aboard; 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()); 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) foreach (IEntity remaining : diagnostic.Members)
{ {
if (!remaining) { Print("ERR SUPPLY SEAT: original actor removed"); continue; }
AIControlComponent control = AIControlComponent.Cast(remaining.FindComponent(AIControlComponent)); 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)); 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()); 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; if (original.Starts.Count() != 6) return -1;
foreach (vector start : original.Starts) { m_StartPositions.Insert(start); m_Moved.Insert(false); } 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"); 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; m_Phase = 1; m_PhaseBegan = now;
} }
if (m_Phase == 1) 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)); m_CombatTask = ERR_SupplyConvoyTask.Cast(hq.Convoys().At(0));
vector contact = m_CombatTask.Vehicles[2].Entity.GetOrigin() + "0 0 200"; vector contact = m_CombatTask.Vehicles[2].Entity.GetOrigin() + "0 0 200";
m_CombatTask.Tactics.Report(contact, now, now); 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; 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"); 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_TestLegs = m_CombatTask.Legs; m_RouteResumePosition = m_CombatTask.Vehicles[2].Entity.GetOrigin();
m_CombatTask.TravelDeadline = now; m_CombatTask.Waiting = false; Print("ERR SUPPLY FIXTURE: waiting for physical column arrival, production pause and next base leg; no forced timeout arrival");
Print("ERR SUPPLY FIXTURE: expire this trip's travel deadline to exercise the real pause and next base leg without a ten-minute test delay");
m_Phase = 3; 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_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"); if (m_Phase == 3 && m_CombatTask.Legs > m_TestLegs)
{
m_RouteResumePosition = m_CombatTask.Vehicles[2].Entity.GetOrigin();
m_Phase = 4; 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; return 0;
} }
override void Stop() {} override void Stop() {}
+3
View File
@@ -11,6 +11,9 @@ GameProject {
} }
Configurations { Configurations {
GameProjectConfig PC { GameProjectConfig PC {
InputManagerSettings InputManagerSettings "{50A48858902D5F64}" {
Default "{A7B19F416E541000}Configs/ERR/ObserverInput.conf"
}
MenuManagerSettings MenuManagerSettings "{50C890C996B0A6FC}" { MenuManagerSettings MenuManagerSettings "{50C890C996B0A6FC}" {
MenuConfigs { MenuConfigs {
"{C747AFB6B750CE9A}Configs/System/chimeraMenus.conf" "{C747AFB6B750CE9A}Configs/System/chimeraMenus.conf"
+3 -1
View File
@@ -33,12 +33,14 @@ The mission currently has a **medical supply stash**, not a spawned hospital bui
## Skill, accuracy and perception ## Skill, accuracy and perception
Mission-owned hostile military AI use `EAISkill.CYLON` for maximum skill while the native aim-error node uses the `REGULAR` random-error distribution. This avoids CYLON's zero-error shooting. Recognition uses the native alerted factor (2.5) even in safe/suppressed states, preserving equipment factors and the engine's visibility/occlusion checks. This does not create through-wall vision or knowledge of unseen players. Mission-owned hostile military AI use `EAISkill.CYLON` for maximum skill while the native aim-error node uses the `REGULAR` random-error distribution with deviations from the mean reduced by 10%. This avoids CYLON's zero-error shooting. Recognition uses the native alerted factor (2.5) even in safe/suppressed states, preserving equipment factors and the engine's visibility/occlusion checks. This does not create through-wall vision or knowledge of unseen players.
The authorized original AI code supplied the scoped skill/equipment and detection-hook approach. The separate aim-error reduction is new: no corresponding independent accuracy hook was found in the inspected original. Native `SCR_AIGetAimErrorOffset` and `SCR_AICombatComponent` implementations were used to separate these behaviours. The authorized original AI code supplied the scoped skill/equipment and detection-hook approach. The separate aim-error reduction is new: no corresponding independent accuracy hook was found in the inspected original. Native `SCR_AIGetAimErrorOffset` and `SCR_AICombatComponent` implementations were used to separate these behaviours.
Actual enemy-detection events activate local native combat and bring forward the next HQ report; report delivery still obeys radio routing. Primary-enemy units share HQ. Other hostile factions receive the same scoped skill/aim policy but do not leak intelligence to a mutually hostile faction. Specialist scouts and flight crews retain their authored equipment. Captured/ineligible actors are excluded from the profile, and normal teardown restores prior skill and native perception. Actual enemy-detection events activate local native combat and bring forward the next HQ report; report delivery still obeys radio routing. Primary-enemy units share HQ. Other hostile factions receive the same scoped skill/aim policy but do not leak intelligence to a mutually hostile faction. Specialist scouts and flight crews retain their authored equipment. Captured/ineligible actors are excluded from the profile, and normal teardown restores prior skill and native perception.
See [local awareness and heard fighting](ai-local-awareness.md) for immediate visual snapshots, prioritized reports and uncertain gunfire investigation.
## Audit trail and adjustment ## Audit trail and adjustment
The complete HQ journal is `$logs:rereintroduced-hq.log` (existing filename retained). Relevant events: The complete HQ journal is `$logs:rereintroduced-hq.log` (existing filename retained). Relevant events:
+73
View File
@@ -0,0 +1,73 @@
# Local observations and HQ response
Mission-owned primary-enemy squads now capture visual evidence in the native
`SCR_AIGroupPerception.AddOrUpdateTarget` callback, before the next one-second HQ
update. Capture still requires an enemy target seen within 0.5 seconds, a clear
native trace and positive exposure. The source, last-seen position, observation
time and visible equipment are copied immediately. Subsequent movement or gear
changes do not alter that report.
Each squad keeps at most 16 visual snapshots and one sound report, for at most
30 seconds. Repeated visual capture is limited to once per second per retained
target. Sending uses the existing observation watermark, so polling cannot resend
the same observation. This is local squad knowledge; only successful physical
radio delivery creates HQ memory. A missing radio route leaves the snapshot
pending until it expires. An accepted transmission is not duplicated if it fails.
Captured contacts can use the next free uplink, with at most one attempt per five
seconds. They can precede routine status, but status overdue by 15 seconds gets
the next free slot. Existing transmissions are allowed to finish. The saved
report interval still controls the ordinary polling path; the new event path
works even on an existing server profile with a ten-second report interval.
A delivered report brings forward the next HQ planning pass.
## Heard fighting
The native `AddOrUpdateGunshot` callback supplies hostile-fire evidence. Native
weapon danger reactions invoke it for audible shots or incoming fire; grenade
danger can also notify this native group path. The mission does not scan player
positions or invent a hearing radius. Capture is scoped to bound, server-owned,
primary-enemy AI groups during the active post-prison mission. Unregistration
and round teardown detach the hook and discard pending observations.
A sound report carries a 150-metre grid area's centre, its observation time and
the reporting squad. Shooter identity, player count, equipment and movement are
not sent. Sound updates are coalesced to one per second and radio reports to one
per 30 seconds per squad. Visual evidence takes precedence in the local inbox.
Delivered sound has its own memory kind, keyed by reporting squad rather than an
unseen shooter. Initial confidence is 0.4, halves every 60 seconds, and positional
uncertainty starts at 150 metres and grows by 2 metres per second. Repeated sounds
never infer target velocity. A visual sighting creates a separate stronger track.
HQ can allocate one existing mobile search squad per sound task, within the
current response radius, total assignment limit and hunt cooldown. This uses the
ordinary radio downlink, search waypoint and negative-search report. Sound alone
cannot authorize mortar/aircraft targeting, paratrooper targeting or a hunter
UAZ precision interception. Permanent objective defenders, mortar crews and
other reserved specialists remain excluded from mobile assignments. Native
local combat and the existing defensive waypoints remain responsible for their
immediate defensive movement.
Maximum AI skill, alerted recognition and Regular aim error are unchanged. No
additional soldiers or vehicles are spawned by this change.
## Validation
`test_hq_local_awareness.py` executes the production cache and contact scheduler
with engine/radio adapters: frozen evidence, expiry, coalescing, capacity, duplicate
rejection, route loss/retry, captured-contact priority and bounded status delay.
The existing memory, radio fairness and local handoff tests cover the new sound
kind and retained observation age. The dedicated `hq-awareness` native fixture exercises the
cache, isolated native gunfire callback, sound confidence/targeting restrictions,
and production cached-contact intake followed by physical radio delivery. Its
injected observations do not prove natural hearing/visibility or combat tactics
in a live multiplayer session.
Validation on 2026-09-25: repository checks passed; the dedicated Workbench
fixture compiled and completed its awareness, physical radio, memory and teardown
assertions. The launcher encountered a terminating-process classification race
while verifying cleanup; a subsequent private-prefix idle check passed. Logs and
source manifests, including earlier failed fixture attempts, are retained under
`.local/analysis/ai-awareness-2026-09-25/`. The fixture arranges an isolated local
inbox so concurrent natural contacts cannot replace its injected payload.
+40
View File
@@ -0,0 +1,40 @@
# RHS airborne validation — 27 September 2026
The tests use the installed RHS/WCS dependencies, US prisoners and RHS ION enemies in an isolated Workbench game. They do not publish the mod or change RKS 2.
## Physical drop
The first run used the existing controlled-order fixture. Eight original RHS passengers boarded the aircraft, all eight left in two-second intervals, all eight boarded their native parachute compartments, all eight finished their descents alive, and both original four-person groups received the carried HQ search order. The original aircrew remained aboard and post-drop cover became active. The run was deliberately stopped after these checks so the next tests could exercise automatic authorization; it did not qualify the reload cycle.
The archived journal contains exactly eight `AIR DROP JUMP`, eight `PARACHUTE BOARDED`, eight `PARACHUTE FINISHED` and two `AIR TEAM LANDED` events.
## Automatic HQ decision
The upgraded fixture places only the test player in a visible runway-sector encounter and protects that player from damage for the duration of the test. Crew perception, sighting reports, physical radio delivery, HQ memory, airborne policy, corridor selection and passenger transfer remain production behavior. It no longer calls `Begin` with a fabricated drop order.
The first automatic run correctly withheld a drop with `ground forces already committed in sufficient strength`. That is intended policy, not a boarding or parachute failure. This run was stopped after observing the refusal.
For the complementary shortage scenario, the fixture reserves other ground squads using the existing `ReserveGroup` API while waiting for authorization. It does not fabricate sightings, change the airborne policy, bypass artillery safety, or issue a drop command. After authorization, it stops reserving squads so landed teams can receive normal HQ search orders. It also checks cover, return, servicing, another eight seated passengers and the separate airport cargo helicopter. The existing fixture advances the remaining cover timer after confirming cover, avoiding a five-minute idle wait; policy tests check the actual cover deadline independently.
The shortage run exposed a real starvation issue: each artillery veto reset the 45-second confirmation timer. Its first clear window ran from mission time 189.3 to 224.5 seconds (35.2 seconds), too short for confirmation before another mortar order reset it again.
`ERR_AirSupportPolicy.Consider` now validates and retains fresh contact/shortage history while artillery blocks the approach. It still refuses authorization while artillery is busy. Invalid/stale intelligence, a new target, sufficient ground support and the existing cooldown keep their previous checks. The order receiver still revalidates airspace using `Refusal` with its default airspace check enabled. A regression fails on the previous implementation and passes with the correction, including stale-target and cooldown cases.
The corrected-timer run then exposed a second blocker: successive reports from multiple observers could be less than two seconds apart. `ERR_HQMemoryTrack.Evaluate` used only the last two samples, repeatedly discarding a valid stationary-target history as unknown movement. Its bounded history now selects the nearest older sample with the existing two-second minimum separation. It stops at travel-mode changes and never skips an implausible eligible pair to find a convenient older estimate. Existing speed, freshness and prediction bounds are retained. A regression reproduces the previous failure and covers stationary sightings, movement, mode changes, implausible jumps and insufficient history.
The final corrected shortage run completed all seven native assertions:
- HQ automatically authorized the drop from native sightings and delivered radio reports at 20:01:23.
- All eight original passengers jumped at two-second intervals, boarded their parachutes, and completed descent without damage. Both original squads were released by 20:02:52 and received HQ search assignments for the carried target.
- Post-drop cover became active with the original aircraft and aircrew retained.
- The same helicopter returned to the airfield, serviced, boarded another eight passengers and departed at 20:06:46. The separate cargo helicopter was waiting at the airport.
The runner recorded `smoke-completed`, confirmed cleanup and verified that source and external dependencies remained unchanged throughout the run. The journal contains eight jumps, eight parachute boardings, eight completed descents, two landed teams and two completed passenger loads.
The generic native report still exits nonzero for existing dependency/resource errors: RHS/WCS localization GUIDs, prefab keywords and two inherited parachute material remaps referring to nonexistent source material names. The same errors were present in the initial controlled-drop run; there are no new engine errors or script/runtime failures in the final run. These warnings did not prevent boarding, descent or landing, but this headless test does not establish the canopy's visual appearance on multiplayer clients.
## Scope
The repository checks and production airborne-policy tests passed. They cover confidence, persistent sightings, ground-force availability, artillery veto, cooldown, cover and invalid inputs. Native results establish local Workbench behavior; dedicated multiplayer replication and client-side canopy rendering still require a server playthrough.
Evidence: `.local/analysis/parachutes-2026-09-27/`.
+50
View File
@@ -0,0 +1,50 @@
# Convoy coordination review — 27 September 2026
## Findings
The latest RKS 2 logs available for this review were from 26 September. That session contains a native `Defend Waypoint not provided to the node!` exception in a supply-convoy escort. The existing local escort correction removes infantry Defend orders from mounted crews; it was not running in that logged game.
The supply controller also had coordination problems independent of that exception:
- Each vehicle independently drove to the same route/base destination at the same cruise speed. There was no spacing controller or leader wait for slower vehicles.
- Being within 250 metres of a base immediately counted as arrival. A shared ten-minute deadline also marked every remaining vehicle arrived regardless of location.
- Combat refreshed support and infantry waypoints every thirty seconds even when the contact had not moved, interrupting native manoeuvres.
- Native move completion and a check against the vehicle origin need different tolerances. The first new-controller test reproduced lead vehicles stopping short of a physical completion check when both used the same radius.
## Current behavior
`ERR_SupplyConvoyColumn` owns ordinary travel. The initial lead vehicle follows the existing road route; each follower targets the preceding surviving vehicle rather than the distant base. Targets remain stable through small position changes. Followers regulate cruise speed, leave a spacing gap, and can catch up. The leader waits when a gap between neighbours exceeds 160 metres and resumes below 90 metres. After combat and at a new base leg, a stable travel order is formed from surviving vehicles’ physical distance to the next destination. This prevents restoring spawn order by sending trucks toward each other; canonical vehicle identities and combat roles remain unchanged. Released vehicles are skipped and leadership preserves route progress. A replacement leader still has to physically reach the endpoint, even if its predecessor had already completed that route.
The leader uses a 15-metre native waypoint radius and a 35-metre physical arrival check. Followers finish in sequence within 75 metres of their predecessor after it arrives. The normal 45–90 second base pause starts only after this physical column arrival. Timeouts do not advance the trip.
The troop truck waits until dismounted infantry are at least 25 metres away before withdrawing. Owned convoy vehicles slow to 8 km/h within 45 metres of their fit dismounted teams; column travel restores the cruising speed after regroup.
Travel pauses during combat and boarding; the original tactical controller retains those orders. Supply transactions retain their own control. Intentionally waiting vehicles do not accrue stuck time. During commanded travel, a missing native usable-vehicle association can be restored for the healthy original seated crew. Stock `SCR_AIRemoveStuckVehicle` removes that association after failed movement; a retained crew needs it to accept driving orders again. Restoration is checked when travel starts and on bounded retries, with an explicit journal event. No five-metre progress triggers a native route retry every 45 seconds; three minutes without progress releases that vehicle from the column and logs its position. Physical vehicles, surviving crew and loot remain; no teleport or replacement spawn is used.
Reboarding tracks occupied seats and the farthest remaining soldier’s distance. Thirty seconds without either kind of progress permits a retry for only that unfinished team, at most three times. The old waypoint is cancelled and its replacement waits until a subsequent tick, allowing native reservations to clear. Seated soldiers are not moved. A five-minute hard deadline retains the finite stranded-team policy; active approaches no longer lose their squad at an unconditional two-minute cutoff.
Unchanged contacts no longer recreate tactical orders. A changed contact can refresh them after the existing thirty-second limit; dismount retries and emergency abandonment retain their separate policies.
The HQ journal now records the selected lead vehicle, spacing waits/resumes, periodic neighbour-gap and supply state, route retries, stranded vehicles and departures.
## Validation
`tests/test_convoy_column.py` executes the production controller against doubles of native entities and waypoints. It covers spacing hysteresis, stable targets, physical arrival, supply-order ownership, route-preserving leader promotion, held-time exclusion, retries and loss. It also exercises the actual unchanged-contact order policy and rejects native-test success based on movement before departure.
`tests/test_supply_convoys.py` verifies that expired travel deadlines and proximity to the old 250-metre base boundary no longer create arrival, and that supply/combat holds prevent departure.
The native fixture starts three production convoys (18 vehicles, 75 soldiers), checks physical cargo and seating, observes all vehicles moving, injects one labelled contact, verifies both infantry teams dismount and reboard, waits for real arrival and the normal pause, and measures truck movement from a new position captured after the next leg starts. The contact is a test input, not proof of native enemy perception. The final fixture parks its observer away from the convoy route and gives that observer a convoy-friendly native perceived faction to prevent unrelated combat from contaminating the injected-contact sequence. The fixture explicitly resets the scenario at completion; it no longer tries to kill a protected prisoner as cleanup.
Native iteration evidence is preserved under `.local/analysis/convoy-coordination-2026-09-27/`. The second run completed convoy movement, dismount, boarding, regroup and physical arrival, but failed obsolete player-death cleanup; its runner result remains `test-failed`. Its old next-leg assertion also counted earlier regroup displacement, so the final fixture requires a fresh post-departure movement sample.
The third run exposed crossed vehicle ordering after combat and also stopped on a native `No suppression volume provided!` exception while unrelated units were reporting the observer near the airfield. Its failed result is preserved; the suppression exception is not claimed fixed by the convoy ordering change. The isolated repeat separates that engine/ambient-combat issue from the controlled convoy sequence.
The fourth run had all 18 vehicles moving and both teams dismounted, but only 13 of 14 passengers had reboarded when the old two-minute deadline released a team. This is preserved as a failed native test and motivated the progress-aware boarding policy.
The fifth run stopped in the test-only seat logger after an infantry actor lost its AI agent during dismounted movement. The diagnostic dereference is now guarded. Vehicles moving near dismounted infantry also motivated the clearance and low-speed protection; the log does not identify a definitive damage instigator.
The sixth run reboarded all 14 passengers and formed a physical travel order, then reproduced a healthy seated troop-truck driver remaining stationary despite three waypoint retries. The three-minute watchdog correctly exposed/released it instead of inventing arrival. The subsequent change adds bounded usable-vehicle restoration and native driver/group-action diagnostics; the old failure itself does not prove which native action discarded or blocked driving.
The seventh run completed the strengthened fixture: all 18 vehicles moved, both infantry teams dismounted, all 14 passengers reboarded, all three convoys departed for another base, and the original troop truck moved twenty metres measured strictly after its next-leg departure. There were no script errors, VM exceptions or stranded-vehicle releases in that run. Native boarding emitted temporary stuck/reset warnings before completing. The runner records `smoke-completed`, unchanged source/external-addon hashes, and confirmed cleanup. All repository checks passed. Generic qualification still exits nonzero for existing external localization/resource and inherited-prefab keyword errors; the raw report is preserved in `r7-completed/qualification.log`. The successful run did not exercise missing-registration restoration, so it does not establish that as the cause of the sixth run’s stall.
Dedicated multiplayer and long-session navigation across all road layouts remain separate validation requirements. Native obstacle/pathfinding failures are recoverable only within the engine's capabilities; the controller now exposes and bounds stationary failures rather than reporting them as arrival.
+30
View File
@@ -140,6 +140,36 @@
"guid": "962C400354E4BD6C", "guid": "962C400354E4BD6C",
"addon": "629B2BA37EFFD577", "addon": "629B2BA37EFFD577",
"verification": "Installed WCS USSR AA character references this exact prefab; inspected launcher has infrared guidance and a loaded disposable muzzle, 2026-09-23." "verification": "Installed WCS USSR AA character references this exact prefab; inspected launcher has infrared guidance and a loaded disposable muzzle, 2026-09-23."
},
"Prefabs/Characters/Factions/BLUFOR/RHS_USAF/RHS_USAF_MARSOC/RandomSoldiers/Character_RHS_USMC_MARSOC_Rifleman_Random.et": {
"guid": "844936B168DFAB8E",
"addon": "595F2BF2F44836FB",
"verification": "Exact path/type/GUID verified in installed owning resourceDatabase.rdb, 2026-09-26. Response roles and medic stock inspected in RHS character/group prefabs."
},
"Prefabs/Characters/Factions/BLUFOR/RHS_USAF/RHS_USAF_MARSOC/RandomSoldiers/Character_RHS_USMC_MARSOC_Medic_Random.et": {
"guid": "DBB8D1F13930220E",
"addon": "595F2BF2F44836FB",
"verification": "Exact path/type/GUID verified in installed owning resourceDatabase.rdb, 2026-09-26. Response roles and medic stock inspected in RHS character/group prefabs."
},
"Prefabs/Characters/Factions/OPFOR/RHS_AFRF/MSV/Flora/Character_RHS_RF_MSV_Flora_SL.et": {
"guid": "B3C496CCFA72D25E",
"addon": "595F2BF2F44836FB",
"verification": "Exact path/type/GUID verified in installed owning resourceDatabase.rdb, 2026-09-26. Response roles and medic stock inspected in RHS character/group prefabs."
},
"Prefabs/Characters/Factions/OPFOR/RHS_AFRF/MSV/Flora/Character_RHS_RF_MSV_Flora_Medic.et": {
"guid": "D9AF475A4E5DC52C",
"addon": "595F2BF2F44836FB",
"verification": "Exact path/type/GUID verified in installed owning resourceDatabase.rdb, 2026-09-26. Response roles and medic stock inspected in RHS character/group prefabs."
},
"Prefabs/Characters/Factions/INDFOR/RHS_ION_Coy/RandomSoldiers/Character_RHS_ION_Coy_Rifleman_Random.et": {
"guid": "140001FC383DF9A2",
"addon": "595F2BF2F44836FB",
"verification": "Exact path/type/GUID verified in installed owning resourceDatabase.rdb, 2026-09-26. Response roles and medic stock inspected in RHS character/group prefabs."
},
"Prefabs/Characters/Factions/INDFOR/RHS_ION_Coy/RandomSoldiers/Character_RHS_ION_Coy_Medic_Random.et": {
"guid": "0FA8ACE19A772327",
"addon": "595F2BF2F44836FB",
"verification": "Exact path/type/GUID verified in installed owning resourceDatabase.rdb, 2026-09-26. Response roles and medic stock inspected in RHS character/group prefabs."
} }
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
# Random field hospitals and camps # Random field hospitals and camps
Everon now has **three field hospitals and five field camps**. This replaces the old medical stash and eastern outpost and adds six locations. Their positions and headings are chosen again at round start. The prison, radio, four mortars and existing supply/extraction objectives remain separate. Everon requests **three field hospitals and five field camps**. This replaces the old medical stash and eastern outpost and adds six locations. Their positions and headings are chosen again at round start. Optional sites with no safe placement are skipped for that round. The prison, radio, four mortars and existing supply/extraction objectives remain separate.
Each field site has four permanent defenders and a separate two-person mobile team, drawn from the selected enemy faction's group roster. Both groups are created once, registered with the force ledger, and receive the existing AI skill, equipment, intel and reporting systems. HQ reserves the defenders against hunting, support, courier and logistics assignments. The mobile team is eligible for delivered HQ hunting orders and otherwise defends its home assembly area. Its standing home waypoint remains available after a temporary HQ assignment ends. Losing mobile soldiers does not turn the permanent guards into replacements. Guards still fight locally and can be killed; the existing objective-defence system permits one finite relief squad after a garrison is lost. Each field site has four permanent defenders and a separate two-person mobile team, drawn from the selected enemy faction's group roster. Both groups are created once, registered with the force ledger, and receive the existing AI skill, equipment, intel and reporting systems. HQ reserves the defenders against hunting, support, courier and logistics assignments. The mobile team is eligible for delivered HQ hunting orders and otherwise defends its home assembly area. Its standing home waypoint remains available after a temporary HQ assignment ends. Losing mobile soldiers does not turn the permanent guards into replacements. Guards still fight locally and can be killed; the existing objective-defence system permits one finite relief squad after a garrison is lost.
@@ -12,7 +12,7 @@ The original medical clue route remains marked after prison escape. The other fi
Edit `addons/core/Configs/ERR/EveronSites.conf`. `GuardCount` selects the permanent roster size (1–8, or zero for the full faction group). `ResponseCount` selects a separate mobile roster (0–8). `GuardOffset` and `ResponseOffset` are rotated with the composition. Do not put a roaming `Patrol` on a permanent garrison; use its separate response team instead. The selected profile must provide usable infantry members. Edit `addons/core/Configs/ERR/EveronSites.conf`. `GuardCount` selects the permanent roster size (1–8, or zero for the full faction group). `ResponseCount` selects a separate mobile roster (0–8). `GuardOffset` and `ResponseOffset` are rotated with the composition. Do not put a roaming `Patrol` on a permanent garrison; use its separate response team instead. The selected profile must provide usable infantry members.
`RandomClear` supports hospitals, outposts and depots as well as the existing generated objectives. Each new field site is at least 600 metres from other committed locations; unresolved fallback seeds do not reserve ground. The survey checks water, terrain variation, physical obstacles, the composition footprint, and both squad assembly areas. Each site has a bounded 12,000-candidate/30-second search and a validated authored fallback. Invalid terrain is never accepted just to satisfy a requested count; preparation reports the site's ID if placement fails. Custom scenery still needs to fit the surveyed footprint (ten metres either side of its centre). `RandomClear` supports hospitals, outposts and depots as well as the existing generated objectives. Each new field site is at least 600 metres from other committed locations; unresolved fallback seeds do not reserve ground. The survey checks water, terrain variation, physical obstacles, the composition footprint, and both squad assembly areas. Each site has a bounded 12,000-candidate/30-second search and a validated authored fallback. Invalid terrain is never accepted just to satisfy a requested count. If both search and fallback fail, a dispensable site is removed before spawning and logged under `[site skipped]`; preparation continues. A site providing indispensable accessible radio intel cannot be skipped. Custom scenery still needs to fit the surveyed footprint (ten metres either side of its centre).
Scenario bounds are 32 sites, 24 initial groups including mobile teams, 512 stock items and 128 scenery props. The default field sites total 32 fixed defenders and 16 mobile soldiers, in addition to other mission forces. These finite limits are not performance measurements. Scenario bounds are 32 sites, 24 initial groups including mobile teams, 512 stock items and 128 scenery props. The default field sites total 32 fixed defenders and 16 mobile soldiers, in addition to other mission forces. These finite limits are not performance measurements.
+23
View File
@@ -0,0 +1,23 @@
# Corrections from the 26 September RKS 2 game
The source review and downloaded evidence are in `.local/analysis/rks2-game-2026-09-26/analysis.md`. These changes do not publish the Workshop mod or restart RKS 2.
- Helicopters defer crew allocation until native airframe simulation, fuel and accessible compartments have initialized. They allocate the original crew once, assign the selected faction and group before native boarding, and only then start passenger loading. This ordering matters for ION: the native convenience spawn function attempted to board a Soviet pilot before changing its faction. A failed allocation terminates that attempt with the seat name; it cannot create replacement crew on subsequent ticks. The same path serves search and ammunition helicopters.
- Convoy road selection applies spacing exclusions before choosing its best candidate, so a nearby occupied deployment area cannot hide other valid formations. Failed bounded placement still retains existing convoys and reports the actual deployed/requested counts; it never forces an unsafe spawn or replaces a lost convoy.
- Mounted convoy escorts move to cover and retain native vehicle combat. They no longer receive infantry Defend waypoints, eliminating that convoy path into the missing-Defend-waypoint exception during regroup.
- Mission-owned hunter and mortar boarding tasks only receive completion callbacks while still present in their actor's native utility and while `m_AIInfo` exists. Teardown/dead-actor references cannot invoke the native null-pointer completion callback.
- Hunters preserve their existing route for fresh reports within 75 m of the area actually delivered to them. A materially changed contact still causes an HQ radio order. Delivered road destinations within 50 m of the current waypoint also preserve navigation. Pending or failed radio packets never advance the delivered-area anchor. The original finite vehicle-radio fix remains required.
- Stale native perception entries no longer suspend stuck-vehicle recovery. The same recent-contact predicate controls both combat priority and the movement watchdog. Actual fresh combat retains priority.
- Mortar HQ selection predicts the reported impact area and checks settlement/friendly exclusions before issuing an order. Rejected native aim/fire attempts receive a 60-second cooldown. Exact ballistic and per-shot authorization remain in place, including the eight-round salvo and finite ammunition; these changes do not allow firing through friendly forces or settlements.
- While prisoners are unconscious, medical diagnostics log blood, resilience, bleeding, observed improvement, treatment and natural recovery at most every ten seconds. Defeat additionally records the current per-prisoner snapshot at the decision. Medical damage/recovery rules are unchanged.
Regression tests execute route retention, stale-contact retry, safe callback cleanup and mortar preflight/cooldown behavior. The complete repository check suite passed. Native compilation passed with the installed RHS/WCS dependencies.
Native Workbench evidence is archived under `.local/analysis/gameplay-fixes-2026-09-27/`:
- The vanilla-enemy fixture completed launch, native player sighting and radio delivery, return/landing, destruction delay, replacement and faction takeover before the final faction-before-boarding correction.
- The RHS ION fixture first reproduced the pilot boarding failure. With the correction, all four original flight crew and eight passengers boarded; the helicopter took off and flew its search route. A second ammunition helicopter also seated its crew.
- The final RHS ION fixture did **not** complete: at 18:24:51 it reported `pilot or airframe lost`. The engine also reported an extreme particle-parent velocity and coordinates during the helicopter explosion. That run did not identify the cause. A subsequent close-contact reproduction isolated repeated outward acceleration in `StandOff`; the correction and successful extended RHS flight/return regression are documented in [helicopter-standoff.md](helicopter-standoff.md). The earlier failed run is retained as evidence.
- The runner could not certify cleanup while an exiting `enfMain` process had empty arguments. A subsequent independent `assert_prefix_idle` check passed; the disposable prefix is idle.
Long-session UAZ navigation, convoy combat/placement and mortar eligibility still need a dedicated multiplayer playthrough. Unit/behavior tests and Workbench compilation do not certify those live behaviors. These edits remain local and have not been published or applied to RKS 2.
+30 -6
View File
@@ -1,13 +1,15 @@
# Generated starts and radio camps # Generated starts and radio camps
Everon now chooses a new prison, radio camp and four mortar sites at preparation time. Other caches, Everon chooses a new prison, radio camp, four mortar sites and optional field
outposts retain their authored positions. The extraction helicopter uses the validated hospitals/camps at preparation time. The supply cache retains its authored position.
The extraction helicopter uses the validated
Everon airfield pad (4800.953 / 11487.412), with no off-airfield fallback. The server Everon airfield pad (4800.953 / 11487.412), with no off-airfield fallback. The server
resolves the prison first, then the radio, before creating any site entities. resolves the prison first, then the radio, before creating any site entities.
Joining clients receive the real replicated locations; intel keeps the same Joining clients receive the real replicated locations; intel keeps the same
stable destination IDs. stable destination IDs.
Site authors can use `RandomClear 1` on a prison, radio or mortar definition. `Heading` Site authors can use `RandomClear 1` on a prison, radio, mortar, hospital, outpost
or depot definition. `Heading`
sets the authored fallback heading; `RootHeight` offsets a composition above its sets the authored fallback heading; `RootHeight` offsets a composition above its
surveyed ground (the radio/table composition uses 0.8 m). Runtime placement never surveyed ground (the radio/table composition uses 0.8 m). Runtime placement never
rewrites the configuration file. Existing definitions default to authored sites. rewrites the configuration file. Existing definitions default to authored sites.
@@ -17,11 +19,17 @@ terrain varying by more than 0.6 m, nearby roads (12 m), and unsafe guard footin
Prisons avoid physical runway/control-tower anchors by 1,500 m. The radio stays Prisons avoid physical runway/control-tower anchors by 1,500 m. The radio stays
at least 1 km from the prison and 300 m from other sites. The extraction pickup at least 1 km from the prison and 300 m from other sites. The extraction pickup
must remain inside the prison-relative escape boundary with 100 m clearance. must remain inside the prison-relative escape boundary with 100 m clearance.
Sampling yields after an 8 ms work budget, with a 30-second overall deadline and Sampling yields after an 8 ms work budget, with a 30-second deadline and
12,000 candidate limit per site. The budget is checked between native terrain 12,000 candidate limit per site. The budget is checked between native terrain
queries, so a single engine query can exceed it. Authored fallback positions queries, so a single engine query can exceed it. Authored fallback positions
must pass the same checks; otherwise preparation reports failure instead of must pass the same checks. If both search and fallback fail, an optional outpost,
spawning an incomplete camp or waiting forever. hospital or depot is omitted for that round and preparation continues. The
`[site skipped]` log identifies its label, ID and attempt count. Removal happens
before spawning, map registration or AI intel assignment, leaving no empty site.
Every following site gets a fresh search budget. Required prison/radio/extraction
and mortar sites remain mandatory. An otherwise optional site also remains
mandatory if removing it would invalidate the scenario, for example by losing
the minimum accessible radio-intel sources. No unsafe placement is accepted.
The existing prison composition, gate, backpack and guards move together. The existing prison composition, gate, backpack and guards move together.
Player slots and escape-boundary tests use the same rotation. Admission still Player slots and escape-boundary tests use the same rotation. Admission still
@@ -63,6 +71,22 @@ definitions. Other Workbench smoke outcomes explicitly load
fixtures, including the retired western cache and old extraction position. fixtures, including the retired western cache and old extraction position.
Ordinary Workbench play and dedicated servers use the live definitions. Ordinary Workbench play and dedicated servers use the live definitions.
`--project core --smoke optional-site-skip` uses current randomized definitions,
forces camp 5 to fail both random and authored terrain checks, and verifies that
the camp has no runtime, required objectives/mortars remain and a player is
admitted. The failure injection exists only in the opt-in Workbench fixture.
On 2026-09-26, this fixture compiled and completed after camp 5 exhausted 12,000
candidates and failed its fallback. The camp was omitted and player admission
succeeded with prison, radio, extraction and all four mortars retained. The full
repository check passed. The runner reported a terminating-process classification
race at shutdown; independent follow-up confirmed the private prefix idle and
the addon source manifest unchanged. External inputs were unchanged. Existing
loaded asset errors remain; the completed scenario had no script/runtime failures. Evidence,
including the earlier test-selector compile failure, is retained under
`.local/analysis/optional-site-skip-2026-09-26/`. This validates local startup,
not a published or dedicated-server update.
On 2026-09-22, the full `python3 tools/check.py` suite passed. The final native On 2026-09-22, the full `python3 tools/check.py` suite passed. The final native
`generated-sites` fixture passed 39 assertions at a randomized Everon start: `generated-sites` fixture passed 39 assertions at a randomized Everon start:
prison at approximately (5100, 9137), heading 89 degrees; radio at (5991, 5659). prison at approximately (5100, 9137), heading 89 degrees; radio at (5991, 5659).
+16
View File
@@ -0,0 +1,16 @@
# Helicopter close-contact speed correction — 27 September 2026
`ERR_ReconFlightRules.StandOff` receives the persistent, smoothed flight velocity. Inside 180 metres of an observed player, it previously added the full outward correction on every simulation frame. Even a helicopter already moving away at the requested speed received another impulse. This made acceleration depend on frame rate and could send the native airframe physics into extreme velocities.
The correction restores the original mission policy: calculate the existing radial speed, calculate the required minimum outward speed, and add only the positive difference. Tangential motion and already sufficient outward motion are preserved. The 180-metre radius and 25 m/s maximum outward correction target are unchanged. No health, damage, player takeover, replacement timing or airframe position is overridden by this fix.
## Reproduction and validation
- A regression executing the production function fails before the correction: 600 repeated evaluations keep accelerating an already safe outward velocity. The corrected function passes repeated-call and 30/60/120 Hz cases, alongside the existing orbit, return and loss-timer tests.
- Ordinary native RHS ION flight telemetry stayed below 40 m/s, with angular velocity below 0.056 rad/s; that diagnostic was deliberately stopped after entering landing. It was not a completed qualification run.
- The extended native Workbench fixture first requires real crew perception and radio delivery to HQ. It then relocates only the test player 100 metres ahead of the aircraft to exercise a sudden close sighting. Aircraft movement, perception, radio, crew and damage remain production behavior.
- With the unfixed controller, that encounter failed at 19:19:46 with commanded speed **573.703 m/s** and measured speed **45,378.7 m/s**. This reproduced the flight instability without changing flight physics in the test.
- With the correction, the **same native close-contact fixture completed** at 19:27:49: actual close sighting, 60 seconds of bounded flight with the original living pilot, return/landing with the same intact aircraft and crew, retained parked aircraft, intentional destruction/replacement delay, replacement and faction takeover. Intact-flight telemetry peaked at 41.094 m/s.
- All repository checks passed. Native compilation and scripted flight assertions passed, and the runner confirmed cleanup and unchanged source/external inputs. The final generic native-error report returned nonzero for existing RHS/WCS localization and prefab keyword errors also present in the unfixed run; there were no new engine-error signatures. This is a successful flight regression, not a claim of an error-free dependency installation.
Evidence is retained in `.local/analysis/helicopter-standoff-2026-09-27/`. The native fixture is Workbench-only; these results do not substitute for a dedicated multiplayer playthrough. Nothing has been published or restarted on RKS 2.
+55
View File
@@ -0,0 +1,55 @@
# Infantry contact and search orders
HQ-controlled enemy infantry now suspend their current HQ mobile search when
native group perception has a detected/identified target updated within 15
seconds. This is evaluated on the existing one-second server tick, before radio
downlink delivery. Local reactions do not wait for a report to reach HQ.
At the transition, the group and its combat-capable agents are activated without
changing their permanent simulation LOD; casualties are not awakened.
The mission removes only its own search waypoint from the native queue, retaining
the assignment and waypoint identity. Native waypoint deselection cancels that
search activity's child investigation orders. The searching squad is set to fire
at will, and pending infantry task orders are discarded. Status/check-in and
vehicle transports remain independent. Individual native combat, visibility,
cover, suppression, ammunition and aiming still determine whether soldiers fire.
After qualifying contacts stop, a 15-second grace period prevents immediate
retasking. The same search is reinserted once only if its assignment is still
current, unexpired and eligible. Released/replaced/expired orders cannot return.
Old, destroyed or disarmed target records no longer block HQ indefinitely.
Defensive site and road-interception waypoints stay in place. Baseline home patrol
routes and other owners' waypoints are not removed. Permanent objective guards,
specialists, logistics reservations, vehicle crews and player-controlled groups
retain their existing ownership/eligibility restrictions. No extra AI are spawned.
Journal events under `INFANTRY COMBAT` identify the squad and spawn source, contact,
search pause, search resumption or completion without a surviving search. This is
an order-conflict safeguard, not proof that every reported hesitation was caused
by HQ. Native attacks already have a higher default priority than investigation;
visibility, suppression, weapon state and other native behavior need a live
playtest if soldiers still fail to fire.
`test_infantry_combat.py` executes the actual state machine with native waypoint
adapters, including renewal, quiet delay, expiry, replacement, defensive orders,
and ownership changes. The focused `infantry-combat` Workbench fixture exercises
the actual mobile outpost responders' search and home-defence queue with an
explicitly injected contact. It does not certify natural target acquisition or
multiplayer shooting behavior.
The older `patrol-hq` scenario's traversal stage runs before unlocking the prison;
its stationary enemies are consistent with the intentional prison AI hold. Its
old search helper also selected permanent objective defenders, which correctly
reject mobile HQ assignments. The shared queue probe now selects the separate
mobile responder roster. The focused scenario leaves the prison hold in place
and tests queue ownership only; it does not claim physical patrol traversal.
Validation on 25 September 2026: the full repository check passed. Targeted checks
after the wake-up and fixture updates also passed. Native run 5 compiled and
completed 15 assertions for the actual mobile squad's search pause/resume and
home-defence queue. Source/external inputs were unchanged and cleanup confirmed.
Loaded localization/world/controller asset errors still make the strict runner
exit nonzero; no script/runtime failure was reported in that completed run.
Logs, source manifests and earlier failed fixture setups are retained in
`.local/analysis/infantry-combat-2026-09-25/`.
+21
View File
@@ -0,0 +1,21 @@
# Rapid mortar barrage — 27 September 2026
An authorized HQ fire mission retains eight HE rounds distributed over an 80-metre radius. Mission-owned AI mortar crews use a dedicated behavior tree with the two fixed 1.5-second firing-loop delays removed. The native loading animation, physical projectile event, ammunition transfer, per-round ballistic solve and spread still determine the real firing rate. The 0.75-second settling pause after leaving the aiming seat is retained.
The tree is derived from the installed Arma Reforger 1.8.0.13 `AI/BehaviorTrees/Chimera/Soldier/StaticArtilleryBehavior.bt`. Only its resource identity and the two delay values differ. It is selected only for a mortar registered with the mission mortar service; unrelated artillery keeps its stock tree. Player firing actions are unchanged.
The existing shooting-cycle gate waits for an actual projectile before advancing the native shot counter. Removing a fixed delay does not replace that event, create ammunition, skip the loading animation, or manufacture explosions. The final transfer still checks the delivered report, current ballistic aim, friendly exclusions, mission state and eight-round limit. Orders retain their finite ammunition, report freshness, cooldown and helicopter airspace coordination.
The old salvo fixture now unlocks the prison through the normal mission API before waiting for crew readiness. Previously it stayed in the prison phase while HQ was paused. The fixture moves its player to the distant airfield after unlocking, keeping the participant away from the crew while testing the synthetic range target. It also stops automatic UAZ responders before unlocking: the first live rapid-fire trial correctly withheld fire when those vehicles pursued its synthetic report into the impact area. This arrangement is restricted to the opt-in Workbench fixture; production friendly-fire checks are unchanged.
The unchanged single-report baseline fired once and then correctly expired its target report. The first unobstructed rapid-fire run fired seven actual rounds, then correctly withheld the eighth because the older refresh fixture stopped generating sightings halfway through a salvo. That fixture now continues observations until six rounds have fired, leaving two rounds for the final downlink to arrive; its existing 90-second update bound remains. Freshness and the production order deadline are not relaxed. The fixture also waits within its existing deadline when normal crew/status traffic occupies the radio: a zero queue result creates no observation and no longer fails the test immediately.
A further run refused a radio update because the original native aim owner was no longer available, amid nearby combat involving the released test player. The test player is now placed away from the battery; the production rejection remains intact.
The unobstructed eight-round run exposed a completion race after the fixed waits were removed: the native eighth-shot callback completed the activity, but the zero-delay repeater began a ninth aim before HQ observed the completion event. Its normal eight-round guard then marked the already-completed barrage rejected. The shooting-cycle node now returns RUNNING after superclass processing of the last confirmed shot, waiting for normal HQ cleanup. Repeated ticks cannot increment the native counter again. An executable regression covers eight physical events, pending supply, exactly one completion, no ninth aiming request and unchanged unrelated artillery behavior.
Final native run passed all 57 assertions with eight distinct physical projectiles and eight matching commits. The original stock decreased from 27 to 19 shells; the native activity completed naturally and the 25-second replay watch observed no extra projectile or replenishment. Launch intervals measured from mission journal time were 5.1, 4.9, 4.9, 5.9, 6.0, 6.0, 6.1 seconds (5.557 seconds on average; 38.9 seconds from first to eighth launch). This is one isolated firing-range result, not a guaranteed cadence under combat or server load. Initial order delivery and barrel setup occur before the first shot.
The runner recorded `smoke-completed`, source/dependency hashes unchanged, and cleanup confirmed. The generic report still returns nonzero for the same 13 installed dependency/prefab errors seen in the stock baseline; it reports no script/runtime failures or new engine errors. All repository checks passed, including the executable last-shot regression. Removing just the completion wait makes that regression fail.
These results qualify local native Workbench launch behavior and ammunition accounting. Multiplayer replication, actual impact positions and damage are not measured by this fixture. Evidence: `.local/analysis/mortar-cadence-2026-09-27/`.
+48 -34
View File
@@ -1,40 +1,54 @@
# Revivable players and all-unconscious mission failure # Player injuries and team failure
Admitted player bodies receive server-owned casualty protection. Ordinary damage Admitted players keep the same living, treatable body. Otherwise lethal damage
still causes wounds and bleeding. Damage to physical, overall-health and blood is capped just above hit-zone destruction and causes unconsciousness. Continued
hitzones is limited above their configured destruction threshold; an otherwise bleeding still drains blood down to the protected minimum; protection never adds
lethal hit knocks the prisoner unconscious. Resilience can still reach zero, and health or blood. AI retain native lethal damage and recovery rules.
healing damage is passed through unchanged. Further hits and bleeding cannot
finish the casualty. AI retain their normal damage rules. Protection stays with
the original body across reconnects and does not grant a new life or respawn.
The round fails after **30 uninterrupted seconds** with every remaining prisoner Passive blood, wound and resilience recovery use one quarter of their native
unconscious, no conscious reserved prisoner available to reconnect, and no rates. Bleeding prevents passive blood and consciousness recovery. Bandaging
ongoing treatment or observed blood/resilience recovery. Waking up, a treatment stops the bleeding and lets blood improve gradually. With sufficient blood,
in progress, or increasing blood/resilience resets that timer. A passive recovery resilience recovers slowly and the player can eventually wake without an injector.
effect that is present but makes no progress does not prevent failure. An empty This is not an instant or periodic full heal. Saline restores blood through its
server does not start this timer. Mission status explains whether recovery is finite native infusion; epinephrine improves resilience over 30 seconds without
continuing or the team is in the failure grace period. replacing blood or treating wounds. Medicine is not slowed by the passive-rate
setting. Duplicate epinephrine dosing is refused while its effect is active.
Treatment uses the existing medical system. Bandaging and saline remain relevant; The round fails with **“Everybody died.”** after 30 uninterrupted seconds when
ACE Medical epinephrine heals resilience and is not blocked by casualty protection. all remaining prisoners are unconscious, no conscious reserved prisoner can
ACE's own treatment prerequisites still apply (including stopping bleeding and reconnect to help, and nobody has treatment, observed recovery, or viable native
restoring sufficient health). Installing this mission does not itself install passive recovery. A bandaged casualty with a blood/resilience regeneration effect
ACE Medical or add an unlimited epinephrine action. also counts during the engine's initial regeneration delay. Full hit zones,
zero-rate effects, insufficient blood for resilience, and bleeding do not falsely
count as self-recovery. Waking, viable recovery, or treatment resets the timer.
An empty server does not start it. Existing all-dead/missing-body and exhausted
admission handling remains active.
`/esc mission abandon` remains an administrator-only way to end a round explicitly. The `recovery` fixture exercises the real team-failure timer and treatment on an
The all-dead/missing-body fallback and exhausted-admission handling also remain, admitted prisoner. `medical-combat` checks an actual hunter medic's inventory,
for deleted bodies and administrative/native paths outside ordinary damage. bleeding, consumed bandages, slow blood recovery without saline, natural waking,
finite saline/epinephrine and protected fatal blood loss. Fixtures inject initial
injuries and enter at native item-effect completion; they do not certify manual
interactions or remote multiplayer play. ACE medical overrides require separate
compatibility testing. The 25 September lethal-death result is historical and
has been superseded by the restored revivable-player policy.
The isolated `recovery` Workbench fixture exercises damage exceeding full health ## Local validation, 26 September 2026
on health, blood and head hitzones, continued survival while treatment is flagged
active, restoration by native resilience healing, and the actual all-unconscious
failure timer. This is separate from remote-client/manual treatment acceptance.
Validation on 2026-09-23: all repository checks passed after updating the existing Repository checks passed. The updated `medical-combat` engine fixture completed
ServerTick test adapters. The isolated `recovery` run at 32 assertions, including the actual SF UAZ medic's backpack and six injectors,
`/tmp/erri-revivable-native2/logs/logs_2026-09-23_20-02-04` completed 66 assertions native bleeding, bandaging, blood recovery without saline, natural waking after
with no script/runtime failures, unchanged source and verified cleanup. Existing blood replacement, finite epinephrine, and a living unconscious body after fatal
RHS/WCS resource and inherited prefab-keyword errors remain in that engine log. blood damage. No script compilation or medical assertion failed in the final run.
The recovery effect was a native timed resilience-healing effect; an actual ACE The strict runner reported a transient process-cleanup classification failure;
injector action and remote multiplayer treatment were not exercised by this run. a subsequent independent check confirmed the private prefix idle and unchanged
source hashes. Original reports and the unsuccessful diagnostic run are retained
under `.local/analysis/revivable-medical-2026-09-26/`. Loaded external/base assets
still emit their existing warnings; this does not certify remote multiplayer.
The subsequent `recovery` engine run verified natural recovery and active
medical treatment each keeping the full mission alive beyond 30 seconds,
waking the same admitted prisoner, resetting the failure timer, and automatic
team defeat after 30 seconds of unrecoverable unconsciousness. Source remained
unchanged and cleanup was confirmed. The strict asset-error scan still reports
existing inherited resource errors; all recovery assertions passed.
+93
View File
@@ -0,0 +1,93 @@
# Response-team medicine and combat priority
RKS 2 session `1790341620-1` (25 September 2026), journal part 1, confirms the old
casualty floor repeatedly prevented lethal injury. Events 23102–23118 also show
UAZ 6 switching to a routine road patrol while its combat flag was active; events
23162–23371 show subsequent travel with that flag still set. These establish an
order conflict, not the exact reason for every individual failure to fire.
The read-only SSH excerpt is retained in
`.local/analysis/medical-combat-2026-09-25/rks2-journal-excerpt.log`.
Hunter UAZs now fill accessible driver, turret and cargo seats (bounded at 12),
with exactly one cargo medic. US uses Green Berets and USSR uses Spetsnaz native
presets. Modded factions first use optional `ResponseSoldier` and `ResponseMedic`
resources in `Configs/ERR/Content/Registry.conf`, then their enabled character
catalog. The registry supplies RHS ION, AFRF and MARSOC profiles because installed
RHS character catalogs can be empty despite the characters being available.
Catalog fallback prefers SF/recon presets and a medic; the ordinary soldier
fallback comes from the selected faction's group. No wrong-faction medic is
substituted. Missing crew resources defer deployment for 30 seconds before any
vehicle/body is created or allowance consumed. Genuine partial physical spawn
failures still consume their allowance to prevent duplicate forces. Native medic presets retain their authored medical bag, saline, bandages,
tourniquets and morphine. Admission waits for every original occupant to be
seated. The crew's native combat mode is explicitly fire at will.
Each physical soldier receives a finite startup allowance: two epinephrine and
at least two morphine for SF/hunter troops; six of each for medics. Twenty-five
percent of ordinary hostile soldiers receive one of each. Existing stock counts
toward the allowance. Medic additions target the existing backpack when present, with overflow into free
personal pouches only after the bag refuses insertion. Medics do not receive the
optional mine/anti-air grants that could consume their treatment storage.
Insertion failures and completion are logged; completed, pending or looted grants
are never periodically replenished. This does not create AI healers for every
ordinary squad; native medic roles are guaranteed specifically in hunter UAZs.
A native detected/identified contact no older than 15 seconds suspends that
hunter's travel waypoint and pending travel radio order. Driver recovery cancels
its own high-priority boarding action while combat is active. Existing native
combat behavior remains free to fight/dismount. After contacts stop qualifying,
a further 15 quiet seconds permits HQ travel and driver recovery again. Lost,
destroyed, disarmed and old remembered targets cannot hold the patrol forever.
The existing permanent objective-defender exclusions remain unchanged.
Full crews increase the number of physical AI per hunter vehicle. The existing
three initial UAZs and maximum fifteen vehicle allowance remain unchanged.
Historical validation on 25 September 2026 (the lethal-player medical policy
has since been superseded; see [recovery.md](recovery.md)): the `medical-combat` Workbench run completed
31 assertions, including the actual three-seat native UAZ crew and medic stock,
bleeding/bandaging, timed saline, finite epinephrine and lethal blood loss.
Source stayed unchanged during the run and private-prefix cleanup was confirmed.
The strict runner still returned failure for loaded asset errors (localization
references and inherited world/controller properties); there were no script or
runtime failures. Repository medical/equipment/hunter checks passed. This is
local scripted integration evidence, not manual or multiplayer combat acceptance.
Logs, manifests and failed earlier attempts are retained under
`.local/analysis/medical-combat-2026-09-25/`.
## RKS 2 vehicle deployment failure, 26 September 2026
The 14:08 UTC server log contained two rounds. In both, all three opening
UAZ attempts failed with “selected faction has no usable soldier/medic profile”;
the second round also lost the fourth allowance the same way. Enemy faction was
RHS ION. The same journal contains delivered sightings and infantry search orders,
so HQ was operating; no hunter crew reached deployment. The installed ION
character catalog is empty, although RHS supplies stocked ION medic prefabs.
The downloaded journal is retained in `.local/analysis/rks2-hunters-2026-09-26/`.
Response profile overrides resolve this without changing faction identity.
The `hunter-faction` Workbench fixture explicitly selects ION, unlocks the actual
prison gate, checks the seated crew/medic stock, supplies a sighting at HQ's
already-delivered intake boundary, and requires a radio-delivered hunter order
and more than 30 metres of native vehicle movement. It does not certify a live
multiplayer attack, visual detection, or combat accuracy percentages.
The ION native regression exposed a second issue: the crew's modded loadouts
refused the required personal manpack. Each hunter UAZ now receives exactly one
physical R-107M in vehicle cargo before admission. Its original seated crew can
use that manpack as their HQ link; looting, capture, vehicle destruction,
dismounting or mission teardown removes the vehicle link. No medic backpack is
replaced and no lost radio is replenished. Personal radios retain their existing
rules. Radio-fitting and HQ routing both recognize the physical vehicle radio.
Final native validation: all three ION crews admitted with their vehicle radio;
the medic had at least four saline/bandages, six morphine and six epinephrine.
All three UAZs received a contact order through the production radio route;
the fixture confirmed native driving beyond 30 metres. Source hashes were
unchanged during the run and cleanup was confirmed. All fixture assertions
passed; the strict runner's asset scan still reports existing inherited asset
errors. Earlier failed/stalled runs are preserved beside the successful run.
The final complete repository check suite and the targeted vehicle-radio/aim
policy regressions also passed after the radio changes. No server restart,
Workshop publication or Git push was performed.
+13 -5
View File
@@ -1,9 +1,17 @@
# Spectator reason and controls # Spectator controls and recovery
The local observer hint now explains the replicated reason for spectating: the player has used their round's life, joined after extraction began, or could not be spawned. It tells a spectator with a selected survivor about the existing `/esc spectate next` and `/esc spectate previous` commands. Those commands already use the ordinary nonadministrator path; the change does not grant administration, change spawn/identity policy or alter camera control. While spectating, **Left / Right arrow** selects the previous/next eligible teammate. **Enter** (including numpad Enter) toggles between the orbit camera and a player view at the teammate's head, looking along their replicated head-aim direction. Orbit retains mouse-look and zoom. Player view follows posture and vehicle seating; it does not possess the teammate, reproduce their private HUD/ADS camera, or move the unconscious body. With no available/streamed target the camera falls back to the observer anchor. `/esc spectate next|previous` remains available without administrator permission.
When the server has selected no survivor, the hint says that nobody is currently available to follow and that the view will follow a survivor when one becomes available. It omits ineffective cycling advice in that state. Availability is read from the replicated selected entity ID, not from whether that entity has finished streaming to this client, so streaming delay is not mistaken for an empty survivor set. The camera activates `ERR_ObserverContext`, inherited from vanilla's manual-camera context. Dedicated input actions bind the arrows and Enter; normal gameplay input remains inherited from `chimeraInputCommon.conf`. Held keys trigger once; opposing arrows cancel. Camera input is disabled while ordinary menus/dialogs are open. These controls replace raw `Debug.KeyState` polling; the old bit-15 mask is documented by the engine, so inspection alone does not establish it as the cause of the reported missed arrows.
The existing 250 ms observer monitor refreshes the twelve-second hint on first display, a reason change or a change between having/no selected survivor. Stable state does not repeat hints, and switching between two available survivors does not produce another hint. Closing the camera clears the shown flag. If the native hint manager is temporarily absent, the monitor retries without consuming the first display. There are no new timers, input bindings, RPCs, permission checks or server mutations. The 250 ms local monitor records an unconscious controlled body independently of whichever camera is active. When that same body becomes conscious, it asks the native editor manager (Game Master/ArmaVision) to close and explicitly selects the controller's player camera. This also handles a separate manual camera taking over from the mission's observer camera. Asynchronous editor closure or unavailable player cameras are retried for up to ten seconds; refusal is logged rather than permanently forcing the camera. Once returned, conscious admins can open their cameras normally. Round end, body loss, or changed possession cancels the pending handoff. No remote player is possessed and no administrative privileges change.
`tests/test_spectator_guidance.py` executes the actual message and refresh methods for every current reason, a fallback reason, both availability states and all prior/current refresh combinations. It also checks the existing monitoring/command boundaries: no extra timer, state recorded only after a hint exists, close/reopen reset, cycling handled before the admin transport and unchanged admin rejection. These are source-local policy and integration-contract checks. Native compilation, real hint rendering, actual chat input and remote target streaming still require their own tests. The spectator hint explains why the player is observing and how to switch targets/views. Availability uses the replicated selected entity ID, not local streaming completion. The hint refreshes on first display, a reason change, or having/no selected survivor; stable state does not repeatedly show it. Missing/refusing hint managers retain the retry.
## Verification
`test_unconscious_controls.py`, `test_spectator_recovery.py`, `test_spectator_guidance.py`, `test_hint_delivery.py`, and `test_participant_selection.py` exercise production input dispatch, held/opposing/menu keys, Enter toggling, camera/editor recovery retries and cancellation, guidance, and canonical server selection. Native APIs and remote clients are doubled in these tests.
`ERR_UnconsciousCameraProbe` tests native incapacitation, unchanged possession, physical camera tracking, loaded input actions including inherited orbit input, toggling to/from head view, a different manual camera taking over before healing, return to the normal camera, and repeated knockout. It uses a physical stand-in for another player and programmatic input-handler calls; it does not prove physical keyboard delivery or dedicated multiplayer target streaming. Native run results are recorded separately under `.local/analysis/spectator-2026-09-27/`.
On 27 September, the isolated native fixture completed 25 assertions without script errors or VM exceptions. It verified both camera views, the loaded controls, a separate manual camera takeover, restoration after healing, and repeated knockout. The runner subsequently hit a transient cleanup-classification failure; separate follow-up confirmed the private runtime idle and authored sources unchanged. Existing external resource errors remain. Raw results and failed diagnostic iterations are preserved in the evidence directory above. Physical keyboard delivery, remote client cycling, and native Game Master/ArmaVision menu closure remain manual multiplayer checks.
+5 -5
View File
@@ -1,6 +1,6 @@
# Supply convoys # Supply convoys
The production HQ now uses `ERR_SupplyConvoySystem`, `ERR_SupplyConvoyService` and `ERR_SupplyConvoyTactics`, adapting the owned original `EAI_SupplyConvoys.c` to the combined mod's native AI and force ledger. The production HQ now uses `ERR_SupplyConvoySystem`, `ERR_SupplyConvoyService`, `ERR_SupplyConvoyColumn` and `ERR_SupplyConvoyTactics`, adapting the owned original `EAI_SupplyConvoys.c` to the combined mod's native AI and force ledger.
## Configuration ## Configuration
@@ -20,15 +20,15 @@ The ammo truck receives eight random weapons from the convoy faction's native ar
After prison escape and existing force admission, automatic deployment begins after a 30-second grace period. Every five seconds it tests up to eight road formations, for at most 120 startup passes. It uses friendly military outpost, depot and radio-site road approaches within 500 metres, deduplicated at 300 metres. Starting formations are separated by up to one kilometre and retain all existing road, terrain and obstruction checks. After prison escape and existing force admission, automatic deployment begins after a 30-second grace period. Every five seconds it tests up to eight road formations, for at most 120 startup passes. It uses friendly military outpost, depot and radio-site road approaches within 500 metres, deduplicated at 300 metres. Starting formations are separated by up to one kilometre and retain all existing road, terrain and obstruction checks.
The initial road route feeds an ongoing circuit between different friendly bases. Arrival accepts surviving vehicles within 250 metres; a ten-minute travel deadline advances a stuck trip. Convoys pause 45–90 seconds before selecting their next destination. The original carried supplies between bases but did not unload or regenerate them; no artificial unloading is added here. The initial road route feeds an ongoing circuit between different friendly bases. One travel controller paces each column: the lead vehicle follows the route and each follower targets the preceding surviving vehicle. After combat or a new base leg, travel order follows physical approach to the destination while vehicle identities and combat roles remain unchanged. The leader waits when a neighbour gap exceeds 160 metres and resumes below 90 metres; followers regulate their speed and retain stable movement targets. Arrival requires the lead vehicle to physically reach the route endpoint and the followers to close up in order. A timeout never counts as arrival. Commanded vehicles with no five-metre progress retry native navigation after 45 seconds; after three minutes without progress they leave the column, preserving their physical crew and cargo. Combat, boarding, intentional spacing waits and supply stops do not consume that timer. Convoys pause 45–90 seconds after physical arrival before selecting their next destination. The original carried supplies between bases but did not unload or regenerate them; no artificial unloading is added here.
Combat starts from fresh identified native group perception within 400 metres of a surviving convoy vehicle or a delivered HQ sighting. Mounted group origins are not used for this distance, because native groups can retain their original spawn position. Infantry dismounts, approaches the left and right flanks, then searches those sectors. Armed escorts provide cover while the ammo truck withdraws; the troop truck waits until the passengers have dismounted. Orders refresh at a bounded thirty-second interval. Still-mounted infantry receives at most six dismount retries, ten seconds apart, to recover from busy doors or a braking truck. Native boarding waypoints require all surviving members and use a 100-metre approach radius; actual compartment membership determines readiness. Crew retention suppresses both group and individual abandonment priorities only for the original mounted vehicle crew and releases on critical damage or drivetrain failure, allowing native emergency abandonment. Combat starts from fresh identified native group perception within 400 metres of a surviving convoy vehicle or a delivered HQ sighting. Mounted group origins are not used for this distance, because native groups can retain their original spawn position. Infantry dismounts, approaches the left and right flanks, then searches those sectors. Armed escorts provide cover while the ammo truck withdraws; the troop truck waits until the passengers have dismounted and cleared it by 25 metres. Convoy vehicles slow to 8 km/h within 45 metres of their fit dismounted teams and restore cruising speed for travel. Contact-driven tactical orders refresh no more often than every thirty seconds and only after the contact moves at least forty metres; unchanged sightings retain the existing manoeuvres. Combat and boarding suspend column travel, and surviving vehicles regroup before resuming. Still-mounted infantry receives at most six dismount retries, ten seconds apart, to recover from busy doors or a braking truck. Native boarding waypoints require all surviving members and use a 100-metre approach radius; actual compartment membership determines readiness. Crew retention suppresses both group and individual abandonment priorities only for the original mounted vehicle crew and releases on critical damage or drivetrain failure, allowing native emergency abandonment.
After sixty seconds without a fresh contact, the teams reboard under escort cover. A new contact interrupts boarding. After a 120-second boarding deadline, stranded survivors are released and the remaining vehicles continue. A lost troop truck releases the infantry. Player capture stops convoy control permanently, preserving the actors and loot. After sixty seconds without a fresh contact, the teams reboard under escort cover. A new contact interrupts boarding. While soldiers approach or board, their native orders are preserved. A team with no approach or seat progress for thirty seconds can receive up to three boarding retries, with one tick between cancellation and reissue so native reservations can settle. A five-minute hard deadline releases genuinely stranded survivors and lets the remaining vehicles continue. A lost troop truck releases the infantry. Player capture stops convoy control permanently, preserving the actors and loot.
## Validation ## Validation
`tests/test_supply_convoys.py` executes production startup, base selection, arrival, circuit timing, contact freshness and perception boundaries, scoped crew-abandonment priorities, combat quiet time, boarding interruption, timeout and truck-loss policies. The receipt tests cover all eight groups and configured fleets through twenty convoys, including no replacement after losses. Existing dispatch, native seat allocation, cargo, HQ radio and force-ledger regressions remain in the main check suite. `tests/test_convoy_column.py` executes the production spacing, stable-target, physical-arrival, leader-promotion, supply-ownership and stuck-recovery controller against native-boundary doubles. `tests/test_supply_convoys.py` executes production startup, base selection, arrival, circuit timing, contact freshness and perception boundaries, scoped crew-abandonment priorities, combat quiet time, boarding interruption, timeout and truck-loss policies. The receipt tests cover all eight groups and configured fleets through twenty convoys, including no replacement after losses. Existing dispatch, native seat allocation, cargo, HQ radio and force-ledger regressions remain in the main check suite.
`tools/headless_workbench.py --project chat --smoke supply-convoys --rounds 1 --timeout 900` exercises production automatic startup and physical AI in an isolated Workbench profile. Its final controlled contact explicitly tests native dismount/reboarding rather than claiming that an injected report proves perception. Remote-client replication and twenty-convoy performance require dedicated-server testing. Earlier one-convoy evidence in `convoy-migration.md` and `convoy-startup.md` remains historical evidence for those earlier sources. `tools/headless_workbench.py --project chat --smoke supply-convoys --rounds 1 --timeout 900` exercises production automatic startup and physical AI in an isolated Workbench profile. Its final controlled contact explicitly tests native dismount/reboarding rather than claiming that an injected report proves perception. Remote-client replication and twenty-convoy performance require dedicated-server testing. Earlier one-convoy evidence in `convoy-migration.md` and `convoy-startup.md` remains historical evidence for those earlier sources.
+6
View File
@@ -35,6 +35,8 @@
"Configs/Map/MapPlain.conf": "A786DD4868598F15", "Configs/Map/MapPlain.conf": "A786DD4868598F15",
"Configs/Navmesh/Navmesh_GM_Eden_Soldier.conf": "8BBC73472092902E", "Configs/Navmesh/Navmesh_GM_Eden_Soldier.conf": "8BBC73472092902E",
"Configs/Navmesh/Navmesh_GM_Eden_Vehicle.conf": "0228A79846FD8A97", "Configs/Navmesh/Navmesh_GM_Eden_Vehicle.conf": "0228A79846FD8A97",
"Configs/System/ActionContext/ManualCameraContext.conf": "C538807EEE48BED8",
"Configs/System/chimeraInputCommon.conf": "795184CF9AD764DB",
"Configs/System/chimeraMenus.conf": "C747AFB6B750CE9A", "Configs/System/chimeraMenus.conf": "C747AFB6B750CE9A",
"Configs/Systems/MissionSystems.conf": "C50579A2EC48E8EF", "Configs/Systems/MissionSystems.conf": "C50579A2EC48E8EF",
"Prefabs/AI/Groups/Group_Base.et": "000CD338713F2B5A", "Prefabs/AI/Groups/Group_Base.et": "000CD338713F2B5A",
@@ -50,6 +52,8 @@
"Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_HeliPilot.et": "42A502E3BB727CEB", "Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_HeliPilot.et": "42A502E3BB727CEB",
"Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_Sniper.et": "0F6689B491641155", "Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_Sniper.et": "0F6689B491641155",
"Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_Unarmed.et": "2F912ED6E399FF47", "Prefabs/Characters/Factions/BLUFOR/US_Army/Character_US_Unarmed.et": "2F912ED6E399FF47",
"Prefabs/Characters/Factions/BLUFOR/US_Army/GreenBerets/Character_US_SF.et": "36E28C628A2F83CB",
"Prefabs/Characters/Factions/BLUFOR/US_Army/GreenBerets/Character_US_SF_Medic.et": "9A234C5857D92187",
"Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_AR.et": "23ADBBC31B6A3DC6", "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_AR.et": "23ADBBC31B6A3DC6",
"Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_AT.et": "1C78331E156A3D65", "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_AT.et": "1C78331E156A3D65",
"Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_HeliPilot.et": "A62FA97C4EC64F14", "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_HeliPilot.et": "A62FA97C4EC64F14",
@@ -59,6 +63,8 @@
"Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_SL.et": "5436629450D8387A", "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_SL.et": "5436629450D8387A",
"Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_SR.et": "333DA6244C7DA34C", "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_SR.et": "333DA6244C7DA34C",
"Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_Sharpshooter.et": "976AC400219898FA", "Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_Sharpshooter.et": "976AC400219898FA",
"Prefabs/Characters/Factions/OPFOR/USSR_Army/Spetsnaz/Character_USSR_SF.et": "2DB452B3EC386B92",
"Prefabs/Characters/Factions/OPFOR/USSR_Army/Spetsnaz/Character_USSR_SF_Medic.et": "8CA70597606992EC",
"Prefabs/Characters/Footwear/CombatBoots_US_01.et": "DAAFD15478BDE1C3", "Prefabs/Characters/Footwear/CombatBoots_US_01.et": "DAAFD15478BDE1C3",
"Prefabs/Characters/HeadGear/Hat_Patrol_US_01/Hat_Patrol_US_01.et": "F545C1BE7C99954A", "Prefabs/Characters/HeadGear/Hat_Patrol_US_01/Hat_Patrol_US_01.et": "F545C1BE7C99954A",
"Prefabs/Characters/HeadGear/Helmet_PASGT_01/Helmet_PASGT_01_base.et": "8A2B2A544F3ED831", "Prefabs/Characters/HeadGear/Helmet_PASGT_01/Helmet_PASGT_01_base.et": "8A2B2A544F3ED831",
+20 -1
View File
@@ -29,9 +29,28 @@ int main(){
vector away=StandOff({100,120,0},{0,0,0},{-40,0,0});assert(away.x>0); vector away=StandOff({100,120,0},{0,0,0},{-40,0,0});assert(away.x>0);
vector clear=StandOff({300,120,0},{0,0,0},{-40,0,0});assert(clear.x==-40); vector clear=StandOff({300,120,0},{0,0,0},{-40,0,0});assert(clear.x==-40);
vector coincident=StandOff({0,120,0},{0,0,0},{0,0,0});assert(std::isfinite(coincident.x)&&coincident.x>0); vector coincident=StandOff({0,120,0},{0,0,0},{0,0,0});assert(std::isfinite(coincident.x)&&coincident.x>0);
// Clearing the target area specifies a minimum outward SPEED, not a
// per-frame impulse. Repeated calls must not accelerate an already-safe
// radial velocity, even while the aircraft remains inside the radius.
vector outward{25,0,28};
for(int i=0;i<600;i++)outward=StandOff({100,120,0},{0,0,0},outward);
assert(std::abs(outward.x-25)<.001&&outward.z==28);
vector correction=StandOff({170,120,0},{0,0,0},{-40,0,28});
assert(std::abs(correction.x-5)<.001&&correction.z==28);
// The same stationary close-contact observation at different frame rates
// must not inject unbounded speed into the persistent smoothed velocity.
for(int hz: {30,60,120}){
vector v{-40,0,28};
for(int frame=0;frame<hz*5;frame++){
vector desired{-10,0,28};v=v+(desired-v)*(0.6f/hz);
v=StandOff({100,120,0},{0,0,0},v);
assert(v.Length()<40);
}
}
} }
''') ''')
print('PASS: original 15-minute launch/loss cadence, active/captured timer disarm, saved delay reset, stop, 300m orbit and close-contact standoff') print('PASS: original 15-minute launch/loss cadence, active/captured timer disarm, saved delay reset, stop, 300m orbit and bounded close-contact standoff at 30/60/120 Hz')
# Explicit entity deletion must not leave an inactive launch deadline behind. # Explicit entity deletion must not leave an inactive launch deadline behind.
removed=body(path,'void Removed(') removed=body(path,'void Removed(')
+21 -1
View File
@@ -21,7 +21,7 @@ int main(){
p.Committed(45000);assert(!p.Consider(&s,50000));assert(!p.Consider(&s,525000));assert(p.Consider(&s,570000)); p.Committed(45000);assert(!p.Consider(&s,50000));assert(!p.Consider(&s,525000));assert(p.Consider(&s,570000));
s.CommittedFit=6;assert(!p.Consider(&s,600000));s.CommittedFit=0;s.GroundFit=6;assert(!p.Consider(&s,600000)); s.CommittedFit=6;assert(!p.Consider(&s,600000));s.CommittedFit=0;s.GroundFit=6;assert(!p.Consider(&s,600000));
s.ConfirmedLosses=2;assert(!p.Consider(&s,600000));assert(p.Consider(&s,620000)); s.ConfirmedLosses=2;assert(!p.Consider(&s,600000));assert(p.Consider(&s,620000));
s.ArtilleryBusy=true;assert(!p.Consider(&s,630000));s.ArtilleryBusy=false;assert(!p.Consider(&s,640000));assert(p.Consider(&s,660000)); s.ArtilleryBusy=true;assert(!p.Consider(&s,630000));s.ArtilleryBusy=false;assert(p.Consider(&s,640000));assert(p.Consider(&s,660000));
s.BeliefId=2;assert(!p.Consider(&s,670000));assert(!p.Consider(&s,689999));assert(p.Consider(&s,690000)); s.BeliefId=2;assert(!p.Consider(&s,670000));assert(!p.Consider(&s,689999));assert(p.Consider(&s,690000));
auto good=s; auto good=s;
s.Age=91;assert(!p.Consider(&s,700000));s=good;s.Confidence=.69;assert(!p.Consider(&s,700000)); s.Age=91;assert(!p.Consider(&s,700000));s=good;s.Confidence=.69;assert(!p.Consider(&s,700000));
@@ -30,6 +30,26 @@ int main(){
s=good;s.MotionKnown=false;assert(!p.Consider(&s,700000));s=good;s.AircraftReady=false;assert(!p.Consider(&s,700000)); s=good;s.MotionKnown=false;assert(!p.Consider(&s,700000));s=good;s.AircraftReady=false;assert(!p.Consider(&s,700000));
s=good;s.Age=std::numeric_limits<float>::quiet_NaN();assert(!p.Consider(&s,700000)); s=good;s.Age=std::numeric_limits<float>::quiet_NaN();assert(!p.Consider(&s,700000));
assert(!p.Consider(nullptr,700000));assert(!p.Consider(&good,std::numeric_limits<float>::infinity())); assert(!p.Consider(nullptr,700000));assert(!p.Consider(&good,std::numeric_limits<float>::infinity()));
// Artillery blocks authorization, not the history of fresh sightings and
// ground-force shortage. Repeated <45-second clear windows used to starve
// airborne support by restarting its confirmation timer on every salvo.
ERR_AirSupportPolicy blocked;auto contact=good;contact.BeliefId=9;contact.GroundFit=0;contact.ConfirmedLosses=0;
assert(!blocked.Consider(&contact,0));
contact.ArtilleryBusy=true;
assert(!blocked.Consider(&contact,10000));
assert(!blocked.Consider(&contact,46000));
contact.ArtilleryBusy=false;assert(blocked.Consider(&contact,47000));
// Stale intelligence and a changed target still invalidate confirmation.
contact.Age=91;assert(!blocked.Consider(&contact,48000));
contact.Age=5;assert(!blocked.Consider(&contact,49000));
contact.BeliefId=10;assert(!blocked.Consider(&contact,90000));
assert(!blocked.Consider(&contact,134999));assert(blocked.Consider(&contact,135000));
// Busy airspace must not preconfirm a new drop during the 8-minute cooldown.
blocked.Committed(135000);contact.ArtilleryBusy=true;
assert(!blocked.Consider(&contact,614999));
contact.ArtilleryBusy=false;assert(!blocked.Consider(&contact,615000));
assert(!blocked.Consider(&contact,659999));assert(blocked.Consider(&contact,660000));
} }
''') ''')
print('PASS: production airborne memory confidence, persistence, ground coverage, confirmed losses, artillery veto, cooldown and invalid-input rules') print('PASS: production airborne memory confidence, persistence, ground coverage, confirmed losses, artillery veto, cooldown and invalid-input rules')
+234
View File
@@ -0,0 +1,234 @@
"""Run the production column controller against physical-position/native-order doubles."""
from policy_harness import ROOT, body, run
import re
p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyColumn.c'
def cpp(s):
s=re.sub(r'//[^\n]*', '', s).replace('protected ', '').replace('out ', '')
s=s.replace('foreach (', 'for (').replace('null','nullptr')
for typ in ('ERR_ConvoyVehicle','ERR_ConvoyColumnVehicle'):
s=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',s)
for obj in ('m_Task','vehicle','state','scan','promoted','ahead','m_State[i]','m_Task->Vehicles[previous]'):
s=s.replace(obj+'.',obj+'->')
for obj in ('Entity','Group','Waypoint','Route','Receipt','Combat'):
s=s.replace(obj+'.',obj+'->')
for cls in ('ERR_ConvoyColumnRules','ERR_ConvoyDriving','ERR_SupplyConvoyTactics','ERR_HQ','Math','vector','string'):
s=s.replace(cls+'.',cls+'::')
s=s.replace('m_Task->Vehicles[index].', 'm_Task->Vehicles[index]->').replace('(index + 1).ToString()', 'std::to_string(index + 1)')
s=s.replace('float largestGap; bool supplyHold;', 'float largestGap=0; bool supplyHold=false;')
return s
rules='\n'.join(cpp(body(p, x)) for x in ('static bool WaitForRear(', 'static bool TooClose(', 'static float Speed('))
methods='\n'.join(cpp(body(p,x)) for x in ('protected float DistanceToTarget(', 'protected void Reorder(', 'void Reset(', 'protected void Hold(', 'protected bool Progress(', 'bool Tick('))
run(r'''
#include <cassert>
#include <cmath>
#include <vector>
#include <string>
struct string:std::string{using std::string::string;string(std::string s):std::string(s){}template<class...T>static string Format(T...){return "event";}};
struct vector{float x=0;static float DistanceXZ(vector a,vector b){return std::abs(a.x-b.x);}};
namespace Math{float Max(float a,float b){return a>b?a:b;}int Min(int a,int b){return a<b?a:b;}float Round(float x){return std::round(x);}}
namespace ERR_HQ{int stranded=0,retries=0;void Event(string type,string){if(type=="CONVOY STRANDED")stranded++;if(type=="CONVOY ROUTE RETRY")retries++;}}
template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;int Count(){return this->size();}};
struct ERR_ConvoyColumnRules{'''+rules+r'''};
struct Entity{vector position;vector GetOrigin(){return position;}};
struct Waypoint:Entity{};
struct Group{int added=0;void AddWaypoint(Waypoint*){added++;}};
struct Route{Array<vector>points={{0},{400}};int Count(){return points.Count();}vector At(int i){return points[i];}};
struct Combat{bool Held=false;};
struct ERR_ConvoyVehicle{
int Index=0,RouteIndex=0,clears=0;bool Released=false,Arrived=false,SupplyHeld=false;
struct Entity entity;struct Entity* Entity=&entity;struct Group group;struct Group* Group=&group;
struct Combat combat;struct Combat* Combat=&combat;struct Waypoint* Waypoint=nullptr;
void ClearOrder(){if(Waypoint)clears++;delete Waypoint;Waypoint=nullptr;}
void Release(void*){Released=true;ClearOrder();}
bool Order(struct Route* route,float){if(!Waypoint){Waypoint=new struct Waypoint();Waypoint->position=route->At(RouteIndex);Group->AddWaypoint(Waypoint);}return true;}
~ERR_ConvoyVehicle(){ClearOrder();}
};
namespace ERR_ConvoyDriving{bool Restore(void*,ERR_ConvoyVehicle*){return false;}}
namespace ERR_SupplyConvoyTactics{Waypoint* Move(vector p,float){auto w=new Waypoint();w->position=p;return w;}}
struct Receipt{string SourceId="test";};
struct ERR_SupplyConvoyTask{Array<ERR_ConvoyVehicle*>Vehicles;struct Route route;struct Route* Route=&route;int Legs=0;struct Receipt receipt;struct Receipt* Receipt=&receipt;};
struct ERR_ConvoyColumnVehicle{bool Holding=false;vector Progress;float LastProgress=-1,NextRetry=0,NextTarget=0,Speed=-1;int Ahead=-1;};
struct Column{
void* m_Mode=nullptr;ERR_SupplyConvoyTask* m_Task;Array<ERR_ConvoyColumnVehicle*>m_State;
Array<int>m_Order={0,1,2};int m_Leader=-1,m_RouteIndex=0;bool m_RearWait=false;float m_NextLog=0;
void Speed(ERR_ConvoyVehicle*,ERR_ConvoyColumnVehicle* state,float speed){state->Speed=speed;}
'''+methods+r'''};
int main(){
assert(!ERR_ConvoyColumnRules::WaitForRear(160,false));assert(ERR_ConvoyColumnRules::WaitForRear(161,false));
assert(ERR_ConvoyColumnRules::WaitForRear(91,true));assert(!ERR_ConvoyColumnRules::WaitForRear(90,true));
assert(ERR_ConvoyColumnRules::TooClose(39,false));assert(!ERR_ConvoyColumnRules::TooClose(40,false));
assert(ERR_ConvoyColumnRules::TooClose(64,true));assert(!ERR_ConvoyColumnRules::TooClose(65,true));
ERR_ConvoyVehicle a,b,c;b.Index=1;c.Index=2;b.entity.position={-35};c.entity.position={-70};
ERR_ConvoyColumnVehicle sa,sb,sc;ERR_SupplyConvoyTask task;task.Vehicles={&a,&b,&c};Column column{nullptr,&task,{&sa,&sb,&sc}};
assert(column.Tick(0));assert(a.RouteIndex==1&&a.Waypoint&&a.Waypoint->position.x==400);assert(!b.Waypoint&&!c.Waypoint);
a.entity.position={80};column.Tick(1000);assert(b.Waypoint&&b.Waypoint->position.x==80&&sb.Speed==45);assert(!c.Waypoint);
int additions=b.group.added;a.entity.position={110};column.Tick(2000);assert(b.group.added==additions); // stable target, no per-second churn
a.entity.position={200};column.Tick(3000);assert(column.m_RearWait&&!a.Waypoint&&b.Waypoint); // leader waits for laggard
b.entity.position={130};c.entity.position={60};column.Tick(11000);assert(!column.m_RearWait&&a.Waypoint);
assert(b.Waypoint->position.x==200); // native target follows predecessor, never remote base marker
b.SupplyHeld=true;auto supplyOrder=b.Waypoint;column.Tick(12000);assert(!a.Waypoint&&b.Waypoint==supplyOrder&&sb.LastProgress==-1);
b.SupplyHeld=false;b.combat.Held=true;column.Tick(13000);assert(b.Waypoint==supplyOrder&&sb.LastProgress==-1);b.combat.Held=false;
// Real vehicle positions determine arrival; no stale seated-group origin is consulted.
a.entity.position={400};b.entity.position={330};c.entity.position={260};column.Tick(20000);
assert(a.Arrived&&b.Arrived&&c.Arrived&&!a.Waypoint&&!b.Waypoint&&!c.Waypoint);
// Loss promotes the next surviving vehicle at the current route cursor, no replacement/restart at spawn.
column.Reset(20500);assert(column.m_Leader==0&&column.m_RouteIndex==2); // combat reset preserves route progress for a later promotion
a.Release(nullptr);b.Arrived=c.Arrived=false;column.Tick(21000);assert(column.m_Leader==1&&b.RouteIndex==1&&!b.Arrived&&b.Waypoint->position.x==400);
// Commanded stationary time alone retries and eventually releases; holds reset this clock.
ERR_ConvoyVehicle stuck;ERR_ConvoyColumnVehicle ss;
assert(column.Progress(&stuck,&ss,0));assert(column.Progress(&stuck,&ss,45000));assert(ERR_HQ::retries==1&&!stuck.Released);
column.Hold(&stuck,&ss,170000);assert(column.Progress(&stuck,&ss,180000));assert(!stuck.Released);
stuck.entity.position={6};assert(column.Progress(&stuck,&ss,300000));assert(ss.LastProgress==300000);
assert(column.Progress(&stuck,&ss,479999));assert(!column.Progress(&stuck,&ss,480000)&&stuck.Released&&ERR_HQ::stranded==1);
column.Reset(500000,true);assert(sa.LastProgress==-1&&sb.LastProgress==-1&&!column.m_RearWait&&column.m_Leader==-1);
// Post-combat physical order avoids sending the front truck toward a rear vehicle coming toward it.
b.entity.position={100};c.entity.position={300};b.Arrived=c.Arrived=false;column.m_RouteIndex=1;column.Reset(500001,false,true);
assert(column.m_Order[0]==2&&column.m_Order[1]==1&&column.m_Order[2]==0); // released vehicle last
assert(task.Vehicles[1]==&b&&task.Vehicles[2]==&c); // combat roles and canonical identities unchanged
column.Tick(500002);assert(column.m_Leader==2&&!c.Waypoint&&b.Waypoint&&b.Waypoint->position.x==300);
b.entity.position={230};column.Tick(500003);assert(c.Waypoint&&c.Waypoint->position.x==400);
b.Release(nullptr);c.Release(nullptr);assert(!column.Tick(510000));
}
''')
print('PASS: production convoy spacing, physical arrival, stable follower targets, supply ownership, leader promotion and bounded stuck recovery')
q=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c'
orders=body(q,'protected void Orders(').replace('protected ','').replace('m_Task.','m_Task->').replace('vector.','vector::')
run(r'''
#include <cassert>
#include <cmath>
struct vector{float x=0;static float DistanceXZ(vector a,vector b){return std::abs(a.x-b.x);}};
struct Items{int count;int Count(){return count;}};
struct Task{Items Vehicles{6},Teams{2};};
struct Controller{Task task;Task* m_Task=&task;float m_NextOrders=0;vector m_LastOrderContact,m_Contact;bool m_OrdersIssued=false,m_TruckWithdrawn=false;int support=0,flank=0;
void Support(int,bool){support++;}void Flank(int,bool){flank++;}
'''+orders+r'''};
int main(){
Controller c;c.Orders(true,0);assert(c.support==6&&c.flank==2);
c.Orders(false,30000);assert(c.support==6&&c.flank==2&&c.m_NextOrders==60000);
c.m_Contact={39};c.Orders(false,60000);assert(c.support==6&&c.flank==2);
c.m_Contact={40};c.Orders(false,90000);assert(c.support==12&&c.flank==4);
c.m_OrdersIssued=false;c.Orders(true,100000);assert(c.support==18&&c.flank==6); // new engagement can issue at same location
}
''')
print('PASS: convoy combat keeps native manoeuvres for unchanged contacts and refreshes when contact moves')
probe=ROOT/'addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSupplyConvoyProbe.c'
departure=body(probe,'protected int Departure(').replace('protected ','').replace('m_CombatTask.','m_CombatTask->').replace('Vehicles[2].','Vehicles[2]->').replace('Entity.','Entity->').replace('vector.','vector::')
run(r'''
#include <cassert>
#include <cmath>
struct vector{float x=0;static float DistanceXZ(vector a,vector b){return std::abs(a.x-b.x);}};
void Print(const char*){}
struct Entity{vector position;vector GetOrigin(){return position;}};
struct Vehicle{bool Released=false;struct Entity entity;struct Entity* Entity=&entity;};
struct Bases{int Count(){return 2;}};
struct Task{bool Stopped=false;int Legs=0;Vehicle* Vehicles[3];struct Bases Bases;};
struct Probe{Task* m_CombatTask;int m_Phase=3,m_TestLegs=0;vector m_RouteResumePosition;bool m_AllMoved=true;
'''+departure+r'''};
int main(){
Task task;Vehicle truck;task.Vehicles[2]=&truck;Probe p{&task};truck.entity.position={200};
assert(p.Departure()==0&&p.m_Phase==3); // earlier regroup displacement cannot pass
task.Legs=1;assert(p.Departure()==0&&p.m_Phase==4&&p.m_RouteResumePosition.x==200);
assert(p.Departure()==0);truck.entity.position={219};assert(p.Departure()==0);
truck.entity.position={220};assert(p.Departure()==1&&p.m_Phase==5);
truck.Released=true;assert(p.Departure()==-1);
}
''')
smoke=(ROOT/'addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSmoke.c').read_text()
convoy=smoke.split('else if (m_ERRSmokeStep == 106)')[1].split('else if (m_ERRSmokeStep == 105)')[0]
assert 'Transition(ERR_Phase.DEFEAT)' in convoy and 'm_ERRSmokeStep = 1061' in convoy
assert 'damage.Kill' not in convoy and 'm_ERRSmokeStep = 9' not in convoy
print('PASS: native convoy proof excludes pre-departure movement and ends via explicit fixture reset, not prohibited player death')
boarding=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyBoardingProgress.c'
retry=body(boarding,'bool Retry(')
refresh=body(q,'protected void RefreshBoarding(').replace('protected ','').replace('foreach (','for (').replace('m_Task.','m_Task->').replace('team.','team->').replace('Vehicles[2].','Vehicles[2]->').replace('BoardingProgress.','BoardingProgress->').replace('Receipt.','Receipt->').replace('ERR_HQ.','ERR_HQ::').replace('Math.','Math::').replace('string.','string::').replace('ERR_SupplyTeam team','ERR_SupplyTeam* team')
run(r'''
#include <cassert>
#include <cmath>
#include <vector>
struct string{template<class...T>static string Format(T...){return {};}};
namespace Math{float Round(float x){return std::round(x);}}
namespace ERR_HQ{void Event(const char*,string){}}
struct ERR_ConvoyBoardingProgress{int Seated=-1,Retries=0;float Distance=-1,LastProgress=0,NextRetry=0;
'''+retry+r'''};
struct ERR_SupplyTeam{bool Released=false,aboard=false;float BoardingRetryAt=0,distance=200;int seated=0,clears=0,adds=0;ERR_ConvoyBoardingProgress* BoardingProgress=nullptr;
bool Aboard(void*,bool){return aboard;}int BoardingCount(void*,float& d){d=distance;return seated;}void Clear(){clears++;}void Add(int){adds++;}};
struct Vehicle{void* Entity=nullptr;};struct Receipt{const char* SourceId="test";};
struct Task{std::vector<ERR_SupplyTeam*>Teams;Vehicle* Vehicles[3];struct Receipt receipt;struct Receipt* Receipt=&receipt;};
struct Tactics{Task* m_Task;int Board(void*,bool){return 1;}
'''+refresh+r'''};
int main(){
ERR_ConvoyBoardingProgress p;assert(!p.Retry(0,200,0));
assert(!p.Retry(0,194,29000));assert(!p.Retry(0,194,58000));assert(p.Retry(0,194,59000));
assert(!p.Retry(1,100,60000));assert(!p.Retry(1,100,89999));assert(p.Retry(1,100,90000));
assert(p.Retry(1,100,120000));assert(!p.Retry(1,100,150000)&&p.Retries==3); // bounded even if no progress
Task task;Vehicle truck;task.Vehicles[2]=&truck;ERR_SupplyTeam team,finished,released;finished.aboard=true;released.Released=true;
task.Teams={&team,&finished,&released};Tactics t{&task};t.RefreshBoarding(0);assert(team.BoardingProgress&&!finished.BoardingProgress&&!released.BoardingProgress);
t.RefreshBoarding(30000);assert(team.clears==1&&team.adds==0&&team.BoardingRetryAt==31000);
t.RefreshBoarding(30999);assert(team.adds==0);t.RefreshBoarding(31000);assert(team.adds==1&&team.BoardingRetryAt==0);
team.seated=6;team.distance=10;t.RefreshBoarding(40000);assert(team.clears==1);t.RefreshBoarding(69999);assert(team.clears==1);
t.RefreshBoarding(70000);assert(team.clears==2);team.aboard=true;t.RefreshBoarding(71000);assert(team.adds==1); // do not disturb completed boarding
delete team.BoardingProgress;
}
''')
print('PASS: boarding retries only after approach/seat progress stops, capped at three, with delayed native reissue and completed-team preservation')
safe='\n'.join(body(q,s) for s in ('protected float InfantryDistance(', 'protected void ProtectInfantry('))
safe=safe.replace('protected ','').replace('foreach (','for (')
safe=safe.replace('AICarMovementComponent movement = AICarMovementComponent.Cast(vehicle.Entity.FindComponent(AICarMovementComponent));', 'AICarMovementComponent* movement = &vehicle->Entity->movement;')
for typ in ('IEntity','ERR_SupplyTeam','ERR_ConvoyVehicle'):safe=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',safe)
for obj in ('m_Task','team','member','vehicle','movement'):safe=safe.replace(obj+'.',obj+'->')
for cls in ('ERR_Actors','CompartmentAccessComponent','Math','vector'):safe=safe.replace(cls+'.',cls+'::')
run(r'''
#include <cassert>
#include <cmath>
#include <vector>
struct vector{float x=0;static float DistanceXZ(vector a,vector b){return std::abs(a.x-b.x);}};
namespace Math{float Min(float a,float b){return a<b?a:b;}}
struct AICarMovementComponent{float speed=99;void SetCruiseSpeed(float s){speed=s;}};
struct IEntity{vector position;bool fit=true,aboard=false;AICarMovementComponent movement;vector GetOrigin(){return position;}};
namespace ERR_Actors{bool CanFight(IEntity* m){return m&&m->fit;}}
namespace CompartmentAccessComponent{IEntity* GetVehicleIn(IEntity* m){return m->aboard?m:nullptr;}}
struct ERR_SupplyTeam{bool Released=false;std::vector<IEntity*>Members;};
struct ERR_ConvoyVehicle{bool Released=false;IEntity* Entity;};
struct Task{std::vector<ERR_SupplyTeam*>Teams;std::vector<ERR_ConvoyVehicle*>Vehicles;};
struct Tactics{Task* m_Task;
'''+safe+r'''};
int main(){
IEntity truck{{0}},car{{100}},foot{{30}},passenger{{0}};passenger.aboard=true;ERR_SupplyTeam team{false,{&foot,&passenger}};
ERR_ConvoyVehicle a{false,&truck},b{false,&car};Task task{{&team},{&a,&b}};Tactics t{&task};
assert(t.InfantryDistance(&truck)==30);t.ProtectInfantry();assert(truck.movement.speed==8&&car.movement.speed==35);
foot.aboard=true;t.ProtectInfantry();assert(truck.movement.speed==35&&car.movement.speed==35);
foot.aboard=false;foot.fit=false;assert(t.InfantryDistance(&truck)>100000);foot.fit=true;team.Released=true;assert(t.InfantryDistance(&truck)>100000);
team.Released=false;a.Released=true;truck.movement.speed=99;t.ProtectInfantry();assert(truck.movement.speed==99); // never retake a released vehicle
}
''')
print('PASS: slow only owned vehicles near fit dismounted infantry; ignore passengers, casualties and released teams/vehicles')
restore=body(p,'static bool Restore(').replace('ERR_GameMode mode','void* mode').replace('ERR_ConvoyVehicle vehicle','ERR_ConvoyVehicle* vehicle')
restore=restore.replace('SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(vehicle.Entity.FindComponent(SCR_AIVehicleUsageComponent));','SCR_AIVehicleUsageComponent* usage = vehicle->Entity->usage;')
restore=restore.replace('SCR_AIGroupUtilityComponent utility', 'SCR_AIGroupUtilityComponent* utility')
for obj in ('vehicle','Group','utility','usage'):restore=restore.replace(obj+'.',obj+'->')
restore=restore.replace('ERR_ConvoyMountedCrew.','ERR_ConvoyMountedCrew::')
run(r'''
#include <cassert>
struct SCR_AIVehicleUsageComponent{bool pilot=true;bool CanBePiloted(){return pilot;}};
struct Entity{SCR_AIVehicleUsageComponent* usage=nullptr;};
struct SCR_AIGroupUtilityComponent{void* m_VehicleMgr=this;bool known=false;int added=0;bool IsUsableVehicle(Entity*){return known;}void AddUsableVehicle(SCR_AIVehicleUsageComponent*){known=true;added++;}};
struct Group{SCR_AIGroupUtilityComponent* utility=nullptr;SCR_AIGroupUtilityComponent* GetGroupUtilityComponent(){return utility;}};
struct ERR_ConvoyVehicle{bool Released=false,valid=true;struct Group group;struct Group* Group=&group;struct Entity entity;struct Entity* Entity=&entity;void* Driver=this;bool CrewValid(void*){return valid;}};
namespace ERR_ConvoyMountedCrew{bool healthyOwned=true;bool Retain(void*,Entity*){return healthyOwned;}}
'''+restore+r'''
int main(){
int mode;ERR_ConvoyVehicle car;SCR_AIGroupUtilityComponent utility;SCR_AIVehicleUsageComponent usage;car.group.utility=&utility;car.entity.usage=&usage;
assert(!Restore(nullptr,&car)&&!Restore(&mode,nullptr));car.Released=true;assert(!Restore(&mode,&car));car.Released=false;
car.valid=false;assert(!Restore(&mode,&car));car.valid=true;ERR_ConvoyMountedCrew::healthyOwned=false;assert(!Restore(&mode,&car));ERR_ConvoyMountedCrew::healthyOwned=true;
car.group.utility=nullptr;assert(!Restore(&mode,&car));car.group.utility=&utility;utility.m_VehicleMgr=nullptr;assert(!Restore(&mode,&car));utility.m_VehicleMgr=&utility;
car.entity.usage=nullptr;assert(!Restore(&mode,&car));car.entity.usage=&usage;usage.pilot=false;assert(!Restore(&mode,&car));usage.pilot=true;
assert(Restore(&mode,&car)&&utility.known&&utility.added==1);assert(!Restore(&mode,&car)&&utility.added==1);
}
''')
print('PASS: restore missing native driving registration only for healthy owned seated crews; preserve existing association and refuse invalid/released vehicles')
+1
View File
@@ -78,5 +78,6 @@ int main(){
''') ''')
build=body(p,'static bool Build(') build=body(p,'static bool Build(')
assert 'candidateIndex < 33' in build assert 'candidateIndex < 33' in build
assert build.index('ERR_SupplyConvoyRules.Separated(') < build.index('Prefer(distance,')
assert build.index('Prefer(distance,') < build.index('ERR_ConvoyRules.GroundClear(') assert build.index('Prefer(distance,') < build.index('ERR_ConvoyRules.GroundClear(')
print('PASS: actual road interpolation/projection/orientation, six35m starts,200m forward tail, preserved bends, short/far/degenerate/nonfinite/overdense/hairpin refusal and deterministic candidate ties') print('PASS: actual road interpolation/projection/orientation, six35m starts,200m forward tail, preserved bends, short/far/degenerate/nonfinite/overdense/hairpin refusal and deterministic candidate ties')
+10 -7
View File
@@ -7,12 +7,13 @@ def native(signature):
return s.replace('perceptionComp.', 'perceptionComp->').replace('m_CombatComponent.', 'm_CombatComponent->') return s.replace('perceptionComp.', 'perceptionComp->').replace('m_CombatComponent.', 'm_CombatComponent->')
run(r''' run(r'''
#include <cassert> #include <cassert>
#include <cmath>
enum class EAISkill{REGULAR,CYLON}; enum class EAISkill{REGULAR,CYLON};
struct SCR_AIThreatSystem{}; struct SCR_AIThreatSystem{};
struct PerceptionComponent{float factor=0;void SetPerceptionFactor(float f){factor=f;}}; struct PerceptionComponent{float factor=0;void SetPerceptionFactor(float f){factor=f;}};
struct Native{ struct Native{
int calls=0;EAISkill sampled=EAISkill::CYLON; float error=1;int calls=0;EAISkill sampled=EAISkill::CYLON;
float GetRandomFactor(EAISkill s,float mu){calls++;sampled=s;return mu+1;} float GetRandomFactor(EAISkill s,float mu){calls++;sampled=s;return mu+error;}
void UpdatePerceptionFactor(PerceptionComponent* p,SCR_AIThreatSystem*){calls++;p->factor=.4f;} void UpdatePerceptionFactor(PerceptionComponent* p,SCR_AIThreatSystem*){calls++;p->factor=.4f;}
}; };
struct Combat:Native{ struct Combat:Native{
@@ -27,15 +28,17 @@ struct Aim:Native{
}; };
int main(){ int main(){
Aim aim;Combat combat;PerceptionComponent perception;SCR_AIThreatSystem threat; Aim aim;Combat combat;PerceptionComponent perception;SCR_AIThreatSystem threat;
aim.GetRandomFactor(EAISkill::CYLON,0);assert(aim.sampled==EAISkill::CYLON); assert(aim.GetRandomFactor(EAISkill::CYLON,0)==1);assert(aim.sampled==EAISkill::CYLON);
aim.m_CombatComponent=&combat;aim.GetRandomFactor(EAISkill::CYLON,0);assert(aim.sampled==EAISkill::CYLON); aim.m_CombatComponent=&combat;assert(aim.GetRandomFactor(EAISkill::CYLON,0)==1);assert(aim.sampled==EAISkill::CYLON);
combat.bound=true;aim.GetRandomFactor(EAISkill::CYLON,0);assert(aim.sampled==EAISkill::REGULAR); combat.bound=true;assert(std::abs(aim.GetRandomFactor(EAISkill::CYLON,0)-.9f)<.00001f);assert(aim.sampled==EAISkill::REGULAR);
assert(std::abs(aim.GetRandomFactor(EAISkill::CYLON,5)-5.9f)<.00001f);
aim.error=-2;assert(std::abs(aim.GetRandomFactor(EAISkill::CYLON,0)+1.8f)<.00001f);aim.error=1;
combat.UpdatePerceptionFactor(&perception,&threat);assert(perception.factor==2.5f&&combat.calls==0); combat.UpdatePerceptionFactor(&perception,&threat);assert(perception.factor==2.5f&&combat.calls==0);
combat.m_fEquipmentPerceptionFactor=3;combat.m_fPerceptionFactor=.5f; combat.m_fEquipmentPerceptionFactor=3;combat.m_fPerceptionFactor=.5f;
combat.UpdatePerceptionFactor(&perception,&threat);assert(perception.factor==3.75f); combat.UpdatePerceptionFactor(&perception,&threat);assert(perception.factor==3.75f);
combat.UpdatePerceptionFactor(nullptr,&threat);assert(combat.calls==0); combat.UpdatePerceptionFactor(nullptr,&threat);assert(combat.calls==0);
combat.bound=false;combat.UpdatePerceptionFactor(&perception,&threat);assert(perception.factor==.4f&&combat.calls==1); combat.bound=false;combat.UpdatePerceptionFactor(&perception,&threat);assert(perception.factor==.4f&&combat.calls==1);
aim.GetRandomFactor(EAISkill::CYLON,0);assert(aim.sampled==EAISkill::CYLON); assert(aim.GetRandomFactor(EAISkill::CYLON,0)==1);assert(aim.sampled==EAISkill::CYLON);
} }
''') ''')
print('PASS: actual scoped REGULAR aim error and alert perception; preserves equipment multiplier and restores vanilla behavior when unbound') print('PASS: actual scoped 10% tighter REGULAR aim error and alert perception; preserves equipment multiplier and restores vanilla behavior when unbound')
+61
View File
@@ -0,0 +1,61 @@
"""Execute actual medicine allowance/insertion coordinator with native inventory adapters."""
import re
from policy_harness import ROOT, run
p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyMedicine.c'
s=p.read_text();s=s[s.index('class ERR_EnemyMedicine'):]
s=re.sub(r'//[^\n]*','',s).replace('protected ','').replace('ref ','').replace('class ERR_EnemyMedicine','struct ERR_EnemyMedicine')
s=s.replace('array<IEntity>', 'Array<IEntity*>').replace('foreach (','for (').replace('ResourceName','string')
s=s.replace('InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent))','actor->inventory').replace('BaseInventoryStorageComponent.Cast(item.FindComponent(BaseInventoryStorageComponent))','item->bag').replace('ERR_HQ.Cast(mode.Command())','mode->Command()')
for cls in ('IEntity','ERR_GameMode','SCR_AIGroup','InventoryStorageManagerComponent','BaseInventoryStorageComponent','ERR_HQ'):
s=re.sub(r'\b'+cls+r' (\w+)',cls+r'* \1',s)
s=s.replace('ERR_EnemyEquipmentCallback m_Operation','ERR_EnemyEquipmentCallback* m_Operation')
for obj in ('actor','existing','item','mode','inventory','m_Operation','hq'):
s=s.replace(obj+'.',obj+'->')
s=s.replace('GetPrefabData().','GetPrefabData()->').replace('Hunters().','Hunters()->')
for ns in ('ERR_EnemyEquipmentRules','ERR_EnemyMedicalCatalog','ERR_HQ','Math','EStoragePurpose','string'):
s=s.replace(ns+'.',ns+'::')
s=s.replace('string::Format(', 'Format(').replace('null','nullptr')
s=re.sub(r'\b(bool|int|float|\w+\*) (\w+);',r'\1 \2 {};',s).replace('\n}\n','\n};\n')
run(r'''
#include <cassert>
#include <string>
#include <vector>
#include <algorithm>
struct string:std::string {using std::string::string;string(const std::string& s):std::string(s){}bool Contains(string x){return find(x)!=npos;}void ToLower(){std::transform(begin(),end(),begin(),::tolower);}};
template<class...T>string Format(string,T...){return "event";}
template<class T>struct Array:std::vector<T>{int Count(){return this->size();}};
struct Prefab{string path;string GetPrefabName(){return path;}};
struct BaseInventoryStorageComponent{};
struct InventoryStorageManagerComponent;
struct IEntity{Prefab prefab;bool owned=true;InventoryStorageManagerComponent* inventory=nullptr;BaseInventoryStorageComponent* bag=nullptr;Prefab* GetPrefabData(){return &prefab;}int GetOrigin(){return 0;}};
struct SCR_AIGroup{};
struct ERR_EnemyEquipmentCallback{bool Finished=false,Succeeded=false;};
namespace EStoragePurpose{enum{PURPOSE_DEPOSIT};}
struct InventoryStorageManagerComponent{Array<IEntity*> items;int attempts=0,accepted=0;bool fullBag=false,full=false,async=false;ERR_EnemyEquipmentCallback* pending=nullptr;
void GetItems(Array<IEntity*>& out){out=items;}
bool TrySpawnPrefabToStorage(string prefab,BaseInventoryStorageComponent* bag,int,int,ERR_EnemyEquipmentCallback* callback){attempts++;if(full||(bag&&fullBag))return false;accepted++;auto* item=new IEntity;item->prefab.path=prefab;items.push_back(item);pending=callback;if(!async){callback->Finished=true;callback->Succeeded=true;}return true;}
};
struct Hunters{bool Contains(SCR_AIGroup*){return false;}};
struct ERR_HQ{Hunters hunters;Hunters* Hunters(){return &hunters;}static void Event(string,string){}};
struct ERR_GameMode{ERR_HQ hq;ERR_HQ* Command(){return &hq;}};
namespace ERR_EnemyEquipmentRules {bool Eligible(ERR_GameMode*,SCR_AIGroup*,IEntity* a){return a&&a->owned;}}
namespace ERR_EnemyMedicalCatalog {const string EPI="epi",MORPHINE="morphine";bool Medic(string p){return p.Contains("medic");}bool Specialist(string p){return p.Contains("_sf");}}
namespace Math{int RandomInt(int,int){return 0;}}
'''.replace('Hunters hunters;Hunters* Hunters()', 'struct Hunters hunters;struct Hunters* Hunters()')+s+r'''
int main(){ERR_GameMode mode;SCR_AIGroup group;IEntity actor;InventoryStorageManagerComponent inventory;actor.inventory=&inventory;actor.prefab.path="_sf_medic";
IEntity backpack;backpack.prefab.path="backpack";BaseInventoryStorageComponent bag;backpack.bag=&bag;inventory.items.push_back(&backpack);
IEntity authored;authored.prefab.path="morphine";inventory.items.push_back(&authored);
ERR_EnemyMedicine grant;inventory.fullBag=true;for(int i=0;i<20;i++)grant.Check(&mode,&group,&actor);
assert(grant.m_Done&&grant.m_Issued==11&&inventory.accepted==11&&inventory.attempts==22); // six epi plus five missing morphine; refused bag falls back once
int attempts=inventory.attempts;inventory.items.clear();for(int i=0;i<5;i++)grant.Check(&mode,&group,&actor);assert(inventory.attempts==attempts); // no refill after loot
InventoryStorageManagerComponent blocked;actor.inventory=&blocked;blocked.async=true;ERR_EnemyMedicine pending;for(int i=0;i<35;i++)pending.Check(&mode,&group,&actor);assert(pending.m_Done&&blocked.accepted==1); // pending operation quarantined, never duplicated
InventoryStorageManagerComponent unavailable;actor.inventory=&unavailable;unavailable.full=true;ERR_EnemyMedicine noSpace;noSpace.Check(&mode,&group,&actor);assert(noSpace.m_Done&&unavailable.accepted==0);
InventoryStorageManagerComponent specialStock;actor.inventory=&specialStock;actor.prefab.path="soldier_sf";
ERR_EnemyMedicine special;for(int i=0;i<8;i++)special.Check(&mode,&group,&actor);
assert(special.m_Done&&specialStock.accepted==4);int epi=0,morphine=0;
for(auto* item:specialStock.items){epi+=item->prefab.path=="epi";morphine+=item->prefab.path=="morphine";}
assert(epi==2&&morphine==2); // Every special-forces soldier has two finite injectors.
actor.owned=false;ERR_EnemyMedicine captured;captured.Check(&mode,&group,&actor);assert(!captured.m_Started);captured.Stop();actor.owned=true;captured.Check(&mode,&group,&actor);assert(!captured.m_Started);
}
''')
print('PASS: actual finite medicine grants, authored stock deduction, full-backpack refusal/pouch fallback, no loot replenishment, unresolved-operation quarantine and ownership/stop guards')
+4 -2
View File
@@ -14,8 +14,9 @@ code=code.replace('ref array<','Array<').replace('array<','Array<').replace('ref
code=code.replace('static const ResourceName','inline static const ResourceName').replace('void ERR_EnemyRadio(','ERR_EnemyRadio(') code=code.replace('static const ResourceName','inline static const ResourceName').replace('void ERR_EnemyRadio(','ERR_EnemyRadio(')
code=re.sub(r'\bERR_GameMode (?=\w)','ERR_GameMode* ',code) code=re.sub(r'\bERR_GameMode (?=\w)','ERR_GameMode* ',code)
code=code.replace('foreach (','for (').replace('null','nullptr') code=code.replace('foreach (','for (').replace('null','nullptr')
for name in ('ERR_EnemyEquipmentRules','ERR_HQ','Replication','SCR_Global','ERR_GameMode','EStoragePurpose'): for name in ('ERR_EnemyEquipmentRules','ERR_HQ','Replication','SCR_Global','ERR_GameMode','EStoragePurpose','Math'):
code=code.replace(name+'.',name+'::') code=code.replace(name+'.',name+'::')
code=code.replace('ERR_HQ hq','ERR_HQ* hq').replace('hq.Hunters().','hq->Hunters()->').replace('hq.Hunters()','hq->Hunters()')
for name in ('actor','inventory','item','radio','agent','m_Group','m_Operation','mode'): for name in ('actor','inventory','item','radio','agent','m_Group','m_Operation','mode'):
code=re.sub(r'\b'+name+r'\.',name+'->',code) code=re.sub(r'\b'+name+r'\.',name+'->',code)
code=re.sub(r'\b(bool|int|IEntity|SCR_AIGroup|ERR_EnemyEquipmentCallback\*) (\w+);',r'\1 \2{};',code) code=re.sub(r'\b(bool|int|IEntity|SCR_AIGroup|ERR_EnemyEquipmentCallback\*) (\w+);',r'\1 \2{};',code)
@@ -61,7 +62,8 @@ struct Group{Array<Agent*>agents;int players=0,faction=1;bool slave=false,expand
struct ERR_GameMode{inline static ERR_GameMode*current=nullptr;bool active=true;static ERR_GameMode*Get(){return current;}bool Active(){return active;}int EnemyFaction(){return 1;}}; struct ERR_GameMode{inline static ERR_GameMode*current=nullptr;bool active=true;static ERR_GameMode*Get(){return current;}bool Active(){return active;}int EnemyFaction(){return 1;}};
struct Replication{inline static bool server=true;static bool IsServer(){return server;}}; struct Replication{inline static bool server=true;static bool IsServer(){return server;}};
struct SCR_Global{inline static bool editor=false;static bool IsEditMode(){return editor;}}; struct SCR_Global{inline static bool editor=false;static bool IsEditMode(){return editor;}};
struct ERR_HQ{inline static Array<string>events;static void Event(string,string message){events.Insert(message);}}; struct Math{static int Max(int a,int b){return std::max(a,b);}};
struct ERR_HQ{static ERR_HQ* Get(){return nullptr;} ERR_HQ* Hunters(){return this;} int VehicleRadioCapability(IEntity){return 0;} inline static Array<string>events;static void Event(string,string message){events.Insert(message);}};
struct ERR_EnemyEquipmentRules{static bool Owned(ERR_GameMode*m,Group*g,IEntity a){return m&&g&&a&&a->eligible&&a->group==g;}static bool Eligible(ERR_GameMode*m,Group*g,IEntity a){return Owned(m,g,a);}}; struct ERR_EnemyEquipmentRules{static bool Owned(ERR_GameMode*m,Group*g,IEntity a){return m&&g&&a&&a->eligible&&a->group==g;}static bool Eligible(ERR_GameMode*m,Group*g,IEntity a){return Owned(m,g,a);}};
struct Inventory{Array<IEntity>items;Array<int>requests;bool basicFits=true,manpackFits=true,accept=true;int callback=0;ScriptedInventoryOperationCallback*last=nullptr; struct Inventory{Array<IEntity>items;Array<int>requests;bool basicFits=true,manpackFits=true,accept=true;int callback=0;ScriptedInventoryOperationCallback*last=nullptr;
void GetItems(Array<IEntity>&out){out=items;} void GetItems(Array<IEntity>&out){out=items;}
+58
View File
@@ -0,0 +1,58 @@
"""Execute fixes for the 26 September log failures against boundary doubles."""
from policy_harness import ROOT, body, run
r=ROOT/'addons/core/Scripts/Game/ERR/10_AI'
cleanup=body(r/'ERR_HunterCrewRecovery.c','static void Complete(')
cleanup=cleanup.replace('SCR_AIVehicleBehavior behavior','Behavior* behavior').replace('IEntity actor','Actor* actor')
cleanup=cleanup.replace('SCR_ChimeraAIAgent agent = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(actor));','Agent* agent = actor->agent;')
cleanup=cleanup.replace('array<ref AIActionBase> actions = {};','Actions actions;').replace('agent.','agent->').replace('m_UtilityComponent.','m_UtilityComponent->').replace('behavior.Complete()','behavior->Complete()')
run('''#include <cassert>
#include <vector>
#include <algorithm>
struct Behavior {int completions=0;void Complete(){completions++;}};
struct Actions:std::vector<Behavior*>{bool Contains(Behavior* b){return std::find(begin(),end(),b)!=end();}};
struct Utility {void* m_AIInfo=this;Actions queued;void GetActions(Actions& out){out=queued;}};
struct Agent {Utility* m_UtilityComponent=nullptr;};struct Actor{Agent* agent=nullptr;};
'''+cleanup+'''
int main(){Behavior b;Actor actor;Complete(&b,nullptr);Complete(&b,&actor);assert(!b.completions);
Agent agent;actor.agent=&agent;Complete(&b,&actor);assert(!b.completions);
Utility utility;agent.m_UtilityComponent=&utility;Complete(&b,&actor);assert(!b.completions);
utility.queued.push_back(&b);utility.m_AIInfo=nullptr;Complete(&b,&actor);assert(!b.completions);
utility.m_AIInfo=&utility;Complete(&b,&actor);assert(b.completions==1);
utility.queued.clear();Complete(&b,&actor);assert(b.completions==1);}
''')
# Execute the actual HQ eligibility branch. Its success permits the existing
# radio request path, never directly a shot; per-shot checks remain separate.
tick=body(r/'ERR_MortarSystem.c','void Tick(')
a=tick.index(' vector predicted;');b=tick.index(' selected = battery;',a)
preflight=tick[a:b].replace('vector predicted;','Vec predicted;').replace('ERR_MortarPrediction.Predict','Predict').replace('battery.Trace','battery->Trace').replace('predicted.ToString()','std::string("reported")')
run('''#include <cassert>
#include <string>
struct Vec{};struct Battery{int holds=0;void Trace(std::string,std::string,std::string){holds++;}};
struct Test{bool prediction=true,safe=true;int predictions=0,safetyChecks=0,orders=0;std::string SafetyReason="friendly";
bool Predict(void*,float,float,Vec&){predictions++;return prediction;}
bool Safe(Battery*,Vec,float){safetyChecks++;return safe;}
void Tick(Battery* battery){void* report=nullptr;float now=100;for(int i=0;i<1;i++){
'''+preflight+'''orders++;}}};
int main(){Test t;Battery battery;t.prediction=false;t.Tick(&battery);assert(!t.orders&&!t.safetyChecks);
t.prediction=true;t.safe=false;t.Tick(&battery);assert(!t.orders&&battery.holds==1);
t.safe=true;t.Tick(&battery);assert(t.orders==1&&t.safetyChecks==2);}
''')
# Prevent the exact mounted Defend regression from returning: mounted escorts
# keep Move-to-cover/native vehicle combat, while infantry retain flank/search.
tactics=r/'ERR_SupplyConvoyTactics.c'
assert 'DefendWaypoint()' not in body(tactics,'protected void Support(')
assert 'orders.Add(Move(support))' in body(tactics,'protected void Support(')
assert 'SearchWaypoint()' in body(tactics,'protected void Flank(')
print('PASS: stale/deleted AI utility cannot receive Complete; mortar HQ preflight withholds unsafe orders; mounted convoy cover never creates infantry Defend')
# The native RHS fixture reproduces this boundary: the convenience API boards
# before applying a group faction. Keep production affiliation ahead of entry.
flight=r/'ERR_ReconFlightComponent.c'
prepare=body(flight,'protected bool PrepareCrew(')
assert 'SpawnCharacterInCompartment(' not in prepare
assert prepare.index('ERR_Actors.SetFaction(initialCrew,') < prepare.index('access.GetInVehicle(')
assert prepare.index('m_Crew.AddAgent(') < prepare.index('access.GetInVehicle(')
assert 'SpawnCharacterInCompartment(' not in body(flight,'void Initialize(')
finish=body(flight,'protected bool FinishCrew(')
assert finish.index('now - m_Started > 45000') < finish.index('m_Assault.Preparing()')
print('PASS: flight crew affiliation and group precede native boarding; preparation remains bounded even while passengers wait')
+4 -3
View File
@@ -69,7 +69,7 @@ for name in ('unit', 'utility'):
for name in ('Entity', 'Group', 'Waypoint', 'm_Perception'): for name in ('Entity', 'Group', 'Waypoint', 'm_Perception'):
movement = movement.replace(name+'.', name+'->') movement = movement.replace(name+'.', name+'->')
movement = movement.replace('GetPhysics().', 'GetPhysics()->').replace('unit->Repaths.ToString()', 'std::to_string(unit->Repaths)') movement = movement.replace('GetPhysics().', 'GetPhysics()->').replace('unit->Repaths.ToString()', 'std::to_string(unit->Repaths)')
movement = movement.replace('string.Format', 'Format').replace('ERR_HQ.Event', 'Event') movement = movement.replace('string.Format', 'Format').replace('ERR_HQ.Event', 'Event').replace('ERR_HunterCombat.Active', 'ERR_HunterCombat::Active')
movement = re.sub(r'\b(bool|float|Vec) (\w+);', r'\1 \2 {};', movement) movement = re.sub(r'\b(bool|float|Vec) (\w+);', r'\1 \2 {};', movement)
run(r''' run(r'''
#include <cassert> #include <cassert>
@@ -82,6 +82,7 @@ struct Utility {Perception* m_Perception;};
struct Physics {Vec GetVelocity(){return {};}}; struct Physics {Vec GetVelocity(){return {};}};
struct Vehicle {Vec pos;Physics physics;Vec GetOrigin(){return pos;}Physics* GetPhysics(){return &physics;}}; struct Vehicle {Vec pos;Physics physics;Vec GetOrigin(){return pos;}Physics* GetPhysics(){return &physics;}};
struct Group {Utility utility;Utility* GetGroupUtilityComponent(){return &utility;}void AddWaypoint(void*){}}; struct Group {Utility utility;Utility* GetGroupUtilityComponent(){return &utility;}void AddWaypoint(void*){}};
namespace ERR_HunterCombat {bool fresh=false;bool Active(Group*){return fresh;}};
struct Waypoint {Vec pos;bool arrived=false;bool IsWithinCompletionRadius(Group*){return arrived;}Vec GetOrigin(){return pos;}void SetCompletionRadius(float){}}; struct Waypoint {Vec pos;bool arrived=false;bool IsWithinCompletionRadius(Group*){return arrived;}Vec GetOrigin(){return pos;}void SetCompletionRadius(float){}};
struct MovingHunter {Vehicle* Entity;struct Group* Group;struct Waypoint* Waypoint;Vec Destination,ProgressPosition;float ProgressAt=1,NextMotionLog=0;int Repaths=0;std::string Source="test";void ClearOrder(){Waypoint=nullptr;}}; struct MovingHunter {Vehicle* Entity;struct Group* Group;struct Waypoint* Waypoint;Vec Destination,ProgressPosition;float ProgressAt=1,NextMotionLog=0;int Repaths=0;std::string Source="test";void ClearOrder(){Waypoint=nullptr;}};
Waypoint spawned;Waypoint* Spawn(Vec pos){spawned.pos=pos;return &spawned;} Waypoint spawned;Waypoint* Spawn(Vec pos){spawned.pos=pos;return &spawned;}
@@ -96,8 +97,8 @@ int main(){
ObserveMovement(&hunter,120001);ObserveMovement(&hunter,180001);assert(hunter.Repaths==3); ObserveMovement(&hunter,120001);ObserveMovement(&hunter,180001);assert(hunter.Repaths==3);
ObserveMovement(&hunter,240001);assert(hunter.Repaths==3); ObserveMovement(&hunter,240001);assert(hunter.Repaths==3);
vehicle.pos={20,0};ObserveMovement(&hunter,241001);assert(hunter.Repaths==0); vehicle.pos={20,0};ObserveMovement(&hunter,241001);assert(hunter.Repaths==0);
perception.m_aTargets.empty=false;ObserveMovement(&hunter,400000);assert(hunter.Repaths==0); perception.m_aTargets.empty=false;ERR_HunterCombat::fresh=true;ObserveMovement(&hunter,400000);assert(hunter.Repaths==0);
perception.m_aTargets.empty=true;ObserveMovement(&hunter,400001);assert(hunter.Repaths==0); ERR_HunterCombat::fresh=false;ObserveMovement(&hunter,400001);assert(hunter.Repaths==0);
ObserveMovement(&hunter,460000);assert(hunter.Repaths==1); ObserveMovement(&hunter,460000);assert(hunter.Repaths==1);
} }
''') ''')
+32 -6
View File
@@ -2,23 +2,27 @@
import re import re
from policy_harness import ROOT,body,run from policy_harness import ROOT,body,run
p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_GeneratedSites.c' p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_GeneratedSites.c'
s=body(p,'ERR_PrepareResult Tick(') s=body(p,'protected bool SkipOptional(')+'\n'+body(p,'ERR_PrepareResult Tick(')
s=s.replace('ERR_SiteDefinition site','Site* site').replace('site.','site->').replace('m_Terrain.Site','m_Terrain.SiteAt') s=s.replace('ERR_SiteDefinition site','Site* site').replace('site.','site->').replace('m_Terrain.Site','m_Terrain.SiteAt')
s=s.replace('System.GetTickCount','Clock::Now').replace('Replication.IsServer','Server').replace('Math.RandomFloat','RandomFloat').replace('ERR_PrepareResult.','Result::') s=s.replace('System.GetTickCount','Clock::Now').replace('Replication.IsServer','Server').replace('Math.RandomFloat','RandomFloat').replace('ERR_PrepareResult.','Result::')
s=s.replace('float floor;','float floor=0;') s=s.replace('float floor;','float floor=0;').replace('protected ', '').replace('ref ERR_SiteDefinition retained', 'Site* retained').replace('retained.', 'retained->').replace('m_Definition.', 'm_Definition->').replace('ERR_LocationKind.', 'Kind::')
run(r''' run(r'''
#include <cassert> #include <cassert>
#include <string> #include <string>
#include <vector> #include <vector>
#include <algorithm>
using string=std::string; using string=std::string;
struct vector{float a[3];float& operator[](int i){return a[i];}};vector Vector(float x,float y,float z){return {{x,y,z}};} struct vector{float a[3];float& operator[](int i){return a[i];}};vector Vector(float x,float y,float z){return {{x,y,z}};}
namespace Result{enum Value{FAILED=-1,PENDING,READY};}using ERR_PrepareResult=Result::Value; namespace Result{enum Value{FAILED=-1,PENDING,READY};}using ERR_PrepareResult=Result::Value;
namespace Clock {int now=0,step=1;int Now(){int t=now;now+=step;return t;}} namespace Clock {int now=0,step=1;int Now(){int t=now;now+=step;return t;}}
bool Server(){return true;}float RandomFloat(float a,float b){return (a+b)/2;}template<class...T>void PrintFormat(T...){} bool Server(){return true;}float RandomFloat(float a,float b){return (a+b)/2;}template<class...T>void PrintFormat(T...){}
struct Site{vector Position=Vector(10,0,20);float Heading=0;bool PlacementResolved=false;string Id="site";}; namespace Kind{enum{PRISON,RADIO,EXTRACTION,OUTPOST,DEPOT,MORTAR,HOSPITAL};}
template<class T>struct Array:std::vector<T>{using std::vector<T>::operator=;int Count(){return this->size();}}; struct Site{int Kind=Kind::PRISON;bool required=false;string Label="Test site";vector Position=Vector(10,0,20);float Heading=0;bool PlacementResolved=false;string Id="site";};
struct Terrain{bool clear=true;int queries=0;bool SiteAt(Site*,vector,float,float& height){queries++;height=42;return clear;}}; template<class T>struct Array:std::vector<T>{using std::vector<T>::operator=;int Count(){return this->size();}int Find(T v){auto i=std::find(this->begin(),this->end(),v);return i==this->end()?-1:i-this->begin();}void Remove(int i){this->erase(this->begin()+i);}void InsertAt(T v,int i){this->insert(this->begin()+i,v);}};
struct Definition{Array<Site*> Sites;Site* required=nullptr;bool Valid(){return !required||Sites.Find(required)>=0;}};
struct Terrain{bool clear=true;Site* refused=nullptr;int queries=0;bool SiteAt(Site* site,vector,float,float& height){queries++;height=42;return clear&&site!=refused;}};
struct Scheduler{ struct Scheduler{
Definition definition;Definition* m_Definition=&definition;
bool m_Started=true;ERR_PrepareResult m_Result=Result::PENDING;int m_Index=0,m_Attempts=0,m_Began=0; bool m_Started=true;ERR_PrepareResult m_Result=Result::PENDING;int m_Index=0,m_Attempts=0,m_Began=0;
vector m_Low=Vector(0,0,0),m_High=Vector(10000,0,10000);Array<Site*>m_Pending;Terrain m_Terrain;string m_Error; vector m_Low=Vector(0,0,0),m_High=Vector(10000,0,10000);Array<Site*>m_Pending;Terrain m_Terrain;string m_Error;
bool Separated(Site*,vector,bool){return true;} bool Separated(Site*,vector,bool){return true;}
@@ -31,6 +35,28 @@ int main(){
{Clock::now=0;Scheduler s;Site a;s.m_Pending={&a};s.m_Terrain.clear=false;assert(s.Tick()==Result::PENDING);assert(s.m_Terrain.queries<10&&!a.PlacementResolved);Clock::now=30001;assert(s.Tick()==Result::FAILED&&!s.m_Error.empty()&&!a.PlacementResolved);int calls=s.m_Terrain.queries;assert(s.Tick()==Result::FAILED&&s.m_Terrain.queries==calls);} {Clock::now=0;Scheduler s;Site a;s.m_Pending={&a};s.m_Terrain.clear=false;assert(s.Tick()==Result::PENDING);assert(s.m_Terrain.queries<10&&!a.PlacementResolved);Clock::now=30001;assert(s.Tick()==Result::FAILED&&!s.m_Error.empty()&&!a.PlacementResolved);int calls=s.m_Terrain.queries;assert(s.Tick()==Result::FAILED&&s.m_Terrain.queries==calls);}
{Clock::now=0;Scheduler s;Site a;s.m_Pending={&a};s.m_Attempts=12000;assert(s.Tick()==Result::READY&&a.Position[0]==10&&a.Position[2]==20&&a.PlacementResolved);} {Clock::now=0;Scheduler s;Site a;s.m_Pending={&a};s.m_Attempts=12000;assert(s.Tick()==Result::READY&&a.Position[0]==10&&a.Position[2]==20&&a.PlacementResolved);}
{Clock::now=30001;Scheduler s;Site a;s.m_Pending={&a};assert(s.Tick()==Result::READY&&a.Position[0]==10&&a.Position[2]==20);} {Clock::now=30001;Scheduler s;Site a;s.m_Pending={&a};assert(s.Tick()==Result::READY&&a.Position[0]==10&&a.Position[2]==20);}
// Each optional type can fail after exhausting candidates without blocking the next site.
for(int kind : {Kind::OUTPOST,Kind::HOSPITAL,Kind::DEPOT}){
Clock::now=0;Scheduler s;Site a,b;a.Kind=kind;a.Id="unplaceable";b.Id="next";
s.m_Pending={&a,&b};s.definition.Sites={&a,&b};s.m_Terrain.refused=&a;s.m_Attempts=12000;
while(s.Tick()==Result::PENDING){}
assert(s.m_Result==Result::READY&&s.m_Error.empty()&&!a.PlacementResolved&&b.PlacementResolved);
assert(s.definition.Sites.size()==1&&s.definition.Sites[0]==&b);
int calls=s.m_Terrain.queries;assert(s.Tick()==Result::READY&&s.m_Terrain.queries==calls);
}
// A deadline skip gets a fresh candidate/time budget for the following optional site.
{Clock::now=30001;Scheduler s;Site a,b;a.Kind=Kind::OUTPOST;b.Kind=Kind::HOSPITAL;
s.m_Pending={&a,&b};s.definition.Sites={&a,&b};s.m_Terrain.refused=&a;
while(s.Tick()==Result::PENDING){}assert(s.m_Result==Result::READY&&b.Position[0]!=10&&b.PlacementResolved);}
// Required objectives (including mortars) still fail; no unsafe terrain is accepted.
for(int kind : {Kind::PRISON,Kind::RADIO,Kind::EXTRACTION,Kind::MORTAR}){
Clock::now=0;Scheduler s;Site a;a.Kind=kind;s.m_Pending={&a};s.definition.Sites={&a};s.m_Attempts=12000;s.m_Terrain.clear=false;
assert(s.Tick()==Result::FAILED&&s.definition.Sites[0]==&a&&!a.PlacementResolved);
}
// Optional by type can still be indispensable for minimum accessible radio intel.
{Clock::now=0;Scheduler s;Site before,a,after;a.Kind=Kind::HOSPITAL;
s.definition.Sites={&before,&a,&after};s.definition.required=&a;s.m_Pending={&a};s.m_Attempts=12000;s.m_Terrain.clear=false;
assert(s.Tick()==Result::FAILED&&s.definition.Sites.size()==3&&s.definition.Sites[1]==&a&&!a.PlacementResolved);}
} }
''') ''')
print('PASS: actual generation scheduler bounds each tick, enforces deadline/candidate ceiling, validates fallback, commits only accepted positions, and terminates without repeated searches') print('PASS: actual generation scheduler bounds each tick, enforces deadline/candidate ceiling, validates fallback, commits only accepted positions, skips only dispensable sites, preserves required objectives/intel, and resets the next search budget')
+1
View File
@@ -47,6 +47,7 @@ struct RplId {static int Invalid(){return -1;}};
struct Game {int spawns=0;SCR_ManualCamera* SpawnEntityPrefab(Resource*,int,EntitySpawnParams){spawns++;return &camera;}int GetWorld(){return 1;}CameraManager* GetCameraManager(){return &manager;}} game; struct Game {int spawns=0;SCR_ManualCamera* SpawnEntityPrefab(Resource*,int,EntitySpawnParams){spawns++;return &camera;}int GetWorld(){return 1;}CameraManager* GetCameraManager(){return &manager;}} game;
Game* GetGame(){return &game;} Game* GetGame(){return &game;}
struct Controller { struct Controller {
void ERR_UpdateRecoveryCamera(ERR_GameMode*){}
int nativeChanges=0,starts=0;void ERR_StartGuide(){starts++;} int nativeChanges=0,starts=0;void ERR_StartGuide(){starts++;}
int m_ERRGuidePhase=-1;string m_ERRGuideMessage; int m_ERRGuidePhase=-1;string m_ERRGuideMessage;
Actor* controlled=&actor;int m_ERRObserverReason=ERR_ObserverReason::NONE; Actor* controlled=&actor;int m_ERRObserverReason=ERR_ObserverReason::NONE;
+5 -3
View File
@@ -8,7 +8,7 @@ H=BASE/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c'
A=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQAmmoSampler.c' A=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQAmmoSampler.c'
def cpp(s): def cpp(s):
s=s.replace('protected ','').replace('ERR_HQPursuitRules.', 'ERR_HQPursuitRules::').replace('string.Format','Format') s=s.replace('ERR_HQLocalAwareness.Priority','ERR_HQLocalAwareness::Priority').replace('protected ','').replace('ERR_HQPursuitRules.', 'ERR_HQPursuitRules::').replace('string.Format','Format')
for t in ('ERR_HQStatusReport','ERR_HQAmmoSnapshot','ERR_HQUnit','ERR_HQRoute'): for t in ('ERR_HQStatusReport','ERR_HQAmmoSnapshot','ERR_HQUnit','ERR_HQRoute'):
s=re.sub(r'\b'+t+r' (?=\w)',t+'* ',s) s=re.sub(r'\b'+t+r' (?=\w)',t+'* ',s)
for v in ('stock','report','unit','route'): for v in ('stock','report','unit','route'):
@@ -45,10 +45,12 @@ struct ERR_HQRoute{int MessageId=0;string Payload;};
struct Network{bool available=true;int routes=0;ERR_HQRoute* Route(void*){routes++;return available?new ERR_HQRoute:nullptr;}}; struct Network{bool available=true;int routes=0;ERR_HQRoute* Route(void*){routes++;return available?new ERR_HQRoute:nullptr;}};
struct Journal{int seq=0,writes=0;int Message(){return ++seq;}void Write(string,string,int){writes++;}}; struct Journal{int seq=0,writes=0;int Message(){return ++seq;}void Write(string,string,int){writes++;}};
struct Task{float ArrivedAt=0;}; struct Task{float ArrivedAt=0;};
struct ERR_HQUnit{struct Task* Task=nullptr;void* Radio=this;ERR_HQRoute* Uplink=nullptr;ERR_HQStatusReport* PendingStatus=nullptr; struct ERR_HQLocalAwareness{static bool Priority(float,float,float){return false;}};
struct ERR_HQUnit{float NextContact=0;struct Task* Task=nullptr;void* Radio=this;ERR_HQRoute* Uplink=nullptr;ERR_HQStatusReport* PendingStatus=nullptr;
float NextStatus=0;ERR_HQAmmoReporting AmmoReporting;Sampler StatusSampler;bool visible=true,contact=false,sweep=false; float NextStatus=0;ERR_HQAmmoReporting AmmoReporting;Sampler StatusSampler;bool visible=true,contact=false,sweep=false;
ERR_HQUnit(){AmmoReporting.Initialize(6,0);}}; ERR_HQUnit(){AmmoReporting.Initialize(6,0);}};
struct HQ{Network m_Network;Journal m_Journal;int delivered=0; struct HQ{void ContactReport(ERR_HQUnit*,float){}
Network m_Network;Journal m_Journal;int delivered=0;
bool OriginOccupied(void*,ERR_HQUnit*){return false;} bool OriginOccupied(void*,ERR_HQUnit*){return false;}
void Observe(ERR_HQUnit* u,float){if(!u->Uplink&&u->Radio&&u->visible&&m_Network.available){u->contact=true;u->Uplink=new ERR_HQRoute;}} void Observe(ERR_HQUnit* u,float){if(!u->Uplink&&u->Radio&&u->visible&&m_Network.available){u->contact=true;u->Uplink=new ERR_HQRoute;}}
void ObserveBody(ERR_HQUnit*,float){} void ObserveBody(ERR_HQUnit*,float){}
+89
View File
@@ -0,0 +1,89 @@
"""Execute the actual local evidence cache and priority/radio intake methods."""
import re
from policy_harness import ROOT,body,run
base=ROOT/'addons/core/Scripts/Game/ERR/10_AI'
s=(base/'ERR_HQLocalAwareness.c').read_text().split('modded class')[0]
h=(base/'ERR_HQLocalHandoff.c').read_text().split('class ERR_HQObservationHistory')[1]
h='class ERR_HQObservationHistory'+h
q='\n'.join(body(base/'ERR_HQ.c',sig) for sig in ['protected void ContactReport(', 'protected void QueueReports('])
def trans(s):
s=s.replace('protected ','').replace('ref ','').replace('class ','struct ').replace('\n}\n','\n};\n')
s=s.replace('array<ERR_HQReport>', 'Array<ERR_HQReport*>').replace('array<IEntity>','Array<IEntity>').replace('array<float>','Array<float>')
for t in ['ERR_HQReport','ERR_HQObservationHistory','ERR_HQUnit','ERR_HQRoute','SCR_AIGroup']:
s=re.sub(r'\b'+t+r' (\w+)',t+r'* \1',s)
s=s.replace('foreach (','for (').replace('null','nullptr')
for c in ['ERR_ForceRules','ERR_HQMemoryRules','ERR_HQLocalAwareness','Math']:
s=s.replace(c+'.',c+'::')
for c in ['known','report','source','history','unit','route']:
s=re.sub(r'\b'+c+r'\.',c+'->',s)
s=s.replace('m_Sound.', 'm_Sound->').replace('m_Visual[i].','m_Visual[i]->')
s=s.replace('unit->LocalAwareness.','unit->LocalAwareness->').replace('unit->SentObservations.','unit->SentObservations->')
s=s.replace('m_Mode.Settings().Runtime','m_Mode.Settings().Runtime')
s=s.replace('ERR_HQReport* m_Sound;', 'ERR_HQReport* m_Sound=nullptr;').replace('float m_NextSound;', 'float m_NextSound=0;')
return s.replace('unit->Task.', 'unit->Task->')
run(r'''
#include <cassert>
#include <algorithm>
#include <cmath>
#include <string>
#include <vector>
using string=std::string;
struct Actor{};using IEntity=Actor*;
struct SCR_AIGroup:Actor{};
struct vector{float v[3]={0,0,0};float& operator[](int i){return v[i];}};
vector Vector(float x,float y,float z){return {{x,y,z}};}
namespace Math{float Floor(float f){return std::floor(f);}}
namespace ERR_ForceRules{bool Finite(float f){return std::isfinite(f);}}
namespace ERR_HQMemoryRules{bool PositionValid(vector p){return std::isfinite(p[0])&&std::isfinite(p[1])&&std::isfinite(p[2]);}}
template<class T>struct Array:std::vector<T>{void Clear(){this->clear();}int Count(){return this->size();}
void Insert(T x){this->push_back(x);}void Remove(int i){this->erase(this->begin()+i);}int Find(T x){auto p=std::find(this->begin(),this->end(),x);return p==this->end()?-1:p-this->begin();}};
struct ERR_HQReport{IEntity Target=nullptr;SCR_AIGroup* Source=nullptr;vector Position;float ObservedAt=0;
int MessageId=0;string TravelMode="unknown";string Weapon="unknown";
ERR_HQReport* Snapshot(){return new ERR_HQReport(*this);}string Description(float){return TravelMode;}};
'''+trans(h)+trans(s)+r'''
struct ERR_HQRoute{int MessageId=0;string Payload;};
struct Task{float ArrivedAt=0;};
struct ERR_HQUnit{SCR_AIGroup* Group=nullptr;IEntity Radio=nullptr;ERR_HQLocalAwareness own;
ERR_HQLocalAwareness* LocalAwareness=&own;ERR_HQObservationHistory hist;ERR_HQObservationHistory* SentObservations=&hist;
ERR_HQRoute* Uplink=nullptr;ERR_HQReport* PendingReport=nullptr;struct Task* Task=nullptr;
float NextContact=0,NextReport=0,NextStatus=0;bool status=false;};
struct Settings{int Runtime(string){return 10;}};
struct Mode{struct Settings Settings(){return {};}};
struct Journal{int id=0;int Message(){return ++id;}void Write(string,string,int){}};
struct HQ{Mode m_Mode;Journal m_Journal;bool network=true,occupied=false;
ERR_HQRoute* ObservationRoute(ERR_HQUnit*){return network?new ERR_HQRoute:nullptr;}
bool OriginOccupied(IEntity,ERR_HQUnit*){return occupied;}
void StatusReport(ERR_HQUnit* u,float now){if(!u->Uplink&&now>=u->NextStatus){u->Uplink=new ERR_HQRoute;u->status=true;u->NextStatus=now+180000;}}
void Observe(ERR_HQUnit*,float){} void ObserveBody(ERR_HQUnit*,float){} void Sweep(ERR_HQUnit*,float){}
'''+trans(q)+r'''
};
int main(){
SCR_AIGroup group;Actor enemy;ERR_HQLocalAwareness cache;ERR_HQObservationHistory history;
ERR_HQReport r;r.Target=&enemy;r.Source=&group;r.Position=Vector(20,0,40);r.ObservedAt=100;r.Weapon="rifle";
cache.Remember(&r,100);r.Position[0]=900;r.Weapon="launcher";
auto held=cache.Peek(1200,&history);assert(held&&held->Position[0]==20&&held->Weapon=="rifle"&&held->ObservedAt==100);
assert(cache.Peek(30100,&history)&&!cache.Peek(30101,&history)); // original time, never refresh on reads
r.ObservedAt=32000;cache.Remember(&r,32000);assert(!cache.CaptureDue(&enemy,32999)&&cache.CaptureDue(&enemy,33000));
held=cache.Peek(33000,&history);history.Queued(held->Target,held->ObservedAt,33000);cache.Queued(held,33000);
cache.Remember(&r,33000);assert(!cache.Peek(33000,&history)); // same observation cannot replay
cache.Hear(&group,Vector(12,8,-1),34000,34000);auto sound=cache.Peek(34000,&history);
assert(sound&&!sound->Target&&sound->Source==&group&&sound->Position[0]==75&&sound->Position[2]==-75);
assert(sound->TravelMode=="heard gunfire"&&sound->Weapon=="unknown");
cache.Queued(sound,34000);cache.Hear(&group,Vector(800,0,0),35000,35000);
assert(!cache.Peek(63999,&history)&&cache.Peek(64000,&history));
cache.Clear();assert(!cache.Peek(64000,&history));
Actor targets[40];for(int i=0;i<40;i++){r.Target=&targets[i];r.ObservedAt=70000+i;cache.Remember(&r,70000+i);}
assert(cache.m_Visual.Count()==16);cache.Clear();
HQ h;ERR_HQUnit u;u.Group=&group;r.Target=&enemy;r.ObservedAt=1000;
u.own.Remember(&r,1000);h.QueueReports(&u,2000);assert(u.PendingReport&&!u.status&&u.PendingReport->ObservedAt==1000);
auto inFlight=u.Uplink;r.ObservedAt=3000;u.own.Remember(&r,3000);h.QueueReports(&u,4000);assert(u.Uplink==inFlight);
u.Uplink=nullptr;u.PendingReport=nullptr;r.ObservedAt=16000;u.own.Remember(&r,16000);
h.QueueReports(&u,16000);assert(u.status&&!u.PendingReport); // bounded status delay under continuous contacts
u.Uplink=nullptr;u.status=false;h.QueueReports(&u,17000);assert(u.PendingReport&&u.PendingReport->ObservedAt==16000);
u.Uplink=nullptr;u.PendingReport=nullptr;r.ObservedAt=23000;u.own.Remember(&r,23000);
h.network=false;h.ContactReport(&u,23000);assert(!u.Uplink&&u.own.Peek(24000,&u.hist));
h.network=true;h.ContactReport(&u,24000);assert(!u.Uplink); // failed-route retry bounded
h.ContactReport(&u,28000);assert(u.PendingReport&&u.PendingReport->ObservedAt==23000);
}
''')
print('PASS: captured sighting retention, immutable details, expiry, replay rejection, bounded sound uncertainty/cooldown, radio loss, priority and overdue-status fairness')
+1 -1
View File
@@ -83,7 +83,7 @@ int main(){
// Per-source watermark rejects duplicate/replayed/future/stale sightings including failed-route attempts. // Per-source watermark rejects duplicate/replayed/future/stale sightings including failed-route attempts.
{ERR_HQObservationHistory history;IEntity target;assert(history.CanQueue(&target,100,100));history.Queued(&target,100,100); {ERR_HQObservationHistory history;IEntity target;assert(history.CanQueue(&target,100,100));history.Queued(&target,100,100);
assert(!history.CanQueue(&target,100,101)&&!history.CanQueue(&target,99,101));assert(history.CanQueue(&target,101,101)); assert(!history.CanQueue(&target,100,101)&&!history.CanQueue(&target,99,101));assert(history.CanQueue(&target,101,101));
assert(!history.CanQueue(&target,102,101)&&!history.CanQueue(&target,101,602)); assert(!history.CanQueue(&target,102,101)&&history.CanQueue(&target,101,602)&&history.CanQueue(&target,101,30101)&&!history.CanQueue(&target,101,30102));
assert(!history.CanQueue(&target,std::numeric_limits<float>::quiet_NaN(),602));} assert(!history.CanQueue(&target,std::numeric_limits<float>::quiet_NaN(),602));}
{ERR_HQObservationHistory history;IEntity targets[65];for(int i=0;i<64;i++){history.Queued(&targets[i],100,100);}assert(!history.CanQueue(&targets[64],100,100)); {ERR_HQObservationHistory history;IEntity targets[65];for(int i=0;i<64;i++){history.Queued(&targets[i],100,100);}assert(!history.CanQueue(&targets[64],100,100));
assert(history.CanQueue(&targets[64],120101,120101));assert(!history.CanQueue(&targets[0],100,120101));} assert(history.CanQueue(&targets[64],120101,120101));assert(!history.CanQueue(&targets[0],100,120101));}
+5 -3
View File
@@ -4,7 +4,7 @@ from pathlib import Path
from policy_harness import ROOT,body,run from policy_harness import ROOT,body,run
p=Path(os.environ.get('ERR_HQ_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c')) p=Path(os.environ.get('ERR_HQ_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c'))
s='\n'.join(body(p,x) for x in ('protected bool OriginOccupied(', 'protected ERR_HQRoute ObservationRoute(', 'protected void Observe(', 'protected void QueueReports(')) s='\n'.join(body(p,x) for x in ('protected bool OriginOccupied(', 'protected ERR_HQRoute ObservationRoute(', 'protected void Observe(', 'protected void QueueReports('))
s=s.replace('protected ','').replace('array<IEntity>','Arr<IEntity>').replace('array<ref ERR_HQVisualContact>','Arr<ERR_HQVisualContact*>').replace('foreach (','for (') s=s.replace('ERR_HQLocalAwareness.Priority','ERR_HQLocalAwareness::Priority').replace('protected ','').replace('array<IEntity>','Arr<IEntity>').replace('array<ref ERR_HQVisualContact>','Arr<ERR_HQVisualContact*>').replace('foreach (','for (')
for name in ('ERR_HQUnit','ERR_HQRoute','ERR_HQReport','ERR_HQVisualContact'): for name in ('ERR_HQUnit','ERR_HQRoute','ERR_HQReport','ERR_HQVisualContact'):
s=re.sub(r'\b'+name+r' (\w+)',name+r'* \1',s) s=re.sub(r'\b'+name+r' (\w+)',name+r'* \1',s)
for name in ('unit','other','route','own','seen','report'): for name in ('unit','other','route','own','seen','report'):
@@ -33,12 +33,14 @@ struct ERR_HQReport{int MessageId=0,Position=0;IEntity Target=nullptr;Group*Sour
struct Settings{int Runtime(string){return 5;}}; struct Settings{int Runtime(string){return 5;}};
struct Mode{struct Settings values;struct Settings*Settings(){return &values;}}; struct Mode{struct Settings values;struct Settings*Settings(){return &values;}};
struct Task{float ArrivedAt=0;}; struct Task{float ArrivedAt=0;};
struct ERR_HQUnit{struct Group*Group=nullptr;struct Utility*Utility=nullptr;IEntity Radio=nullptr;float NextReport=0;ERR_HQRoute*Uplink=nullptr;ERR_HQReport*PendingReport=nullptr;Selector ReportSelection;History SentObservations;int StatusSampler=1;struct Task*Task=nullptr;}; struct ERR_HQLocalAwareness{static bool Priority(float,float,float){return false;}};
struct ERR_HQUnit{float NextContact=0,NextStatus=0;struct Group*Group=nullptr;struct Utility*Utility=nullptr;IEntity Radio=nullptr;float NextReport=0;ERR_HQRoute*Uplink=nullptr;ERR_HQReport*PendingReport=nullptr;Selector ReportSelection;History SentObservations;int StatusSampler=1;struct Task*Task=nullptr;};
struct Network{ERR_HQRoute*own=nullptr,*local=nullptr;IEntity ownCarrier=nullptr;int attempts=0;Arr<IEntity>lastBusy; struct Network{ERR_HQRoute*own=nullptr,*local=nullptr;IEntity ownCarrier=nullptr;int attempts=0;Arr<IEntity>lastBusy;
ERR_HQRoute*Route(IEntity caller){return caller==ownCarrier?own:nullptr;} ERR_HQRoute*Route(IEntity caller){return caller==ownCarrier?own:nullptr;}
ERR_HQRoute*LocalRoute(Group*source,int,Arr<IEntity>&busy){attempts++;lastBusy=busy;if(!local||busy.Contains(local->Nodes[0]->Carrier))return nullptr;local->LocalSource=source;return local;}}; ERR_HQRoute*LocalRoute(Group*source,int,Arr<IEntity>&busy){attempts++;lastBusy=busy;if(!local||busy.Contains(local->Nodes[0]->Carrier))return nullptr;local->LocalSource=source;return local;}};
struct Journal{int next=0,reports=0;int Message(){return ++next;}void Write(string kind,string,int){if(kind=="REPORT")reports++;}}; struct Journal{int next=0,reports=0;int Message(){return ++next;}void Write(string kind,string,int){if(kind=="REPORT")reports++;}};
struct HQ{Mode mode;Mode*m_Mode=&mode;Network m_Network;Journal m_Journal;Arr<ERR_HQUnit*>m_Units;int statuses=0,bodies=0,sweeps=0; struct HQ{void ContactReport(ERR_HQUnit*,float){}
Mode mode;Mode*m_Mode=&mode;Network m_Network;Journal m_Journal;Arr<ERR_HQUnit*>m_Units;int statuses=0,bodies=0,sweeps=0;
void StatusReport(ERR_HQUnit*,float){statuses++;}void ObserveBody(ERR_HQUnit*,float){bodies++;}void Sweep(ERR_HQUnit*,float){sweeps++;} void StatusReport(ERR_HQUnit*,float){statuses++;}void ObserveBody(ERR_HQUnit*,float){bodies++;}void Sweep(ERR_HQUnit*,float){sweeps++;}
''' + s.replace('static void Describe(auto*,auto&,float)','template<class T,class A>static void Describe(T*,A&,float)') + r''' ''' + s.replace('static void Describe(auto*,auto&,float)','template<class T,class A>static void Describe(T*,A&,float)') + r'''
}; };
+32
View File
@@ -132,6 +132,26 @@ int main(){
assert(memory.Get(&a)->SampleCount==8&&memory.Get(&a)->BeliefRevision==12&&memory.Get(&a)->HasVelocity); assert(memory.Get(&a)->SampleCount==8&&memory.Get(&a)->BeliefRevision==12&&memory.Get(&a)->HasVelocity);
auto old=Report(13,&a,23000);assert(!memory.Ingest(&old,25000)&&memory.Get(&a)->BeliefRevision==12); auto old=Report(13,&a,23000);assert(!memory.Ingest(&old,25000)&&memory.Get(&a)->BeliefRevision==12);
auto jump=Report(14,&a,26000,{10000,0,0});assert(memory.Ingest(&jump,26000)&&!memory.Get(&a)->HasVelocity); auto jump=Report(14,&a,26000,{10000,0,0});assert(memory.Ingest(&jump,26000)&&!memory.Get(&a)->HasVelocity);
// A near-simultaneous second observer must not erase credible motion
// history. Use the nearest sample at least two seconds old, never a live
// entity position or an older convenient sample across a mode change.
memory.Clear();
auto spaced=Report(1,&a,1000,{10,0,20});assert(memory.Ingest(&spaced,1000));
auto closeA=Report(2,&a,4000,{10,0,20});assert(memory.Ingest(&closeA,4000));
auto closeB=Report(3,&a,4500,{10,0,20});assert(memory.Ingest(&closeB,4500));
assert(memory.Get(&a)->HasVelocity&&near(memory.Get(&a)->Speed,0));
auto moving=Report(4,&a,5000,{18,0,20});assert(memory.Ingest(&moving,5000));
assert(memory.Get(&a)->HasVelocity&&near(memory.Get(&a)->Speed,2));
auto boarded=Report(5,&a,5500,{18,0,20},"vehicle");assert(memory.Ingest(&boarded,5500));
assert(!memory.Get(&a)->HasVelocity);
auto dismounted=Report(6,&a,6000,{18,0,20});assert(memory.Ingest(&dismounted,6000));
assert(!memory.Get(&a)->HasVelocity); // do not bridge the intervening vehicle observation
auto farJump=Report(7,&a,8000,{10000,0,0});assert(memory.Ingest(&farJump,8000));
assert(!memory.Get(&a)->HasVelocity); // do not skip an implausible eligible pair
memory.Clear();
auto rapidA=Report(1,&a,1000);assert(memory.Ingest(&rapidA,1000));
auto rapidB=Report(2,&a,1500);assert(memory.Ingest(&rapidB,1500));
assert(!memory.Get(&a)->HasVelocity); // insufficient time baseline still refuses motion
// No equal/weak burst can evict a stronger current sighting; later stronger reports can. // No equal/weak burst can evict a stronger current sighting; later stronger reports can.
memory.Clear();Identity people[70];for(int i=0;i<70;i++)people[i].id=i+10; memory.Clear();Identity people[70];for(int i=0;i<70;i++)people[i].id=i+10;
for(int i=0;i<64;i++){auto r=Report(i+1,&people[i],0);assert(memory.Ingest(&r,0));} for(int i=0;i<64;i++){auto r=Report(i+1,&people[i],0);assert(memory.Ingest(&r,0));}
@@ -139,6 +159,18 @@ int main(){
over.ObservedAt=10000;assert(memory.Ingest(&over,10000)&&memory.Count()==64); over.ObservedAt=10000;assert(memory.Ingest(&over,10000)&&memory.Count()==64);
auto weak=Report(66,&people[65],10000);weak.Distress=true;assert(!memory.Ingest(&weak,10000)); auto weak=Report(66,&people[65],10000);weak.Distress=true;assert(!memory.Ingest(&weak,10000));
assert(!memory.At(-1)&&!memory.At(64)&&!memory.Get(nullptr)); assert(!memory.At(-1)&&!memory.At(64)&&!memory.Get(nullptr));
// Heard fire is source-keyed, imprecise, short-lived, never a precision target.
memory.Clear();auto sound=Report(1,nullptr,1000,{75,0,225},"heard gunfire");sound.Source=&friendly;
assert(memory.Ingest(&sound,1000));auto noise=memory.At(0);
assert(noise->Kind==ERR_HQMemoryKind::GUNFIRE&&!noise->Target&&!noise->HasVelocity&&!noise->Actionable());
assert(near(noise->Radius,150)&&near(noise->Confidence,.4));
int soundId=noise->BeliefId;assert(memory.NegativeSearch(soundId,1,1000));
assert(near(memory.GetById(soundId)->Confidence,.26));
sound.MessageId=2;sound.ObservedAt=3000;sound.Position={375,0,225};assert(memory.Ingest(&sound,3000));
assert(memory.Count()==1&&!memory.At(0)->HasVelocity&&memory.At(0)->Target==nullptr);
auto visual=Report(3,&a,3000,{400,0,210});assert(memory.Ingest(&visual,3000));
assert(memory.Count()==2&&memory.Get(&a)->Actionable()&&near(memory.Get(&a)->Confidence,1));
memory.Tick(63000);assert(near(memory.GetById(soundId)->Confidence,.2));
// The global replay table is bounded even when valid observation IDs arrive rapidly. // The global replay table is bounded even when valid observation IDs arrive rapidly.
memory.Clear();for(int i=1;i<=4096;i++){auto r=Report(i,&a,float(i));assert(memory.Ingest(&r,float(i)));} memory.Clear();for(int i=1;i<=4096;i++){auto r=Report(i,&a,float(i));assert(memory.Ingest(&r,float(i)));}
auto full=Report(4097,&a,4097);assert(!memory.Ingest(&full,4097)&&memory.m_Seen.Count()==4096); auto full=Report(4097,&a,4097);assert(!memory.Ingest(&full,4097)&&memory.m_Seen.Count()==4096);
+5 -3
View File
@@ -10,7 +10,7 @@ legacy='Observe(unit, now);\n ObserveBody(unit, now);\n StatusReport(unit, n
assert legacy in original or 'QueueReports(unit, now);' in original assert legacy in original or 'QueueReports(unit, now);' in original
def cpp(s): def cpp(s):
s=s.replace('protected ','').replace('array<ref ERR_HQVisualContact>','Array<ERR_HQVisualContact*>') s=s.replace('ERR_HQLocalAwareness.Priority','ERR_HQLocalAwareness::Priority').replace('protected ','').replace('array<ref ERR_HQVisualContact>','Array<ERR_HQVisualContact*>')
for t in ('ERR_HQAmmoSnapshot','ERR_HQUnit','ERR_HQVisualContact','ERR_HQRoute','ERR_HQReport','ERR_HQStatusReport','ERR_HQTask','ERR_HQSearchReport'): for t in ('ERR_HQAmmoSnapshot','ERR_HQUnit','ERR_HQVisualContact','ERR_HQRoute','ERR_HQReport','ERR_HQStatusReport','ERR_HQTask','ERR_HQSearchReport'):
s=re.sub(r'\b'+t+r' (?=\w)',t+'* ',s) s=re.sub(r'\b'+t+r' (?=\w)',t+'* ',s)
s=s.replace('ERR_HQRoute* route;', 'ERR_HQRoute* route=nullptr;') s=s.replace('ERR_HQRoute* route;', 'ERR_HQRoute* route=nullptr;')
@@ -62,7 +62,8 @@ struct Network{bool available=true;int routes=0;ERR_HQRoute* Route(void*){routes
struct Bodies{bool visible=false;bool Observe(Group*,float now,ERR_HQReport*& r){if(!visible)return false;r=new ERR_HQReport;r->ObservedAt=now;return true;}}; struct Bodies{bool visible=false;bool Observe(Group*,float now,ERR_HQReport*& r){if(!visible)return false;r=new ERR_HQReport;r->ObservedAt=now;return true;}};
struct Journal{int seq=0;int Message(){return ++seq;}void Write(string,string,int){}}; struct Journal{int seq=0;int Message(){return ++seq;}void Write(string,string,int){}};
struct History{bool CanQueue(void*,float,float){return true;}void Queued(void*,float,float){}}; struct History{bool CanQueue(void*,float,float){return true;}void Queued(void*,float,float){}};
struct ERR_HQUnit{History SentObservations;int Id=1;struct Group ownGroup;struct Utility ownUtility;Selection selection;Sampler sampler; struct ERR_HQLocalAwareness{static bool Priority(float,float,float){return false;}};
struct ERR_HQUnit{float NextContact=0;History SentObservations;int Id=1;struct Group ownGroup;struct Utility ownUtility;Selection selection;Sampler sampler;
struct Group* Group=&ownGroup;struct Utility* Utility=&ownUtility;void* Radio=this; struct Group* Group=&ownGroup;struct Utility* Utility=&ownUtility;void* Radio=this;
Selection* ReportSelection=&selection;Sampler* StatusSampler=&sampler; Selection* ReportSelection=&selection;Sampler* StatusSampler=&sampler;
ERR_HQRoute* Uplink=nullptr;ERR_HQReport* PendingReport=nullptr;ERR_HQStatusReport* PendingStatus=nullptr;ERR_HQSearchReport* PendingSweep=nullptr; ERR_HQRoute* Uplink=nullptr;ERR_HQReport* PendingReport=nullptr;ERR_HQStatusReport* PendingStatus=nullptr;ERR_HQSearchReport* PendingSweep=nullptr;
@@ -71,7 +72,8 @@ struct ERR_HQUnit{History SentObservations;int Id=1;struct Group ownGroup;struct
ERR_HQTask* Task=nullptr;void* Assignment=nullptr;void* Waypoint=nullptr; ERR_HQTask* Task=nullptr;void* Assignment=nullptr;void* Waypoint=nullptr;
bool Eligible(Mode*){return eligible;} bool Eligible(Mode*){return eligible;}
}; };
struct HQ{Mode mode;Mode* m_Mode=&mode;Network m_Network;Bodies m_Bodies;Journal m_Journal; struct HQ{void ContactReport(ERR_HQUnit*,float){}
Mode mode;Mode* m_Mode=&mode;Network m_Network;Bodies m_Bodies;Journal m_Journal;
bool OriginOccupied(void*,ERR_HQUnit*){return false;} bool OriginOccupied(void*,ERR_HQUnit*){return false;}
ERR_HQRoute* ObservationRoute(ERR_HQUnit* u){return u->Radio?m_Network.Route(u->Radio):nullptr;} ERR_HQRoute* ObservationRoute(ERR_HQUnit* u){return u->Radio?m_Network.Route(u->Radio):nullptr;}
bool Fighting(ERR_HQUnit* u){return u->fighting;} bool Fighting(ERR_HQUnit* u){return u->fighting;}
+3 -3
View File
@@ -107,7 +107,7 @@ struct BodyAdapter{int confirmations=0;void ConfirmDelivered(void*){confirmation
struct CoverageAdapter{int forgotten=0;void Forget(vector){forgotten++;}}; struct CoverageAdapter{int forgotten=0;void Forget(vector){forgotten++;}};
struct JournalAdapter{void Write(string,string,int){}}; struct JournalAdapter{void Write(string,string,int){}};
template<class... Args> string Format(string,Args...){return "delivered";} template<class... Args> string Format(string,Args...){return "delivered";}
struct HQDelivery{ struct HQDelivery{float m_NextAssignment=5000;
Watch m_Watch;MemoryAdapter m_Memory;BodyAdapter m_Bodies;struct RecoveryAdapter{int calls=0;void ReportBody(ERR_HQReport*){calls++;}} m_Recovery;CoverageAdapter m_Coverage;JournalAdapter m_Journal; Watch m_Watch;MemoryAdapter m_Memory;BodyAdapter m_Bodies;struct RecoveryAdapter{int calls=0;void ReportBody(ERR_HQReport*){calls++;}} m_Recovery;CoverageAdapter m_Coverage;JournalAdapter m_Journal;
Array<ERR_HQReport*>m_Contacts; Array<ERR_HQReport*>m_Contacts;
'''+accept+r''' '''+accept+r'''
@@ -251,7 +251,7 @@ int main(){
assert(wire->CasualtySlot==0&&wire->Position.x==900&&wire->CasualtySourceId=="deleted-during-uplink"); assert(wire->CasualtySlot==0&&wire->Position.x==900&&wire->CasualtySourceId=="deleted-during-uplink");
assert(intake.m_Watch.Count()==0); // preparing/queuing local evidence changes nothing assert(intake.m_Watch.Count()==0); // preparing/queuing local evidence changes nothing
wire->Target=nullptr; // simulate native weak-reference invalidation after captured LOS, before radio completes wire->Target=nullptr; // simulate native weak-reference invalidation after captured LOS, before radio completes
intake.Accept(wire,2000); intake.Accept(wire,2000);assert(intake.m_NextAssignment==5000);
auto delivered=intake.m_Watch.Find("deleted-during-uplink"); auto delivered=intake.m_Watch.Find("deleted-during-uplink");
assert(delivered&&delivered->Closed&&delivered->Count==0&&intake.m_Watch.ConfirmedLosses()==1); assert(delivered&&delivered->Closed&&delivered->Count==0&&intake.m_Watch.ConfirmedLosses()==1);
assert(delivered->MessageId==0&&delivered->ObservedAt==-1); // no invented check-in or corpse position assert(delivered->MessageId==0&&delivered->ObservedAt==-1); // no invented check-in or corpse position
@@ -259,7 +259,7 @@ int main(){
intake.Accept(wire,2001);assert(intake.m_Watch.ConfirmedLosses()==1); // delivered duplicates cannot add losses intake.Accept(wire,2001);assert(intake.m_Watch.ConfirmedLosses()==1); // delivered duplicates cannot add losses
wire->CasualtySourceId="stale";intake.Accept(wire,121001);assert(!intake.m_Watch.Find("stale")); wire->CasualtySourceId="stale";intake.Accept(wire,121001);assert(!intake.m_Watch.Find("stale"));
wire->ObservedAt=2000;wire->TravelMode="walking";wire->Target=&local; wire->ObservedAt=2000;wire->TravelMode="walking";wire->Target=&local;
intake.Accept(wire,2000);assert(!intake.m_Watch.Find("stale")&&intake.m_Contacts.Count()==1); intake.Accept(wire,2000);assert(intake.m_NextAssignment==2000);assert(!intake.m_Watch.Find("stale")&&intake.m_Contacts.Count()==1);
wire->TravelMode="body";wire->Distress=true;intake.Accept(wire,2001); wire->TravelMode="body";wire->Distress=true;intake.Accept(wire,2001);
assert(!intake.m_Watch.Find("stale")&&intake.m_Contacts.Count()==1); // distress cannot claim bodily identity assert(!intake.m_Watch.Find("stale")&&intake.m_Contacts.Count()==1); // distress cannot claim bodily identity
Watch bodyOnly;body.CasualtySourceId="no-survivor";body.CasualtySlot=0;body.ObservedAt=1000; Watch bodyOnly;body.CasualtySourceId="no-survivor";body.CasualtySlot=0;body.ObservedAt=1000;
+27
View File
@@ -0,0 +1,27 @@
from policy_harness import ROOT,body,run
p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HunterCombat.c'
s=body(p,'static bool Recent(').replace('EAITargetInfoCategory.','EAITargetInfoCategory::')
run('''#include <cassert>
namespace EAITargetInfoCategory {enum {UNKNOWN,DESTROYED,DISARMED,LOST,DETECTED,IDENTIFIED};}
'''+s+'''
int main(){for(int c=0;c<6;c++){bool threat=c==4||c==5;assert(Recent(c,0)==threat);assert(Recent(c,15)==threat);assert(!Recent(c,-1));assert(!Recent(c,15.001f));}}
''')
print('PASS: only recent native detected/identified threats suspend hunter travel; stale/disarmed/destroyed contacts cannot strand patrols')
# Execute the actual state transition and owned-order cleanup with native perception doubled.
s=body(ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c','protected void UpdateCombat(')
s=s.replace('protected ','').replace('ERR_HunterVehicle unit','Unit* unit').replace('unit.','unit->').replace('ERR_HunterCombat.Active','Active').replace('ERR_HQ.Event','Event').replace('null','nullptr')
run('''#include <cassert>
#include <string>
struct Group{bool contact=false;};
bool Active(Group* g){return g&&g->contact;}
int messages=0;void Event(std::string,std::string){messages++;}
struct Unit{bool Registered=true,CombatHeld=false;Group* Group=nullptr;float CombatQuietUntil=0,NextOrder=100;void* Downlink=nullptr;std::string Source="test";int clears=0;void ClearOrder(){clears++;}};
'''.replace('Group* Group','struct Group* Group')+s+'''
int main(){Group group;Unit u;u.Group=&group;u.Downlink=&group;UpdateCombat(&u,1000);assert(!u.CombatHeld&&u.clears==0&&u.Downlink);
group.contact=true;UpdateCombat(&u,2000);assert(u.CombatHeld&&u.clears==1&&!u.Downlink&&u.CombatQuietUntil==17000);
UpdateCombat(&u,5000);assert(u.clears==1&&u.CombatQuietUntil==20000&&messages==1);
group.contact=false;UpdateCombat(&u,19999);assert(u.CombatHeld);UpdateCombat(&u,20000);assert(!u.CombatHeld&&u.NextOrder==0&&messages==2);
Unit pending;pending.Group=&group;pending.Registered=false;group.contact=true;UpdateCombat(&pending,100);assert(!pending.CombatHeld&&pending.clears==0);
}
''')
print('PASS: actual hunter combat transition cancels only its travel once, holds through renewed contact and resumes after quiet; unregistered startup preserved')
+10 -1
View File
@@ -35,7 +35,7 @@ struct Group{int GetFaction(){return 1;}};
struct Entity{Vec GetOrigin(){return {};}}; struct Entity{Vec GetOrigin(){return {};}};
struct Waypoint{bool complete=false;bool IsWithinCompletionRadius(Group*){return complete;}}; struct Waypoint{bool complete=false;bool IsWithinCompletionRadius(Group*){return complete;}};
struct Route{}; struct Route{};
struct ERR_HunterVehicle{float NextOrder=99999,LastContact=-1,NextContactOrder=0;Route* Downlink=nullptr;int SourceMessage=0;Vec Destination;string Source="hunter";struct Group* Group=nullptr;struct Entity* Entity=nullptr;struct Waypoint* Waypoint=nullptr;}; struct ERR_HunterVehicle{float NextOrder=99999,LastContact=-1,NextContactOrder=0;Route* Downlink=nullptr;int SourceMessage=0;Vec Destination,ContactPosition,DeliveredContactPosition;bool DeliveredContact=false;string Source="hunter";struct Group* Group=nullptr;struct Entity* Entity=nullptr;struct Waypoint* Waypoint=nullptr;};
struct ERR_HQReport{int Target=1;}; struct ERR_HQReport{int Target=1;};
struct ERR_HQBelief{bool actionable=true;float ObservedAt=10,Confidence=1;int MessageId=7;Vec PredictedPosition={1000,0,2000};bool Actionable(){return actionable;}}; struct ERR_HQBelief{bool actionable=true;float ObservedAt=10,Confidence=1;int MessageId=7;Vec PredictedPosition={1000,0,2000};bool Actionable(){return actionable;}};
struct Memory{ERR_HQBelief belief;ERR_HQBelief* Get(int){return &belief;}}; struct Memory{ERR_HQBelief belief;ERR_HQBelief* Get(int){return &belief;}};
@@ -72,6 +72,15 @@ int main(){
unit.Downlink=nullptr;service.QueueDestination(&unit,&hq,7000);assert(hq.requests==2); // no repeated order for same report unit.Downlink=nullptr;service.QueueDestination(&unit,&hq,7000);assert(hq.requests==2); // no repeated order for same report
ERR_HunterVehicle disconnected;hq.connected=false;service.QueueDestination(&disconnected,&hq,10000); ERR_HunterVehicle disconnected;hq.connected=false;service.QueueDestination(&disconnected,&hq,10000);
assert(!disconnected.Downlink&&disconnected.LastContact==-1); assert(!disconnected.Downlink&&disconnected.LastContact==-1);
// Repeated/new reports in an already delivered area must not churn routing.
Waypoint route;unit.Waypoint=&route;unit.DeliveredContact=true;unit.DeliveredContactPosition={1000,0,2000};
unit.Downlink=nullptr;unit.NextOrder=0;unit.NextContactOrder=0;
int kept=hq.requests;int projections=ERR_SupplyConvoySystem::calls;
hq.memory.belief.ObservedAt=12;service.QueueDestination(&unit,&hq,12000);
assert(hq.requests==kept&&ERR_SupplyConvoySystem::calls==projections&&unit.LastContact==12);
hq.memory.belief.PredictedPosition.x=1100;hq.memory.belief.ObservedAt=13;
service.QueueDestination(&unit,&hq,18000);assert(hq.requests==kept+1); // Material displacement redirects.
hq.memory.belief.ObservedAt=11;
int requests=hq.requests;service.QueueDestination(&disconnected,&hq,11000);assert(hq.requests==requests); int requests=hq.requests;service.QueueDestination(&disconnected,&hq,11000);assert(hq.requests==requests);
hq.connected=true;service.QueueDestination(&disconnected,&hq,15000);assert(disconnected.Downlink&&disconnected.LastContact==11); hq.connected=true;service.QueueDestination(&disconnected,&hq,15000);assert(disconnected.Downlink&&disconnected.LastContact==11);
} }
+80
View File
@@ -0,0 +1,80 @@
"""Execute the pre-spawn profile gate; verify registry-backed faction resources."""
from policy_harness import ROOT, body, run
import re
p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c'
method=body(p,'protected bool SpawnHunter(')
# The real preflight block runs before construction/insertion/native spawning.
preflight=method[method.index('{')+1:method.index(' ERR_HunterVehicle unit =')]
preflight=preflight.replace('ResourceName','std::string').replace('ERR_EnemyMedicalCatalog.','ERR_EnemyMedicalCatalog::').replace('ERR_EnemyLoadoutCatalog.','ERR_EnemyLoadoutCatalog::').replace('ERR_HQ.Event','ERR_HQ::Event').replace('string.Format','Format').replace('m_Mode.EnemyFaction().GetFactionKey()','"TEST_FACTION"')
run('''#include <cassert>
#include <string>
namespace ERR_EnemyMedicalCatalog {std::string soldier="soldier",medic="medic";std::string Crew(void*,bool m){return m?medic:soldier;}}
namespace ERR_EnemyLoadoutCatalog {bool Available(std::string p){return !p.empty()&&p!="invalid";}}
int logged=0;namespace ERR_HQ {void Event(std::string,std::string){logged++;}}
template<class...T>std::string Format(std::string text,T...){return text;}
struct Gate {void* m_Mode=nullptr;float m_NextSpawn=0;bool Check(float now){
'''+preflight+'''return true;}};
int main(){Gate gate;assert(gate.Check(1000)&&logged==0);
ERR_EnemyMedicalCatalog::medic="";assert(!gate.Check(1000)&&gate.m_NextSpawn==31000&&logged==1);
ERR_EnemyMedicalCatalog::medic="medic";ERR_EnemyMedicalCatalog::soldier="invalid";assert(!gate.Check(5000)&&gate.m_NextSpawn==35000);
ERR_EnemyMedicalCatalog::soldier="soldier";assert(gate.Check(35000));
}
''')
registry=(ROOT/'addons/core/Configs/ERR/Content/Registry.conf').read_text()
for key in ('RHS_ION','RHS_USAF','RHS_AFRF'):
option=next(x for x in registry.split('ERR_ContentOption {') if f'NativeFactionKey "{key}"' in x)
assert re.search(r'ResponseSoldier "\{[A-F0-9]{16}\}[^"\n]+\.et"',option)
assert re.search(r'ResponseMedic "\{[A-F0-9]{16}\}[^"\n]*Medic[^"\n]*\.et"',option)
print('PASS: missing/invalid faction crew defers before any native spawn or allowance insertion; all registered RHS factions have explicit soldier and medic profiles')
# Execute actual finite vehicle-radio initialization: pending/refused/lost radios
# never cause repeat allocations, and admission requires physical capability.
prepare=body(p,'protected bool PrepareVehicleRadio(').replace('protected ','').replace('ERR_HunterVehicle unit','Unit* unit')
prepare=prepare.replace('InventoryStorageManagerComponent storage = InventoryStorageManagerComponent.Cast(unit.Entity.FindComponent(InventoryStorageManagerComponent));','Storage* storage = vehicleStorage;')
prepare=prepare.replace('ref ','').replace('unit.','unit->').replace('storage.','storage->').replace('RadioOperation.','RadioOperation->').replace('ERR_HQ.Event','Event').replace('ERR_EnemyRadio.MANPACK','"manpack"').replace('EStoragePurpose.PURPOSE_DEPOSIT','1').replace('null','nullptr')
run('''#include <cassert>
#include <string>
struct ERR_EnemyEquipmentCallback{bool Finished=false,Succeeded=false;};
struct Unit{bool RadioRequested=false;ERR_EnemyEquipmentCallback* RadioOperation=nullptr;void* Driver=nullptr;std::string Source="uaz";};
struct Storage{int calls=0;bool accepts=true;bool TrySpawnPrefabToStorage(std::string,void*,int,int,ERR_EnemyEquipmentCallback*){calls++;return accepts;}};
void Event(std::string,std::string){}
struct Service{Storage* vehicleStorage=nullptr;int physicalCapability=0;int VehicleRadioCapability(void*){return physicalCapability;}
'''+prepare+'''
};
int main(){Service service;Storage storage;service.vehicleStorage=&storage;Unit unit;
assert(!service.PrepareVehicleRadio(&unit)&&storage.calls==1);for(int i=0;i<10;i++)assert(!service.PrepareVehicleRadio(&unit));assert(storage.calls==1);
unit.RadioOperation->Finished=true;unit.RadioOperation->Succeeded=true;assert(!service.PrepareVehicleRadio(&unit));
service.physicalCapability=2;assert(service.PrepareVehicleRadio(&unit)&&storage.calls==1);
service.physicalCapability=0;assert(!service.PrepareVehicleRadio(&unit)&&storage.calls==1); // Loot does not replenish the radio.
Unit refused;storage.accepts=false;assert(!service.PrepareVehicleRadio(&refused));for(int i=0;i<10;i++)assert(!service.PrepareVehicleRadio(&refused));assert(storage.calls==2);
}
''')
print('PASS: vehicle radio issued once; pending, refusal and looting cannot duplicate or fabricate a physical radio link')
capability=body(p,'int VehicleRadioCapability(')
capability=capability.replace('IEntity actor','Entity* actor').replace('foreach (ERR_HunterVehicle unit : m_Hunters)','for (Unit* unit : m_Hunters)').replace('InventoryStorageManagerComponent storage = InventoryStorageManagerComponent.Cast(unit.Entity.FindComponent(InventoryStorageManagerComponent));','Storage* storage = unit->Entity->storage;')
capability=capability.replace('array<IEntity> items = {};','std::vector<Entity*> items;').replace('foreach (IEntity item : items)','for (Entity* item : items)').replace('item.FindComponent(BaseRadioComponent)','item->baseRadio').replace('SCR_RadioComponent radio = SCR_RadioComponent.Cast(item.FindComponent(SCR_RadioComponent));','Radio* radio = item->radio;')
for obj in ('m_Mode','unit','storage','radio'):capability=capability.replace(obj+'.',obj+'->')
capability=capability.replace('ERR_Actors.Alive','Alive').replace('CompartmentAccessComponent.GetVehicleIn','VehicleIn').replace('ERR_ConvoyRules.Captured','Captured')
run('''#include <cassert>
#include <vector>
#include <algorithm>
struct Radio{bool pack=true;bool ERR_IsHQManpack(){return pack;}};
struct Storage;
struct Entity{bool alive=true,baseRadio=true,captured=false;Entity* vehicle=nullptr;Storage* storage=nullptr;Radio* radio=nullptr;};
struct Storage{std::vector<Entity*>items;void GetItems(std::vector<Entity*>&out){out=items;}};
struct Members:std::vector<Entity*>{bool Contains(Entity* p){return std::find(begin(),end(),p)!=end();}};
struct Mode{bool active=true;bool Active(){return active;}};
struct Unit{struct Entity* Entity=nullptr;void* Group=nullptr;bool Released=false,valid=true;struct Members Members;bool CrewValid(Mode*){return valid;}};
bool Alive(Entity* e){return e&&e->alive;}Entity* VehicleIn(Entity* e){return e->vehicle;}bool Captured(Entity* e,void*){return e->captured;}
struct Service{Mode* m_Mode=nullptr;bool m_Stopped=false;std::vector<Unit*>m_Hunters;
'''+capability+'''
};
int main(){Service s;Mode mode;s.m_Mode=&mode;Entity actor,car,item;Radio radio;Storage storage;Unit unit;
unit.Entity=&car;unit.Members.push_back(&actor);actor.vehicle=&car;car.storage=&storage;item.radio=&radio;storage.items={&item};s.m_Hunters={&unit};
assert(s.VehicleRadioCapability(&actor)==2);storage.items.clear();assert(s.VehicleRadioCapability(&actor)==0);storage.items={&item};
actor.vehicle=nullptr;assert(s.VehicleRadioCapability(&actor)==0);actor.vehicle=&car;car.captured=true;assert(s.VehicleRadioCapability(&actor)==0);car.captured=false;
car.alive=false;assert(s.VehicleRadioCapability(&actor)==0);car.alive=true;unit.Released=true;assert(s.VehicleRadioCapability(&actor)==0);unit.Released=false;
mode.active=false;assert(s.VehicleRadioCapability(&actor)==0);mode.active=true;s.m_Stopped=true;assert(s.VehicleRadioCapability(&actor)==0);s.m_Stopped=false;
radio.pack=false;assert(s.VehicleRadioCapability(&actor)==0);radio.pack=true;Entity stranger;stranger.vehicle=&car;assert(s.VehicleRadioCapability(&stranger)==0);
}
''')
print('PASS: actual vehicle-radio link requires the original seated crew, physical manpack and live uncaptured mission vehicle; looting, dismount, release and teardown remove it')
+107
View File
@@ -0,0 +1,107 @@
"""Execute the production infantry combat state machine against waypoint doubles."""
import re
from policy_harness import ROOT, body, run
p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_InfantryCombat.c'
s=p.read_text()
s=s.replace('class ERR_InfantryCombat', 'struct ERR_InfantryCombat')
s=s.replace('protected ', '').replace('ref ', '').replace('null','nullptr')
s=s.replace('EAIGroupCombatMode.FIRE_AT_WILL','1').replace('.IsEmpty()', '.empty()')
for typ in ('AIWaypoint','ERR_HQTask','ERR_HQUnit'):
s=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',s)
s=s.replace('bool Held;', 'bool Held=false;').replace('float QuietUntil;', 'float QuietUntil=0;')
s=s.replace('m_PausedWaypoint;', 'm_PausedWaypoint=nullptr;').replace('m_PausedTask;', 'm_PausedTask=nullptr;')
for name in ('unit','task','Task','Group','Utility'):
s=s.replace(name+'.',name+'->')
s=s.rstrip()+';'
run(r'''
#include <cassert>
#include <vector>
#include <string>
#include <algorithm>
struct AIWaypoint{};
struct ERR_HQTask {bool ReleaseOnly=false,RoadIntercept=false;std::string SecuritySite;float ExpiresAt=100000;};
struct Group {std::vector<AIWaypoint*> queue;int adds=0,removes=0;
void RemoveWaypoint(AIWaypoint* w){removes++;queue.erase(std::remove(queue.begin(),queue.end(),w),queue.end());}
void AddWaypointAt(AIWaypoint* w,int slot){assert(slot==0);adds++;queue.insert(queue.begin(),w);}};
struct Utility{int mode=0,changes=0;void SetCombatMode(int m){mode=m;changes++;}};
struct ERR_HQUnit{AIWaypoint* Waypoint=nullptr;ERR_HQTask* Task=nullptr;void* Assignment=nullptr;struct Group* Group;struct Utility* Utility;};
'''+s+r'''
struct Fixture {Group group;Utility utility;AIWaypoint search,home;ERR_HQTask task;ERR_HQUnit unit;ERR_InfantryCombat combat;
Fixture(){unit.Group=&group;unit.Utility=&utility;unit.Task=&task;unit.Assignment=&task;unit.Waypoint=&search;group.queue={&search,&home};}
};
int main(){
{Fixture f;assert(f.combat.Update(&f.unit,true,false,1000)==0);assert(f.group.removes==0);
assert(f.combat.Update(&f.unit,true,true,2000)==1);assert(f.combat.Held&&f.combat.Paused());
assert(f.group.queue.size()==1&&f.group.queue[0]==&f.home&&f.utility.mode==1);
assert(f.unit.Task==&f.task&&f.unit.Waypoint==&f.search&&f.unit.Assignment);
assert(f.combat.Update(&f.unit,true,true,4000)==0&&f.group.removes==1&&f.utility.changes==1);
assert(f.combat.Update(&f.unit,true,false,18999)==0&&f.combat.Held);
assert(f.combat.Update(&f.unit,true,false,19000)==2&&!f.combat.Held&&!f.combat.Paused());
assert(f.group.adds==1&&f.group.queue.size()==2&&f.group.queue[0]==&f.search);
assert(f.combat.Update(&f.unit,true,false,20000)==0&&f.group.adds==1);}
// Never resurrect expired, released, replaced, or newly defensive tasks.
for(int invalid=0;invalid<6;invalid++){Fixture f;ERR_HQTask replacement;AIWaypoint other;
f.combat.Update(&f.unit,true,true,0);
if(invalid==0)f.task.ExpiresAt=15000;
if(invalid==1)f.unit.Task=nullptr;
if(invalid==2)f.unit.Waypoint=&other;
if(invalid==3)f.unit.Task=&replacement;
if(invalid==4)f.unit.Assignment=nullptr;
if(invalid==5)f.task.RoadIntercept=true;
assert(f.combat.Update(&f.unit,true,false,15000)==3&&f.group.adds==0);}
// Existing defensive posts and recalls keep their queue and fire policy.
for(int defence=0;defence<3;defence++){Fixture f;
if(defence==0){f.task.SecuritySite="radio";}if(defence==1){f.task.RoadIntercept=true;}if(defence==2){f.task.ReleaseOnly=true;}
assert(f.combat.Update(&f.unit,true,true,0)==1&&!f.combat.Paused());
assert(f.group.removes==0&&f.utility.changes==0);
assert(f.combat.Update(&f.unit,true,false,15000)==3&&f.group.adds==0);}
// Reservations/player/vehicle control cannot insert or remove waypoints.
{Fixture f;assert(f.combat.Update(&f.unit,false,true,0)==0&&f.group.removes==0);
f.combat.Update(&f.unit,true,true,0);
assert(f.combat.Update(&f.unit,false,false,20000)==0&&f.group.adds==0);
assert(f.combat.Update(&f.unit,true,false,20000)==2&&f.group.adds==1);}
}
''')
print('PASS: actual infantry search suspension, quiet grace, finite resume, expiry/replacement and defensive/ownership boundaries')
# Guard the production integration: local pause occurs before radio delivery, and
# only task downlinks are cancelled; status/vehicle messages remain independent.
hq=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c'
tick=body(hq,'override void Tick(')
assert tick.index('UpdateInfantryCombat(unit, now)') < tick.index('unit.Downlink.Advance')
assert 'unit.InfantryCombat.Held || ERR_HunterCombat.Active(unit.Group)' in body(hq,'protected bool Fighting(')
assert 'if (unit.PendingTask) LostOrder(unit, now)' in body(hq,'protected void UpdateInfantryCombat(')
print('PASS: infantry combat gate precedes radio delivery and preserves non-task transports')
update=body(hq,'protected void UpdateInfantryCombat(').replace('protected ', '')
update=update.replace('ERR_HQUnit unit','ERR_HQUnit* unit').replace('unit.', 'unit->')
update=update.replace('InfantryCombat.', 'InfantryCombat->').replace('Group.', 'Group->')
update=update.replace('array<AIAgent> reacting', 'std::vector<AIAgent*> reacting')
update=update.replace('foreach (AIAgent actor : reacting)', 'for (AIAgent* actor : reacting)').replace('actor.', 'actor->')
update=update.replace('ERR_HunterCombat.Active', 'ERR_HunterCombat::Active').replace('ERR_Actors.CanFight', 'ERR_Actors::CanFight').replace('string.Format', 'Format')
run(r'''
#include <cassert>
#include <string>
#include <vector>
using string=std::string;
template<class... T>string Format(T...){return "entry";}
struct AIAgent{bool fit=true;int activations=0;AIAgent* GetControlledEntity(){return this;}void ActivateAI(){activations++;}};
struct Group{int activations=0;std::vector<AIAgent*> members;void ActivateAI(){activations++;}void GetAgents(std::vector<AIAgent*>& a){a=members;}};
struct ERR_Actors{static bool CanFight(AIAgent* a){return a->fit;}};
struct ERR_HunterCombat{static bool Active(Group*){return true;}};
struct ERR_HQUnit;
struct State{int change=1;bool paused=true;int Update(ERR_HQUnit*,bool eligible,bool,float){return eligible?change:0;}bool Paused(){return paused;}};
struct ERR_HQUnit{int Id=7;string ForceSourceId="town";struct Group* Group;State* InfantryCombat;void* PendingTask=nullptr;bool eligible=true;bool Eligible(int){return eligible;}};
struct Journal{int logs=0;void Write(string,string){logs++;}};
struct HQ{int m_Mode=1,cancelled=0;Journal m_Journal;void LostOrder(ERR_HQUnit* u,float){cancelled++;u->PendingTask=nullptr;}
'''+update+r'''
};
int main(){HQ h;Group g;State state;ERR_HQUnit u;u.Group=&g;u.InfantryCombat=&state;
AIAgent fit,casualty;casualty.fit=false;g.members={&fit,&casualty,nullptr};u.PendingTask=&fit;
h.UpdateInfantryCombat(&u,1000);assert(g.activations==1&&fit.activations==1&&casualty.activations==0&&h.cancelled==1&&h.m_Journal.logs==1);
state.change=0;h.UpdateInfantryCombat(&u,2000);assert(g.activations==1&&h.cancelled==1);
state.change=1;u.PendingTask=nullptr;h.UpdateInfantryCombat(&u,3000);assert(h.cancelled==1&&fit.activations==2);
u.eligible=false;h.UpdateInfantryCombat(&u,4000);assert(g.activations==2&&h.m_Journal.logs==2);
u.eligible=true;state.change=2;h.UpdateInfantryCombat(&u,5000);assert(g.activations==2&&h.m_Journal.logs==3);
}
''')
print('PASS: actual HQ contact transition wakes only fit squad members, cancels only task orders and logs changes once')
+1 -1
View File
@@ -39,7 +39,7 @@ struct Mode {
struct Fixture {Mode m;Controller first,second;Fixture(){server=true;world.now=0;game.players.connected={{1,&first}};m.roster.lives[1]=Life();}void tick(float t){world.now=t;m.ServerTick();}void exhaust(){m.InitialAdmissionExhausted(1,&first);}}; struct Fixture {Mode m;Controller first,second;Fixture(){server=true;world.now=0;game.players.connected={{1,&first}};m.roster.lives[1]=Life();}void tick(float t){world.now=t;m.ServerTick();}void exhaust(){m.InitialAdmissionExhausted(1,&first);}};
int main(){ int main(){
{Fixture f;f.m.roster.survivor=true;f.m.service.defeat=true;f.tick(31000); {Fixture f;f.m.roster.survivor=true;f.m.service.defeat=true;f.tick(31000);
assert(!f.m.Active() && f.m.m_EndReason=="All prisoners are unconscious and no treatment or recovery is progressing.");} assert(!f.m.Active() && f.m.m_EndReason=="Everybody died. All prisoners are unconscious and nobody can recover or receive help.");}
// Sole exhausted first admission reaches existing grace, without a life/body. // Sole exhausted first admission reaches existing grace, without a life/body.
{Fixture f;f.exhaust();f.tick(0);f.tick(29999);assert(f.m.Active());f.tick(30000); {Fixture f;f.exhaust();f.tick(0);f.tick(29999);assert(f.m.Active());f.tick(30000);
assert(f.m.m_Phase==5&&!f.m.m_StartedPlayers&&!f.m.roster.lives[1].UsedLife); assert(f.m.m_Phase==5&&!f.m.m_StartedPlayers&&!f.m.roster.lives[1].UsedLife);
+5 -4
View File
@@ -63,12 +63,12 @@ run(r'''
#include <set> #include <set>
#include <string> #include <string>
#include <vector> #include <vector>
struct vector {float x=0,y=0,z=0;static float Distance(vector a,vector b){return std::sqrt((a.x-b.x)*(a.x-b.x)+(a.y-b.y)*(a.y-b.y)+(a.z-b.z)*(a.z-b.z));}static float DistanceXZ(vector a,vector b){a.y=0;b.y=0;return Distance(a,b);}}; struct vector {float x=0,y=0,z=0;std::string ToString(){return "point";}static float Distance(vector a,vector b){return std::sqrt((a.x-b.x)*(a.x-b.x)+(a.y-b.y)*(a.y-b.y)+(a.z-b.z)*(a.z-b.z));}static float DistanceXZ(vector a,vector b){a.y=0;b.y=0;return Distance(a,b);}};
using IEntity=void*; using IEntity=void*;
template<class T>struct Items {std::vector<T> values;void Insert(T t){values.push_back(t);}}; template<class T>struct Items {std::vector<T> values;void Insert(T t){values.push_back(t);}};
struct Replication {static inline bool server=true;static bool IsServer(){return server;}}; struct Replication {static inline bool server=true;static bool IsServer(){return server;}};
struct ERR_HQReport {IEntity Target=this;int MessageId=1;float ObservedAt=0;bool Distress=false;vector Position{};std::string TravelMode="foot";std::string Description(float){return "contact";}ERR_HQReport* Snapshot(){return new ERR_HQReport(*this);}}; struct ERR_HQReport {IEntity Target=this;int MessageId=1;float ObservedAt=0;bool Distress=false;vector Position{};std::string TravelMode="foot";std::string Description(float){return "contact";}ERR_HQReport* Snapshot(){return new ERR_HQReport(*this);}};
struct ERR_MortarPrediction {static constexpr float MAX_REPORT_AGE_MS=60000; struct ERR_MortarPrediction {static constexpr float MAX_REPORT_AGE_MS=60000;static bool Predict(ERR_HQReport*,float,float,vector&){return true;}
'''+fresh+r'''}; '''+fresh+r'''};
struct ERR_MortarRefreshRules { struct ERR_MortarRefreshRules {
'''+newer+r'''}; '''+newer+r'''};
@@ -90,6 +90,7 @@ struct ERR_MortarBattery {
bool Operational(){return operational;} bool Operational(){return operational;}
bool CanRefresh(ERR_HQReport*,float);bool BeginRefresh(ERR_HQReport*,ERR_HQRoute*,float);void RejectRefresh(std::string); bool CanRefresh(ERR_HQReport*,float);bool BeginRefresh(ERR_HQReport*,ERR_HQRoute*,float);void RejectRefresh(std::string);
void Cancel(std::string reason="cancel requested");void ObserveProjectile(IEntity,IEntity,bool);void FlushTraces(){}void DetachActivity(){} void Cancel(std::string reason="cancel requested");void ObserveProjectile(IEntity,IEntity,bool);void FlushTraces(){}void DetachActivity(){}
void Trace(std::string,std::string,std::string){}
void Update(float){}bool SupplyPending(){return false;}int CountShells(){return 27;} void Update(float){}bool SupplyPending(){return false;}int CountShells(){return 27;}
bool CanRequest(ERR_HQReport*,float){return false;}bool BeginRequest(ERR_HQReport*,float){assert(false);return false;} bool CanRequest(ERR_HQReport*,float){return false;}bool BeginRequest(ERR_HQReport*,float){assert(false);return false;}
}; };
@@ -100,7 +101,7 @@ struct ERR_MortarAimSolution {
IEntity Gun=nullptr;ERR_HQReport* Report=nullptr;int OrderId=1,Shot=0,Charge=0;vector GunOrigin{},Impact{500,0,0},AimTarget{500,500,0};float FlightTime=30; IEntity Gun=nullptr;ERR_HQReport* Report=nullptr;int OrderId=1,Shot=0,Charge=0;vector GunOrigin{},Impact{500,0,0},AimTarget{500,500,0};float FlightTime=30;
'''+matches+r'''}; '''+matches+r'''};
struct ERR_MortarSystem { struct ERR_MortarSystem {
HQ hq;HQ* m_HQ=&hq;int failures=0; HQ hq;HQ* m_HQ=&hq;int failures=0;std::string SafetyReason;bool Safe(ERR_MortarBattery*,vector,float){return true;}
Items<ERR_MortarBattery*> m_Batteries;bool m_Stopped=false;float m_NextDecision=1e9f,m_NextRefreshDecision=0; Items<ERR_MortarBattery*> m_Batteries;bool m_Stopped=false;float m_NextDecision=1e9f,m_NextRefreshDecision=0;
bool Withhold(ERR_MortarBattery* b,const char* why){b->Rejected=true;b->LastFailure=why;failures++;return false;} bool Withhold(ERR_MortarBattery* b,const char* why){b->Rejected=true;b->LastFailure=why;failures++;return false;}
void AdvanceRefresh(ERR_MortarBattery*,float); void AdvanceRefresh(ERR_MortarBattery*,float);
@@ -196,7 +197,7 @@ int main(){
interrupted.b.ObserveProjectile(&interrupted.next,nullptr,false); // actual physical-event bookkeeping, not native launch proof interrupted.b.ObserveProjectile(&interrupted.next,nullptr,false); // actual physical-event bookkeeping, not native launch proof
assert(!interrupted.b.Released&&interrupted.b.OrderShots==1&&interrupted.b.TotalShots==1&&interrupted.b.Report==&interrupted.old); assert(!interrupted.b.Released&&interrupted.b.OrderShots==1&&interrupted.b.TotalShots==1&&interrupted.b.Report==&interrupted.old);
interrupted.system.Tick(25003);assert(!interrupted.b.CommandOpen&&!interrupted.b.Activity&&!interrupted.b.Report&&!interrupted.b.RefreshRejected); interrupted.system.Tick(25003);assert(!interrupted.b.CommandOpen&&!interrupted.b.Activity&&!interrupted.b.Report&&!interrupted.b.RefreshRejected);
assert(interrupted.b.originalActivity.completions==1&&interrupted.b.transfer.invalidated==1&&interrupted.b.NextOrder==35003); assert(interrupted.b.originalActivity.completions==1&&interrupted.b.transfer.invalidated==1&&interrupted.b.NextOrder==85003);
assert(interrupted.b.OrderShots==1&&interrupted.b.TotalShots==1&&interrupted.b.m_ReportLedger.claimed.count(2)); assert(interrupted.b.OrderShots==1&&interrupted.b.TotalShots==1&&interrupted.b.m_ReportLedger.claimed.count(2));
F noRoute;noRoute.system.hq.contacts.Insert(&noRoute.next);noRoute.b.Released=true; F noRoute;noRoute.system.hq.contacts.Insert(&noRoute.next);noRoute.b.Released=true;
noRoute.system.Tick(25000);assert(noRoute.system.hq.downlinks==1&&noRoute.b.RefreshRejected&&noRoute.b.Released); noRoute.system.Tick(25000);assert(noRoute.system.hq.downlinks==1&&noRoute.b.RefreshRejected&&noRoute.b.Released);
+58
View File
@@ -128,3 +128,61 @@ supply_state=body(battery,'ERR_MortarSupplyState SupplyState(')
assert supply_state.index('m_Transfer.Poll(now)') < supply_state.index('if (!SupplyAuthorized(now))') assert supply_state.index('m_Transfer.Poll(now)') < supply_state.index('if (!SupplyAuthorized(now))')
assert shoot.count('super.EOnTaskSimulate(')==1 and 'battery.SupplyGunner()' not in shoot assert shoot.count('super.EOnTaskSimulate(')==1 and 'battery.SupplyGunner()' not in shoot
print('PASS: actual startup/generation/ownership/timeout/callback policies and native-counter wait; integration preserves one-shot allocation and pending-crew cancellation') print('PASS: actual startup/generation/ownership/timeout/callback policies and native-counter wait; integration preserves one-shot allocation and pending-crew cancellation')
# Execute the production post-shot node, including the superclass completion
# callback. A fast repeater must not start a ninth aim while HQ is still waiting
# to observe native completion; pending ammunition and duplicate ticks stay safe.
from types import SimpleNamespace
section=(r/'ERR_MortarFiring.c').read_text().split('modded class SCR_AIStaticArtilleryBehavior_ShootingLogic',1)[1]
cycle=body(SimpleNamespace(read_text=lambda:section),'override ENodeResult EOnTaskSimulate(')
cycle=cycle.replace('override ', '').replace('AIAgent owner','void* owner')
cycle=cycle.replace('ERR_MortarBattery battery;', 'ERR_MortarBattery* battery=nullptr;').replace('SCR_AIStaticArtilleryBehavior behavior;', 'SCR_AIStaticArtilleryBehavior* behavior=nullptr;')
cycle=cycle.replace('SCR_AIStaticArtilleryBehavior.Cast(m_Utility.GetExecutedAction())','m_Utility->GetExecutedAction()')
cycle=cycle.replace('ERR_MortarSystem system = ERR_MortarSystem.Get();','ERR_MortarSystem* system = &systemInstance;')
for name in ['battery','behavior','system']:cycle=cycle.replace(name+'.',name+'->')
for name in ['ERR_MortarSupplyRules','ERR_MortarSalvo','ERR_MortarSupplyState','ENodeResult']:cycle=cycle.replace(name+'.',name+'::')
cycle=cycle.replace('GetGame().GetWorld().GetWorldTime()', '1000.f').replace('super.EOnTaskSimulate(owner, dt)', 'Base::EOnTaskSimulate(owner, dt)')
gate=body(readiness,'static int CycleGate(')
run('''#include <cassert>
enum class ENodeResult {FAIL,RUNNING,SUCCESS};
enum ERR_MortarSupplyState {FAILED=-1,PENDING,READY};
struct ERR_MortarSalvo {static constexpr int ROUNDS=8;};
struct ERR_MortarSupplyRules {'''+gate+'''};
struct ERR_MortarBattery {
bool CommandOpen=true;int OrderShots=0,checks=0;ERR_MortarSupplyState supply=ERR_MortarSupplyState::READY;
ERR_MortarSupplyState SupplyState(float){checks++;return supply;}
};
struct SCR_AIStaticArtilleryBehavior {
ERR_MortarBattery* battery=nullptr;int count=0,completions=0;
int GetShotCount(){return count;}ERR_MortarBattery* ERR_GetMortarBattery(){return battery;}
};
struct Utility {SCR_AIStaticArtilleryBehavior* behavior;auto GetExecutedAction(){return behavior;}};
struct ERR_MortarSystem {int refusals=0;void Withhold(ERR_MortarBattery*,int){refusals++;}int SupplyFailure(ERR_MortarBattery*,float){return 0;}} systemInstance;
struct Base {
Utility* m_Utility=nullptr;int calls=0;bool aiming=false;static constexpr int PORT_AIMING_NEEDED=1;
ENodeResult EOnTaskSimulate(void*,float){calls++;auto b=m_Utility->behavior;if(++b->count==8)b->completions++;return ENodeResult::SUCCESS;}
void SetVariableOut(int,bool value){aiming=value;}
};
struct Node:Base {'''+cycle+'''};
int main(){
ERR_MortarBattery battery;SCR_AIStaticArtilleryBehavior behavior;behavior.battery=&battery;Utility utility{&behavior};Node n;n.m_Utility=&utility;
assert(n.EOnTaskSimulate(nullptr,0)==ENodeResult::RUNNING&&n.calls==0);
battery.OrderShots=1;battery.supply=ERR_MortarSupplyState::PENDING;
assert(n.EOnTaskSimulate(nullptr,0)==ENodeResult::RUNNING&&n.calls==0);
battery.supply=ERR_MortarSupplyState::READY;
for(int shot=1;shot<=8;shot++){
battery.OrderShots=shot;n.aiming=false;
if(shot==8)battery.supply=ERR_MortarSupplyState::FAILED; // final shot needs no ninth shell
auto result=n.EOnTaskSimulate(nullptr,0);
assert(n.calls==shot&&behavior.count==shot);
assert(result==(shot<8?ENodeResult::SUCCESS:ENodeResult::RUNNING));
assert(n.aiming==(shot<8));
}
assert(behavior.completions==1&&systemInstance.refusals==0);
for(int tick=0;tick<100;tick++)assert(n.EOnTaskSimulate(nullptr,0)==ENodeResult::RUNNING);
assert(n.calls==8&&behavior.completions==1); // no duplicate count or ninth aim
battery.CommandOpen=false;assert(n.EOnTaskSimulate(nullptr,0)==ENodeResult::FAIL);
behavior.battery=nullptr;assert(n.EOnTaskSimulate(nullptr,0)==ENodeResult::SUCCESS); // unrelated native artillery unchanged
}
''')
print('PASS: rapid mortar cycle counts eight physical rounds once and waits for native completion without a ninth aim or shell')
+3
View File
@@ -13,6 +13,8 @@ s=s.replace('ERR_HQ hq','ERR_HQ* hq').replace('hq.','hq->')
run(r''' run(r'''
#include <cassert> #include <cassert>
#include <string> #include <string>
using string = std::string;
struct CLI {string outcome="hq-memory"; void GetCLIParam(string,string& value){value=outcome;}} System;
struct ERR_HQ{bool ready=true;bool ERR_ProbeSquadsReady(){return ready;}}; struct ERR_HQ{bool ready=true;bool ERR_ProbeSquadsReady(){return ready;}};
struct Probe{bool enabled=true,m_Mode=true,m_Failed=false,m_AutoChecked=false;int m_Step=0,automatic=0,checks=0;float m_Deadline=0;bool Enabled(){return enabled;}void Check(bool,std::string){checks++;m_Failed=true;}int AutomaticConvoy(ERR_HQ*){if(automatic==1)m_AutoChecked=true;return automatic;} struct Probe{bool enabled=true,m_Mode=true,m_Failed=false,m_AutoChecked=false;int m_Step=0,automatic=0,checks=0;float m_Deadline=0;bool Enabled(){return enabled;}void Check(bool,std::string){checks++;m_Failed=true;}int AutomaticConvoy(ERR_HQ*){if(automatic==1)m_AutoChecked=true;return automatic;}
METHOD METHOD
@@ -22,6 +24,7 @@ int main(){ERR_HQ hq;
Probe missing;assert(missing.Tick(&hq,0)==0);assert(missing.Tick(&hq,150001)==-1&&missing.checks==1); Probe missing;assert(missing.Tick(&hq,0)==0);assert(missing.Tick(&hq,150001)==-1&&missing.checks==1);
Probe refused;refused.Tick(&hq,0);refused.automatic=-1;assert(refused.Tick(&hq,40000)==-1&&refused.m_Deadline==150000&&!refused.m_AutoChecked); Probe refused;refused.Tick(&hq,0);refused.automatic=-1;assert(refused.Tick(&hq,40000)==-1&&refused.m_Deadline==150000&&!refused.m_AutoChecked);
Probe unavailable;hq.ready=false;assert(unavailable.Tick(&hq,0)==0);assert(unavailable.Tick(&hq,150001)==-1);hq.ready=true; Probe unavailable;hq.ready=false;assert(unavailable.Tick(&hq,0)==0);assert(unavailable.Tick(&hq,150001)==-1);hq.ready=true;
System.outcome="hq-awareness"; hq.ready=false; Probe awareness; assert(awareness.Tick(&hq,1000)==1 && !awareness.m_AutoChecked && awareness.m_Deadline==151000); assert(awareness.Tick(&hq,151001)==-1);
Probe invalid;invalid.enabled=false;assert(invalid.Tick(&hq,0)==-1&&invalid.m_Deadline==0); Probe invalid;invalid.enabled=false;assert(invalid.Tick(&hq,0)==-1&&invalid.m_Deadline==0);
} }
'''.replace('METHOD',s)) '''.replace('METHOD',s))
+2 -1
View File
@@ -61,7 +61,8 @@ struct F {
int main(){ int main(){
assert(Rules::QueueUpdate(true,false,0,0,1000,20000));assert(!Rules::QueueUpdate(true,false,0,0,1000,19999)); assert(Rules::QueueUpdate(true,false,0,0,1000,20000));assert(!Rules::QueueUpdate(true,false,0,0,1000,19999));
assert(!Rules::QueueUpdate(false,false,0,0,1000,20000));assert(!Rules::QueueUpdate(true,true,0,0,1000,20000)); assert(!Rules::QueueUpdate(false,false,0,0,1000,20000));assert(!Rules::QueueUpdate(true,true,0,0,1000,20000));
assert(!Rules::QueueUpdate(true,false,4,0,1000,20000));assert(!Rules::QueueUpdate(true,false,0,0,1000,91000)); assert(Rules::QueueUpdate(true,false,4,0,1000,20000));assert(Rules::QueueUpdate(true,false,5,0,1000,20000));
assert(!Rules::QueueUpdate(true,false,6,0,1000,20000));assert(!Rules::QueueUpdate(true,false,0,0,1000,91000));
assert(!Rules::QueueUpdate(true,false,0,0,22000,20000)); assert(!Rules::QueueUpdate(true,false,0,0,22000,20000));
F f;f.initial();assert(f.p.ReportKnown(&f.first));assert(!f.p.ReportKnown(&f.next));assert(!f.p.AllAccepted()); F f;f.initial();assert(f.p.ReportKnown(&f.first));assert(!f.p.ReportKnown(&f.next));assert(!f.p.AllAccepted());
assert(!f.p.AddObservation(1,1));assert(!f.p.AddObservation(2,0));assert(!f.p.AddObservation(0,100)); assert(!f.p.AddObservation(1,1));assert(!f.p.AddObservation(2,0));assert(!f.p.AddObservation(0,100));
+75 -13
View File
@@ -1,17 +1,79 @@
"""Execute player policy methods; native damage/regen boundaries are modeled."""
from policy_harness import ROOT, body, run from policy_harness import ROOT, body, run
p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_PlayerMedical.c' p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_PlayerMedical.c'
rule=body(p,'static float AllowedDamage(').replace('Math.Clamp','std::clamp').replace('Math.Min','std::min').replace('Math.Max','std::max').replace('0.01','0.01f').replace(', 1)',', 1.0f)').replace('std::max(0,','std::max(0.0f,') source=p.read_text()
run('''#include <algorithm> methods='\n'.join(body(p,s) for s in ['void ERR_EnableCasualtyProtection(', 'override protected bool IsIndefinitelyUnconscious(', 'override float GetResilienceRegenScale('])
#include <cassert> methods=methods.replace('override ','').replace('protected ','').replace('Replication.IsServer()','server').replace('super.','Native::')
'''+rule+''' run('''#include <cassert>
int main(){ bool server=true;
assert(AllowedDamage(-40,10,100,0)==-40); // medical treatment must pass unchanged struct Native {bool unconscious=false,changed=false;float regen=1;int writes=0;bool bleeding=false;
assert(AllowedDamage(0,10,100,0)==0); void SetPermitUnconsciousness(bool value,bool c){unconscious=value;changed=c;writes++;}
assert(AllowedDamage(5,100,100,0)==5); // ordinary damage still wounds void SetRegenScale(float value,bool){regen=value;writes++;}
assert(AllowedDamage(100000,100,100,0)==99); // lethal blow stops above destruction bool IsIndefinitelyUnconscious(bool){return true;}bool IsBleeding(){return bleeding;}float GetResilienceRegenScale(){return .8f;}
assert(AllowedDamage(100000,30,100,0.2f)>8.99f&&AllowedDamage(100000,30,100,0.2f)<9.01f); };
assert(AllowedDamage(50,1,100,0)==0); // repeated damage cannot finish casualty struct Policy:Native{bool m_ERRRevivablePlayer=false;
assert(AllowedDamage(50,0,100,0)==0); // never turn positive damage into free healing '''+methods+'''
};
int main(){Policy p;server=false;p.ERR_EnableCasualtyProtection();assert(!p.m_ERRRevivablePlayer&&p.writes==0);
server=true;p.ERR_EnableCasualtyProtection();assert(p.unconscious&&p.changed&&p.regen==.25f);
assert(!p.IsIndefinitelyUnconscious());p.bleeding=true;assert(p.GetResilienceRegenScale()==0);
p.bleeding=false;assert(p.GetResilienceRegenScale()==.8f);Policy ai;assert(ai.IsIndefinitelyUnconscious());ai.bleeding=true;assert(ai.GetResilienceRegenScale()==.8f);
} }
''') ''')
print('PASS: lethal and repeated damage clamp above configured destruction thresholds; ordinary wounds and healing retain their values') # Execute the real damage cap across wound/blood scales; healing is untouched.
cap=body(p, 'static float AllowedDamage(').replace('Math.Clamp','std::clamp').replace('Math.Min','std::min').replace('Math.Max','std::max').replace('0.01','0.01f').replace(', 1)',', 1.0f)').replace('(0,','(0.0f,')
run('''#include <algorithm>
#include <cassert>
#include <cmath>
'''+cap+'''
int main(){
for(float maximum:{100.0f,5000.0f}) for(float threshold:{0.0f,.05f,.2f}) {
float floor=maximum*(threshold+.01f);
assert(AllowedDamage(-10,maximum,maximum,threshold)==-10);
assert(AllowedDamage(100000, floor,maximum,threshold)==0);
assert(AllowedDamage(100000, floor/2,maximum,threshold)==0);
float health=maximum;
for(int i=0;i<1000;i++) health-=AllowedDamage(13,health,maximum,threshold);
assert(std::abs(health-floor)<.001f);
assert(AllowedDamage(1,maximum,maximum,threshold)==1);
}
}
''')
# Passive resilience has its own native rate; treatment DPS bypasses this getter.
rate=body(p,'override float CalculatePassiveRegenDPS(').replace('override ','').replace('super.','Native::').replace('SCR_CharacterDamageManagerComponent manager = SCR_CharacterDamageManagerComponent.Cast(GetHitZoneContainer());','Manager* manager = target;').replace('manager.','manager->')
run('''#include <cassert>
struct Manager { bool player; bool ERR_IsRevivablePlayer(){return player;} };
struct Native {float CalculatePassiveRegenDPS(bool){return 4;}};
struct Policy:Native {Manager* target=nullptr;
'''+rate+'''
};
int main(){Policy p;assert(p.CalculatePassiveRegenDPS()==4);Manager ai{false};p.target=&ai;assert(p.CalculatePassiveRegenDPS()==4);ai.player=true;assert(p.CalculatePassiveRegenDPS()==1);assert(p.CalculatePassiveRegenDPS(false)==4);}
''')
assert 'SCR_CharacterResilienceHitZone.Cast(this)) return damage' in source
assert 'damage <= 0' in source and '!manager.ERR_IsRevivablePlayer()' in source
assert 'override void Kill(' in source
assert 'FullHeal(' not in source and 'SetHealth(' not in source
print('PASS: authority-scoped slow recovery, bleeding pauses resilience, medicine unaffected, player-only lethal damage cap without healing')
# Run the production recovery prediction over real effect states modeled at the
# native boundary, including a pending regeneration delay and exhausted effects.
future=body(p,'bool ERR_CanRecoverNaturally(')
future=future.replace('array<ref SCR_PersistentDamageEffect> effects','auto effects').replace('GetAllPersistentEffectsOfType(SCR_PassiveHitZoneRegenDamageEffect, true)','GetAllPersistentEffectsOfType()')
future=future.replace('foreach (SCR_PersistentDamageEffect effect : effects)','for (Effect* effect : effects)').replace('DotDamageEffect dot = DotDamageEffect.Cast(effect);','Effect* dot = effect;').replace('HitZone zone','Zone* zone').replace('dot.','dot->').replace('zone.','zone->').replace('EDamageType.REGENERATION','1')
run('''#include <cassert>
#include <vector>
struct Zone {float value=0;float GetHealthScaled(){return value;}};
struct Effect {Zone* zone;float dps;int type=1;int GetDamageType(){return type;}float GetDPS(){return dps;}Zone* GetAffectedHitZone(){return zone;}};
struct Policy {bool m_ERRRevivablePlayer=true,bleeding=false;float scale=1;Zone blood,resilience;std::vector<Effect*> effects;
bool IsBleeding(){return bleeding;}auto GetAllPersistentEffectsOfType(){return effects;}
Zone* GetBloodHitZone(){return &blood;}Zone* GetResilienceHitZone(){return &resilience;}float GetResilienceRegenScale(){return scale;}
'''+future+'''
};
int main(){Policy p;assert(!p.ERR_CanRecoverNaturally());Effect e{&p.blood,-.1f};p.effects={&e};
assert(p.ERR_CanRecoverNaturally());p.bleeding=true;assert(!p.ERR_CanRecoverNaturally());p.bleeding=false;
p.blood.value=1;assert(!p.ERR_CanRecoverNaturally());p.blood.value=.2;
e.dps=0;assert(!p.ERR_CanRecoverNaturally());e.dps=-1;e.type=2;assert(!p.ERR_CanRecoverNaturally());e.type=1;
e.zone=&p.resilience;p.scale=0;assert(!p.ERR_CanRecoverNaturally());p.scale=.1;assert(p.ERR_CanRecoverNaturally());
p.m_ERRRevivablePlayer=false;assert(!p.ERR_CanRecoverNaturally());
}
''')
print('PASS: viable passive recovery survives native delay; bleeding, disabled/full effects and low-blood resilience do not block defeat')

Some files were not shown because too many files have changed in this diff Show More