Files
EscapistsReintroduced/docs/evidence/death-fixture-repair-2026-10-02/cap-supplement.patch
T
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

220 lines
17 KiB
Diff

--- a/addons/core/Scripts/Game/ERR/00_Core/ERR_PlayerMedical.c
+++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_PlayerMedical.c
@@ -48,10 +48,16 @@
#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)
+ bool ERR_ForceFatalLossForTest(notnull Instigator instigator, IEntity expectedActor)
{
string enabled;
if (!Replication.IsServer() || !System.GetCLIParam("errSmoke", enabled) || enabled != "1") return false;
+ IEntity actor = GetOwner();
+ ERR_GameMode mode = ERR_GameMode.Get();
+ if (!expectedActor || actor != expectedActor || !ERR_Actors.Alive(actor) || !mode || !mode.Active() || !mode.Players() || !mode.Players().CurrentActor(actor)) return false;
+ // Vanilla Kill uses HandleDamage, so the ordinary hitzone cap also applies.
+ // Disable it for this exact terminal fixture actor through deferred processing.
+ m_ERRRevivablePlayer = false;
super.Kill(instigator);
return true;
}
--- a/addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSmoke.c
+++ b/addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSmoke.c
@@ -500,7 +500,7 @@
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;
+ if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer), m_ERRSmokePlayer), "explicit terminal fixture fatal loss authorized")) return;
m_ERRSmokeWait = now + 4000;
}
protected void ERR_SmokeTick()
@@ -937,7 +937,7 @@
m_ERRSmokeStep = 9;
SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent));
damage.EnableDamageHandling(true);
- if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer)), "explicit terminal fixture fatal loss authorized")) return;
+ if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer), m_ERRSmokePlayer), "explicit terminal fixture fatal loss authorized")) return;
m_ERRSmokeWait = now + 4000;
}
else if (m_ERRSmokeStep == 110)
@@ -948,7 +948,7 @@
m_ERRSmokeStep = 9;
SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent));
damage.EnableDamageHandling(true);
- if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer)), "explicit terminal fixture fatal loss authorized")) return;
+ if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer), m_ERRSmokePlayer), "explicit terminal fixture fatal loss authorized")) return;
m_ERRSmokeWait = now + 4000;
}
else if (m_ERRSmokeStep == 109)
@@ -963,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); if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer)), "explicit terminal fixture fatal loss authorized")) return;
+ damage.EnableDamageHandling(true); if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer), m_ERRSmokePlayer), "explicit terminal fixture fatal loss authorized")) return;
m_ERRSmokeWait = now + 4000;
}
else if (m_ERRSmokeStep == 108)
@@ -975,7 +975,7 @@
m_ERRSmokeStep = 9;
SCR_CharacterDamageManagerComponent damage;
damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent));
- damage.EnableDamageHandling(true); if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer)), "explicit terminal fixture fatal loss authorized")) return;
+ damage.EnableDamageHandling(true); if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer), m_ERRSmokePlayer), "explicit terminal fixture fatal loss authorized")) return;
m_ERRSmokeWait = now + 4000;
}
else if (m_ERRSmokeStep == 107)
@@ -988,7 +988,7 @@
SCR_CharacterDamageManagerComponent damage;
damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent));
damage.EnableDamageHandling(true);
- if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer)), "explicit terminal fixture fatal loss authorized")) return;
+ if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer), m_ERRSmokePlayer), "explicit terminal fixture fatal loss authorized")) return;
m_ERRSmokeWait = now + 4000;
}
else if (m_ERRSmokeStep == 3100)
@@ -1035,7 +1035,7 @@
SCR_CharacterDamageManagerComponent damage;
damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent));
damage.EnableDamageHandling(true);
- if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer)), "explicit terminal fixture fatal loss authorized")) return;
+ if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer), m_ERRSmokePlayer), "explicit terminal fixture fatal loss authorized")) return;
m_ERRSmokeWait = now + 4000;
}
else if (m_ERRSmokeStep == 100)
@@ -1046,7 +1046,7 @@
m_ERRSmokeStep = 9;
SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent));
damage.EnableDamageHandling(true);
- if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer)), "explicit terminal fixture fatal loss authorized")) return;
+ if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer), m_ERRSmokePlayer), "explicit terminal fixture fatal loss authorized")) return;
m_ERRSmokeWait = now + 4000;
}
else if (m_ERRSmokeStep == 92)
--- a/tests/test_native_player_death_fixture.py
+++ b/tests/test_native_player_death_fixture.py
@@ -1,4 +1,4 @@
-"""Execute actual protected/dead fixture staging; native damage and queue are boundaries."""
+"""Actual fixture staging and hitzone cap, with vanilla max-health damage boundary."""
import os
from pathlib import Path
from policy_harness import ROOT, body, run
@@ -6,27 +6,30 @@
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('protected ', '').replace('override ','').replace('notnull Instigator instigator','int instigator').replace('IEntity expectedActor','Actor* expectedActor')
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('IEntity actor = GetOwner();','Actor* actor = GetOwner();').replace('ERR_GameMode mode = ERR_GameMode.Get();','Mode* mode = Mode::current;').replace('ERR_Actors.Alive(actor)','(actor && actor->alive)')
+ s=s.replace('mode.', 'mode->').replace('Players().CurrentActor','Players()->CurrentActor')
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;}'
+fatal=convert(body(p,'bool ERR_ForceFatalLossForTest('))
+# Permit the unchanged helper's older signature for the runtime red test.
+if 'Actor* expectedActor' not in fatal:
+ fatal=fatal.replace('int instigator)', 'int instigator, Actor* expectedActor)')
+begin=convert(body(f,'protected void ERR_BeginProtectedDeathProbe('))
+complete=convert(body(f,'protected void ERR_CompleteProtectedDeathProbe('))
+complete=complete.replace('ERR_ForceFatalLossForTest(0)', 'ERR_ForceFatalLossForTest(0, m_ERRSmokePlayer)')
+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,')
+effective=body(p,'override float ComputeEffectiveDamage(')
+effective=effective.replace('override ', '').replace('notnull BaseDamageContext damageContext','float damageContext').replace('super.','NativeZone::')
+effective=effective.replace('Replication.IsServer()','server').replace('SCR_CharacterResilienceHitZone.Cast(this)','resilience')
+effective=effective.replace('SCR_CharacterDamageManagerComponent manager = SCR_CharacterDamageManagerComponent.Cast(GetHitZoneContainer());','Medical* manager = medical;').replace('manager.','manager->').replace('ERR_PlayerMedicalRules.','').replace('EDamageState.DESTROYED','0')
run(r'''
+#include <algorithm>
#include <cassert>
#include <string>
#include <map>
@@ -34,47 +37,58 @@
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 Roster {Actor* owned=nullptr;bool CurrentActor(Actor* actor){return actor==owned;}};
+struct Mode {static inline Mode* current=nullptr;bool active=true,haveRoster=true;Roster roster;bool Active(){return active;}Roster* Players(){return haveRoster?&roster:nullptr;}};
+struct Native {Actor* actor;int fatalities=0;bool deferred=false,damagePending=false;virtual float AppliedDamage(float)=0;void Kill(int){fatalities++;if(deferred){damagePending=true;return;}Damage();}void Damage(){damagePending=false;actor->alive=AppliedDamage(100)<100;}Actor* GetOwner(){return actor;}};
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;}
+ float AppliedDamage(float damage) override;
''' + kill+fatal+r'''
};
+''' + cap + r'''
+struct NativeZone {float ComputeEffectiveDamage(float damage,bool){return damage;}};
+struct Zone: NativeZone {bool m_ERRWoundQuery=false,resilience=false;Medical* medical;float GetHealth(){return 100;}float GetMaxHealth(){return 100;}float GetDamageStateThreshold(int){return 0;}
+''' + effective + r'''
+};
+float Medical::AppliedDamage(float damage){Zone zone;zone.medical=this;return zone.ComputeEffectiveDamage(damage,false);}
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;}
+ Actor actor;Medical medical;Mode mode;Actor* m_ERRSmokePlayer=&actor;int m_ERRSmokeStep=0;float m_ERRSmokeWait=0;int failures=0;
+ Fixture(){medical.actor=&actor;mode.roster.owned=&actor;Mode::current=&mode;}
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);
+ // Old helper failed here: super.Kill still goes through the actual hitzone cap.
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);
+ assert(!f.medical.m_ERRRevivablePlayer && f.medical.requests==1 && f.m_ERRSmokeWait==5100);
+ {Fixture q;q.medical.deferred=true;assert(q.medical.ERR_ForceFatalLossForTest(0,&q.actor));assert(q.actor.alive&&q.medical.damagePending&&!q.medical.m_ERRRevivablePlayer);q.medical.Damage();assert(!q.actor.alive);}
+ // An unrelated ordinary actor retains full protection; test flag never global.
+ {Fixture q;q.medical.Kill(0);assert(q.actor.alive&&q.medical.requests==1&&q.medical.fatalities==0&&q.medical.m_ERRRevivablePlayer);}
{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;Actor other;assert(!q.medical.ERR_ForceFatalLossForTest(0,&other)&&q.medical.m_ERRRevivablePlayer&&q.actor.alive);assert(!q.medical.ERR_ForceFatalLossForTest(0,nullptr)&&q.medical.m_ERRRevivablePlayer);}
+ {Fixture q;q.mode.roster.owned=nullptr;assert(!q.medical.ERR_ForceFatalLossForTest(0,&q.actor)&&q.medical.m_ERRRevivablePlayer);}
+ {Fixture q;q.mode.active=false;assert(!q.medical.ERR_ForceFatalLossForTest(0,&q.actor)&&q.medical.m_ERRRevivablePlayer);}
+ {Fixture q;q.mode.haveRoster=false;assert(!q.medical.ERR_ForceFatalLossForTest(0,&q.actor)&&q.medical.m_ERRRevivablePlayer);}
+ {Fixture q;Mode::current=nullptr;assert(!q.medical.ERR_ForceFatalLossForTest(0,&q.actor)&&q.medical.m_ERRRevivablePlayer);}
+ {Fixture q;server=false;assert(!q.medical.ERR_ForceFatalLossForTest(0,&q.actor)&&q.actor.alive&&q.medical.m_ERRRevivablePlayer);server=true;}
+ {Fixture q;System::args.clear();assert(!q.medical.ERR_ForceFatalLossForTest(0,&q.actor)&&q.actor.alive&&q.medical.m_ERRRevivablePlayer);System::args["errSmoke"]="0";assert(!q.medical.ERR_ForceFatalLossForTest(0,&q.actor)&&q.actor.alive&&q.medical.m_ERRRevivablePlayer);}
{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')
+print('PASS: actual protected/capped damage plus exact-actor terminal fixture bypass through delayed native damage, fail-closed ownership and untouched ordinary actor; no native claim')