Files
jakub 6a667d8deb Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
2026-10-13 22:52:53 +02:00

217 lines
13 KiB
Diff

--- 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 <cassert>
+#include <string>
+#include <map>
+using string=std::string;
+bool server=true;
+namespace System { std::map<string,string> 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')