Forecast all-down recovery and preserve self-rescue; record Workshop 0.0.133
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user