Forecast all-down recovery and preserve self-rescue; record Workshop 0.0.133
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-10-31 14:32:30 +01:00
parent 6738eda748
commit d21adb90d5
13 changed files with 1208 additions and 30 deletions
@@ -0,0 +1,154 @@
// Immutable snapshots only: never tick native effects or alter a patient's zones.
// Seconds: >=0 = predicted wake, -1 = beyond horizon, -2 = unsupported/uncertain.
class ERR_RecoveryFlow
{
bool Passive;
float BloodRate;
float ResilienceRate;
float Delay;
float End = 301;
}
class ERR_RecoveryForecast
{
static bool KeepsRound(float forecast, bool selfRescue, bool existingRecovery)
{
return selfRescue || forecast >= 0 || (forecast == -2 && existingRecovery);
}
static float Horizon() { return 300; }
static bool Awake(float blood, float resilience, float bloodThreshold, float resilienceThreshold)
{
return blood > bloodThreshold && resilience > resilienceThreshold;
}
static float Overlap(float start, float end, float delay, float expires)
{
return Math.Max(0, Math.Min(end, expires) - Math.Max(start, delay));
}
static float Predict(float blood, float resilience, float bloodThreshold, float resilienceThreshold, array<ref ERR_RecoveryFlow> flows)
{
if (Awake(blood, resilience, bloodThreshold, resilienceThreshold)) return 0;
for (int second = 0; second < 300; second++)
{
// All flow starts/ends partition this second, including finite medicines.
float time = second;
while (time < second + 1)
{
float next = second + 1;
foreach (ERR_RecoveryFlow flow : flows)
{
if (flow.Delay > time && flow.Delay < next) next = flow.Delay;
if (flow.End > time && flow.End < next) next = flow.End;
}
float db, dr;
foreach (ERR_RecoveryFlow rate : flows)
{
float duration = Overlap(time, next, rate.Delay, rate.End);
db += rate.BloodRate * duration; dr += rate.ResilienceRate * duration;
}
blood = Math.Clamp(blood + db, 0, 1);
resilience = Math.Clamp(resilience + dr, 0, 1);
if (Awake(blood, resilience, bloodThreshold, resilienceThreshold)) return next;
time = next;
}
}
return -1;
}
static float Estimate(SCR_CharacterDamageManagerComponent damage)
{
if (!damage || !damage.ERR_IsRevivablePlayer()) return -2;
return damage.ERR_ForecastRecovery();
}
}
modded class SCR_PassiveHitZoneRegenDamageEffect
{
float ERR_RemainingRegenDelay() { return Math.Max(0, m_fLocalRegenDelayTime - m_fDelayTimer); }
}
modded class SCR_CharacterDamageManagerComponent
{
float ERR_ForecastRecovery()
{
SCR_CharacterBloodHitZone blood = GetBloodHitZone();
SCR_CharacterResilienceHitZone resilience = GetResilienceHitZone();
if (!blood || !resilience || blood.GetMaxHealth() <= 0 || resilience.GetMaxHealth() <= 0) return -2;
if (m_aBeingHealedGroup && !m_aBeingHealedGroup.IsEmpty()) return -2;
array<ref ERR_RecoveryFlow> flows = {};
array<ref SCR_PersistentDamageEffect> effects = GetAllPersistentEffectsOfType(SCR_PersistentDamageEffect, true);
bool bloodChanges, passiveResilience, unknown, pausedNatural;
foreach (SCR_PersistentDamageEffect effect : effects)
{
DotDamageEffect dot = DotDamageEffect.Cast(effect);
if (!dot) { unknown = true; continue; }
HitZone zone = dot.GetAffectedHitZone();
typename kind = effect.Type();
bool bleeding = kind == SCR_BleedingDamageEffect;
bool passive = kind == SCR_PassiveHitZoneRegenDamageEffect;
bool saline = kind == SCR_SalineDamageEffect;
bool epi = kind == ERR_EpinephrineEffect;
// Physical wound regen and native morphine do not restore consciousness.
if (kind == SCR_PhysicalHitZonesRegenDamageEffect || kind == SCR_MorphineDamageEffect) continue;
if (passive && !effect.IsActive()) { pausedNatural = true; continue; }
if (!effect.IsActive()) { unknown = true; continue; }
if (!bleeding && !passive && !saline && !epi) { unknown = true; continue; }
if (!bleeding && zone != blood && zone != resilience) continue;
ERR_RecoveryFlow flow = new ERR_RecoveryFlow();
flow.Passive = passive;
float maximum = dot.GetMaxDuration();
if (maximum > 0) flow.End = Math.Max(0, maximum - dot.GetCurrentDuration());
HitZone target = zone;
if (bleeding) target = blood;
float rate = -dot.GetDPS() * target.GetBaseDamageMultiplier() * target.GetDamageMultiplier(dot.GetDamageType());
if (passive)
{
flow.Delay = SCR_PassiveHitZoneRegenDamageEffect.Cast(effect).ERR_RemainingRegenDelay();
// A terminated blood regen can still be visible until native cleanup.
if (IsBleeding()) rate = 0;
if (zone == resilience) passiveResilience = true;
}
if (bleeding)
{
SCR_CharacterHitZone wound = SCR_CharacterHitZone.Cast(zone);
if (!wound) { unknown = true; continue; }
if (GetGroupTourniquetted(wound.GetHitZoneGroup())) rate *= GetTourniquetStrengthMultiplier();
flow.BloodRate = rate / blood.GetMaxHealth();
// Expiring bleeding can create new delayed native regen: do not declare
// hopelessness based on a snapshot that cannot yet contain that effect.
if (flow.End <= 300) unknown = true;
}
else if (zone == blood) flow.BloodRate = rate / blood.GetMaxHealth();
else flow.ResilienceRate = rate / resilience.GetMaxHealth();
if (flow.BloodRate != 0) bloodChanges = true;
flows.Insert(flow);
}
// Native treatment suppresses passive resilience regeneration while the
// medicine acts. Do not add both DPS values as simultaneous recovery.
bool treatmentOverlap;
foreach (ERR_RecoveryFlow regen : flows)
{
if (!regen.Passive || regen.ResilienceRate <= 0) continue;
foreach (ERR_RecoveryFlow medicine : flows)
{
if (medicine.Passive || medicine.ResilienceRate <= 0) continue;
regen.Delay = Math.Max(regen.Delay, medicine.End);
treatmentOverlap = true;
}
}
float bt = blood.GetDamageStateThreshold(ECharacterBloodState.UNCONSCIOUS);
float rt = resilience.GetDamageStateThreshold(ECharacterResilienceState.WEAKENED);
float predicted = ERR_RecoveryForecast.Predict(blood.GetHealthScaled(), resilience.GetHealthScaled(), bt, rt, flows);
if (unknown || (pausedNatural && predicted < 0)) return -2;
// If the estimate needs post-treatment passive recovery, the native delay
// reset may move that start later. Retain safeguards instead of inventing it.
if (treatmentOverlap)
foreach (ERR_RecoveryFlow treatment : flows)
if (!treatment.Passive && treatment.ResilienceRate > 0 && (predicted < 0 || predicted > treatment.End)) return -2;
// Native resilience rates depend on changing blood-state bands. A snapshot's
// DPS cannot establish that future rate. Use an optimistic blood-only bound
// to prove impossibility; otherwise defer rather than kill a viable casualty.
if (bloodChanges && passiveResilience)
{
float optimistic = ERR_RecoveryForecast.Predict(blood.GetHealthScaled(), 1, bt, rt, flows);
if (optimistic < 0) return -1;
return -2;
}
return predicted;
}
}
@@ -42,6 +42,7 @@ class ERR_RecoveryService
protected float m_NextMedicalLog;
protected string m_MedicalDetails;
protected bool m_DefeatLogged;
protected float m_NextForecast;
protected ref ScriptInvoker m_OnChanged = new ScriptInvoker();
void ERR_RecoveryService(ERR_GameMode mode) { m_Mode = mode; }
ScriptInvoker OnChanged() { return m_OnChanged; }
@@ -55,7 +56,7 @@ class ERR_RecoveryService
if (due && !m_DefeatLogged)
{
m_DefeatLogged = true;
ERR_HQ.Event("MEDICAL DEFEAT", "30 seconds without a viable rescuer or recovery; " + m_MedicalDetails);
ERR_HQ.Event("MEDICAL DEFEAT", "30 seconds without a viable rescuer, self-injection or recovery within five minutes; " + m_MedicalDetails);
}
return due;
}
@@ -70,9 +71,11 @@ class ERR_RecoveryService
{
if (!Replication.IsServer() || m_Stopped || !m_Mode || !m_Mode.Active()) return;
float now = GetGame().GetWorld().GetWorldTime();
if (now >= m_NextForecast - 1000 && now < m_NextForecast) return;
m_NextForecast = now + 1000;
bool logMedical = now >= m_NextMedicalLog;
string medicalDetails;
int conscious, incapacitated, reserved, reservedRescuers, recovering;
int conscious, incapacitated, reserved, reservedRescuers, recovering, selfRescuers, uncertain;
map<IEntity, ref ERR_RecoverySample> nextSamples = new map<IEntity, ref ERR_RecoverySample>();
array<ref ERR_PlayerLife> lives = {};
m_Mode.Players().Participants(lives);
@@ -99,7 +102,12 @@ class ERR_RecoveryService
bool improving = previous && (sample.Blood > previous.Blood + 0.001 || sample.Resilience > previous.Resilience + 0.001);
bool treatment = medical.ERR_TreatmentInProgress();
bool natural = medical.ERR_CanRecoverNaturally();
if (improving || treatment || natural) recovering++;
float forecast = ERR_RecoveryForecast.Estimate(medical);
bool selfRescue = life.Connected && controlled == life.Actor && m_Mode.Settings().Get("selfEpinephrine") == 1 && ERR_SelfEpinephrineInventory.FindUsable(life.Actor);
if (selfRescue) selfRescuers++;
if (forecast == -2) uncertain++;
if (ERR_RecoveryForecast.KeepsRound(forecast, selfRescue, improving || treatment || natural)) recovering++;
medicalDetails += string.Format("forecastSeconds=%1 selfInjection=%2; ", forecast, selfRescue);
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);
}
@@ -122,9 +130,11 @@ class ERR_RecoveryService
{
message = "No conscious prisoner is currently available to help.";
if (evacuating) message += " The rescue crew is carrying all living prisoners to safety; evacuation continues.";
else if (recovering > 0) message += " Treatment or natural recovery is possible; the round continues.";
else if (selfRescuers > 0) message += " Self epinephrine is available. Press Space to use your carried injector; the round continues.";
else if (recovering > 0 && uncertain > 0) message += " Recovery timing is uncertain; existing treatment and recovery safeguards remain active.";
else if (recovering > 0) message += " A prisoner is expected to wake within five minutes under current medical conditions.";
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 += " Nobody is expected to wake within five minutes and no self-injection is available. The mission fails after 30 uninterrupted seconds in this state.";
}
Publish(message);
}
@@ -51,10 +51,34 @@ modded class SCR_PlayerController
if (mode && mode.Players()) life = mode.Players().Get(GetPlayerId());
if (!mode || !ERR_SelfEpinephrineRules.Allowed(mode.Active(), mode.Settings().Get("selfEpinephrine") == 1,
life && life.Actor == actor && mode.Players().CurrentActor(actor) == life, ERR_Actors.Alive(actor), controller && controller.IsUnconscious())) return "";
IEntity injector = ERR_SelfEpinephrineInventory.FindUsable(actor);
if (!injector) return "No usable epinephrine injector in your inventory, or treatment is already active.";
SCR_ConsumableItemComponent item = SCR_ConsumableItemComponent.Cast(injector.FindComponent(SCR_ConsumableItemComponent));
SCR_ConsumableMorphine effect = SCR_ConsumableMorphine.Cast(item.GetConsumableEffect());
item.ApplyItemEffect(actor, actor, effect.GetAnimationParameters(injector, actor), injector, effect.GetDeleteOnUse());
return "Epinephrine administered. Its normal medical effect is active.";
}
[RplRpc(RplChannel.Reliable, RplRcver.Owner)]
protected void ERR_SelfEpinephrineResponse(string message)
{
if (this != GetGame().GetPlayerController()) return;
SCR_HintManagerComponent hints = SCR_HintManagerComponent.GetInstance();
if (hints) hints.ShowCustom(message, "Self epinephrine", 6);
}
}
// Shared read-only eligibility: defeat prediction and the actual Space action
// must agree, including native duplicate-dose rejection and item preference.
class ERR_SelfEpinephrineInventory
{
static IEntity FindUsable(IEntity actor)
{
if (!ERR_Actors.Alive(actor) || ERR_Actors.CanFight(actor)) return null;
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent));
array<IEntity> items = {};
if (inventory) inventory.GetItems(items);
if (items.Count() > 512) return "Unable to inspect your medical supplies.";
if (items.Count() > 512) return null;
// Prefer the standard injector; preserve each item's ordinary medical effect.
for (int kind = 0; kind < 2; kind++)
foreach (IEntity injector : items)
@@ -68,18 +92,8 @@ modded class SCR_PlayerController
if (kind == 1 && !ERR_ConsumableCombatEpinephrine.Cast(effect)) continue;
SCR_EConsumableFailReason reason;
if (!effect.CanApplyEffect(actor, actor, reason)) continue;
// An unconscious player cannot perform the standing injection animation.
// Use the native effect/consumption path on this exact carried item once.
item.ApplyItemEffect(actor, actor, effect.GetAnimationParameters(injector, actor), injector, effect.GetDeleteOnUse());
return "Epinephrine administered. Its normal medical effect is active.";
return injector;
}
return "No usable epinephrine injector in your inventory, or treatment is already active.";
}
[RplRpc(RplChannel.Reliable, RplRcver.Owner)]
protected void ERR_SelfEpinephrineResponse(string message)
{
if (this != GetGame().GetPlayerController()) return;
SCR_HintManagerComponent hints = SCR_HintManagerComponent.GetInstance();
if (hints) hints.ShowCustom(message, "Self epinephrine", 6);
return null;
}
}