--- a/addons/core/Scripts/Game/ERR/00_Core/ERR_PlayerMedical.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_PlayerMedical.c @@ -45,6 +45,17 @@ if (!m_ERRRevivablePlayer) { super.Kill(instigator); return; } ERR_RequestKnockout(); } +#ifdef WORKBENCH + // Explicit terminal-lifecycle fixture only. Ordinary Kill retains casualty protection. + // This helper belongs in the same modded class so super bypasses our Kill override. + bool ERR_ForceFatalLossForTest(notnull Instigator instigator) + { + string enabled; + if (!Replication.IsServer() || !System.GetCLIParam("errSmoke", enabled) || enabled != "1") return false; + super.Kill(instigator); + return true; + } +#endif // 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() --- a/addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSmoke.c +++ b/addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSmoke.c @@ -484,6 +484,25 @@ return checked > 0; } protected void ERR_EndScreenshot() { System.MakeScreenshot("$logs:err-result"); } + protected void ERR_BeginProtectedDeathProbe(float now) + { + SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent)); + if (!ERR_Check(damage && damage.ERR_IsRevivablePlayer(), "admitted prisoner has ordinary casualty protection")) return; + m_ERRSmokeStep = 92; + damage.Kill(Instigator.CreateInstigator(m_ERRSmokePlayer)); + m_ERRSmokeWait = now + 1000; + } + protected void ERR_CompleteProtectedDeathProbe(float now) + { + if (!ERR_Check(ERR_Actors.Alive(m_ERRSmokePlayer), "ordinary protected Kill retains the living prisoner")) return; + SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent)); + CharacterControllerComponent character = CharacterControllerComponent.Cast(m_ERRSmokePlayer.FindComponent(CharacterControllerComponent)); + if (!ERR_Check(damage && damage.ERR_IsRevivablePlayer() && character && character.IsUnconscious(), "ordinary protected Kill makes the prisoner unconscious")) return; + Print("ERR DEATH FIXTURE: protected casualty verified; explicitly forcing fatal loss only to test spent-life spectator and terminal cleanup"); + m_ERRSmokeStep = 9; + if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer)), "explicit terminal fixture fatal loss authorized")) return; + m_ERRSmokeWait = now + 4000; + } protected void ERR_SmokeTick() { float now = GetWorld().GetWorldTime(); @@ -755,9 +774,7 @@ } if (m_ERRTestPlayerDeath) { - m_ERRSmokeStep = 9; - SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent)).Kill(Instigator.CreateInstigator(m_ERRSmokePlayer)); - m_ERRSmokeWait = now + 4000; + ERR_BeginProtectedDeathProbe(now); return; } DamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false); @@ -920,7 +937,7 @@ m_ERRSmokeStep = 9; SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent)); damage.EnableDamageHandling(true); - damage.Kill(Instigator.CreateInstigator(m_ERRSmokePlayer)); + if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer)), "explicit terminal fixture fatal loss authorized")) return; m_ERRSmokeWait = now + 4000; } else if (m_ERRSmokeStep == 110) @@ -931,7 +948,7 @@ m_ERRSmokeStep = 9; SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent)); damage.EnableDamageHandling(true); - damage.Kill(Instigator.CreateInstigator(m_ERRSmokePlayer)); + if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer)), "explicit terminal fixture fatal loss authorized")) return; m_ERRSmokeWait = now + 4000; } else if (m_ERRSmokeStep == 109) @@ -946,7 +963,7 @@ SCR_CharacterDamageManagerComponent damage; damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent)); if (!ERR_Check(damage != null, "local handoff final original prisoner damage component retained")) return; - damage.EnableDamageHandling(true); damage.Kill(Instigator.CreateInstigator(m_ERRSmokePlayer)); + damage.EnableDamageHandling(true); if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer)), "explicit terminal fixture fatal loss authorized")) return; m_ERRSmokeWait = now + 4000; } else if (m_ERRSmokeStep == 108) @@ -958,7 +975,7 @@ m_ERRSmokeStep = 9; SCR_CharacterDamageManagerComponent damage; damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent)); - damage.EnableDamageHandling(true); damage.Kill(Instigator.CreateInstigator(m_ERRSmokePlayer)); + damage.EnableDamageHandling(true); if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer)), "explicit terminal fixture fatal loss authorized")) return; m_ERRSmokeWait = now + 4000; } else if (m_ERRSmokeStep == 107) @@ -971,7 +988,7 @@ SCR_CharacterDamageManagerComponent damage; damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent)); damage.EnableDamageHandling(true); - damage.Kill(Instigator.CreateInstigator(m_ERRSmokePlayer)); + if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer)), "explicit terminal fixture fatal loss authorized")) return; m_ERRSmokeWait = now + 4000; } else if (m_ERRSmokeStep == 3100) @@ -1018,7 +1035,7 @@ SCR_CharacterDamageManagerComponent damage; damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent)); damage.EnableDamageHandling(true); - damage.Kill(Instigator.CreateInstigator(m_ERRSmokePlayer)); + if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer)), "explicit terminal fixture fatal loss authorized")) return; m_ERRSmokeWait = now + 4000; } else if (m_ERRSmokeStep == 100) @@ -1029,13 +1046,17 @@ m_ERRSmokeStep = 9; SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent)); damage.EnableDamageHandling(true); - damage.Kill(Instigator.CreateInstigator(m_ERRSmokePlayer)); + if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer)), "explicit terminal fixture fatal loss authorized")) return; m_ERRSmokeWait = now + 4000; } + else if (m_ERRSmokeStep == 92) + { + ERR_CompleteProtectedDeathProbe(now); + } else if (m_ERRSmokeStep == 9) { SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController()); - if (!ERR_Check(!ERR_Actors.Alive(m_ERRSmokePlayer), "native damage kills prisoner")) return; + if (!ERR_Check(!ERR_Actors.Alive(m_ERRSmokePlayer), "controlled native fatal loss kills prisoner")) return; if (!ERR_Check(controller && controller.ERR_ObserverState() == ERR_ObserverReason.SPENT_LIFE, "death selects spectator without another life")) return; if (!ERR_Check(controller.ERR_HasObserverCamera() && SCR_ManualCamera.Cast(GetGame().GetCameraManager().CurrentCamera()), "spectator camera activates after native death")) return; m_ERRSmokeStep = 91; --- /dev/null +++ b/tests/test_native_player_death_fixture.py @@ -0,0 +1,80 @@ +"""Execute actual protected/dead fixture staging; native damage and queue are boundaries.""" +import os +from pathlib import Path +from policy_harness import ROOT, body, run +base=Path(os.environ.get('ERR_DEATH_FIXTURE_ROOT', ROOT)) +p=base/'addons/core/Scripts/Game/ERR/00_Core/ERR_PlayerMedical.c' +f=base/'addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSmoke.c' +def convert(s): + s=s.replace('protected ', '').replace('override ','').replace('notnull Instigator instigator','int instigator') + s=s.replace('Replication.IsServer()','server').replace('System.GetCLIParam','System::GetCLIParam').replace('super.','Native::') + s=s.replace('SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent));','Medical* damage = &medical;') + s=s.replace('CharacterControllerComponent character = CharacterControllerComponent.Cast(m_ERRSmokePlayer.FindComponent(CharacterControllerComponent));','Actor* character = m_ERRSmokePlayer;') + s=s.replace('Instigator.CreateInstigator(m_ERRSmokePlayer)','0').replace('ERR_Actors.Alive(m_ERRSmokePlayer)','m_ERRSmokePlayer->alive') + s=s.replace('damage.','damage->').replace('character.','character->') + return s +kill=convert(body(p,'override void Kill(')) +if 'bool ERR_ForceFatalLossForTest(' in p.read_text(): + fatal=convert(body(p,'bool ERR_ForceFatalLossForTest(')) + begin=convert(body(f,'protected void ERR_BeginProtectedDeathProbe(')) + complete=convert(body(f,'protected void ERR_CompleteProtectedDeathProbe(')) +else: + # Reproduce the pre-fix path using its actual branch, not a replacement policy. + old=body(f,'if (m_ERRTestPlayerDeath)\n\t\t\t{') + old=old[old.index('{'):] + old=old.replace('SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent)).Kill(Instigator.CreateInstigator(m_ERRSmokePlayer));','medical.Kill(0);') + begin='void ERR_BeginProtectedDeathProbe(float now) '+old + complete='void ERR_CompleteProtectedDeathProbe(float) {}' + fatal='bool ERR_ForceFatalLossForTest(int) {return false;}' +run(r''' +#include +#include +#include +using string=std::string; +bool server=true; +namespace System { std::map args; bool GetCLIParam(string key,string& out){auto it=args.find(key);if(it==args.end())return false;out=it->second;return true;} } +struct Actor {bool alive=true, unconscious=false;bool IsUnconscious(){return unconscious;}}; +struct Native {Actor* actor;int fatalities=0; void Kill(int){fatalities++;actor->alive=false;}}; +struct Medical: Native { + bool m_ERRRevivablePlayer=true,pending=false; int requests=0; + void ERR_RequestKnockout(){requests++;pending=true;} + void Settle(){if(pending){actor->unconscious=true;pending=false;}} + bool ERR_IsRevivablePlayer(){return m_ERRRevivablePlayer;} +''' + kill+fatal+r''' +}; +void Print(string){} +struct Fixture { + Actor actor;Medical medical;Actor* m_ERRSmokePlayer=&actor;int m_ERRSmokeStep=0;float m_ERRSmokeWait=0;int failures=0; + Fixture(){medical.actor=&actor;} + bool ERR_Check(bool ok,string){if(!ok)failures++;return ok;} +''' + begin+complete+r''' +}; +int main(){ + System::args={{"errSmoke","1"}}; + Fixture f; f.ERR_BeginProtectedDeathProbe(100); + // This fails on the prior actual branch: it entered the death assertion stage + // despite invoking protected Kill, whose native callback has not run yet. + assert(f.m_ERRSmokeStep==92 && f.actor.alive && f.medical.requests==1 && f.medical.fatalities==0); + assert(!f.actor.unconscious && f.m_ERRSmokeWait==1100); + f.medical.Settle(); f.ERR_CompleteProtectedDeathProbe(1100); + assert(f.failures==0 && f.m_ERRSmokeStep==9 && !f.actor.alive && f.medical.fatalities==1); + assert(f.medical.m_ERRRevivablePlayer && f.medical.requests==1 && f.m_ERRSmokeWait==5100); + // Explicit native bypass cannot alter ordinary Kill's protection afterward. + f.actor.alive=true;f.actor.unconscious=false;f.medical.Kill(0); + assert(f.actor.alive && f.medical.requests==2 && f.medical.fatalities==1); + {Fixture q;q.ERR_CompleteProtectedDeathProbe(1100);assert(q.failures==1&&q.medical.fatalities==0);} + {Fixture q;q.actor.alive=false;q.ERR_CompleteProtectedDeathProbe(1100);assert(q.failures==1&&q.medical.fatalities==0);} + {Fixture q;q.medical.m_ERRRevivablePlayer=false;q.ERR_BeginProtectedDeathProbe(100);assert(q.failures==1&&q.medical.requests==0&&q.medical.fatalities==0);} + {Fixture q;server=false;assert(!q.medical.ERR_ForceFatalLossForTest(0)&&q.actor.alive);server=true;} + {Fixture q;System::args.clear();assert(!q.medical.ERR_ForceFatalLossForTest(0)&&q.actor.alive);System::args["errSmoke"]="0";assert(!q.medical.ERR_ForceFatalLossForTest(0)&&q.actor.alive);} + {Fixture q;q.medical.m_ERRRevivablePlayer=false;q.medical.Kill(0);assert(!q.actor.alive&&q.medical.requests==0&&q.medical.fatalities==1);} +} +''') +# Static scoping complements the executed policies: no bypass in ordinary builds. +s=p.read_text();at=s.index('bool ERR_ForceFatalLossForTest(') +assert s.rfind('#ifdef WORKBENCH',0,at)>s.rfind('#endif',0,at) +assert 'm_ERRRevivablePlayer =' not in body(p,'bool ERR_ForceFatalLossForTest(') +tick=body(f,'protected void ERR_SmokeTick()') +assert tick.count('damage.ERR_ForceFatalLossForTest(')==7 +assert 'damage.Kill(' not in tick +print('PASS: actual protected-Kill then explicit fatal-loss fixture, fail-closed staging, unchanged ordinary protection and seven scoped support endings; no native claim')