Files
wwdpublic/Content.Server/Psionics/PsionicsSystem.cs
RedFoxIV daf4f66414 "Proper" "Softcrit" "Support" (#1545)
# Description

Implements the softcrit functionality.
Similiar to critical state but spessmen will be able to communicate and
crawl around, but not pick up items.
Also supports configuring what is and isn't allowed in different
MobStates (per mob prototype): you can enable picking up items while in
softcrit so people can pick up their lasgun and continue shooting after
taking a 40x46mm to their ass cheeks from the guest nukies while being
dragged to safety.

![escape-from-tarkov-raid](https://github.com/user-attachments/assets/7f31702d-5677-4daf-a13d-8a9525fd3f9f)

<details> <summary><h1>Technical details</h1></summary>
New prototype type: "mobStateParams" (`MobStateParametersPrototype`)
Used to specify what can and can't be done when in a certain mobstate.
Of note that they are not actually bound to any `MobState` by
themselves. To assign a params prototype to a mobstate, use
`InitMobStateParams` in `MobStateComponent`.
It has to be a prototype because if I just did something akin to
`Dictionary<MobState, Dictionary<string, bool>>`, you'd have to check
the parent and copy every flag besides the one you wish to modify. That
is, if I understand how the prototype system works correctly, which I
frankly doubt. <!-- Working on softcrit made me hate prototypes. -->

MobStateComponent now has:
- `Dictionary<string, string> InitMobStateParams`, for storing "mobstate
- parameter prototype" pairs. `<string, string>` because it has to be
editable via mob prototypes. Named "mobStateParams" for mob prototypes.
- `public Dictionary<MobState, MobStateParametersPrototype>
MobStateParams` for actually storing the params for each state
- `public Dictionary<MobState, MobStateParametersOverride>
MobStateParamsOverrides` for storing overrides.
`MobStateParametersOverride` is a struct which mirrors all
`MobStateParametersPrototype`'s fields, except they're all nullable.
This is meant for code which wants to temporarily override some setting,
like a spell which allows dead people to talk. This is not the best
solution, but it should do at first. A better option would be tracking
each change separately, instead of hoping different systems overriding
the same flag will play nicely with eachother.
- a shitton of getter methods

TraitModifyMobState now has:
- `public Dictionary<string, string> Params` to specify a new prototype
to use.
- Important note: All values of `MobStateParametersPrototype` are
nullable, which is a hack to support `TraitModifyMobState`. This trait
takes one `MobStateParametersPrototype` per mobstate and applies all of
its non-null values. This way, a params prototype can be created which
will only have `pointing: true` and the trait can apply it (e.g. to
critstate, so we can spam pointing while dying like it's a game of turbo
dota)
- The above is why that wall of getters exists: They check the relevant
override struct, then the relevant prototype. If both are null, they
default to false (0f for floats.) The only exception is
OxyDamageOverlay, because it's used both for oxy damage overlay (if
null) and as a vision-limiting black void in crit..

MobStateSystem now has:
- a bunch of new "IsSomething"/"CanDoSomething" methods to check the
various flags, alongside rewritten old ones.
-
![image](https://github.com/user-attachments/assets/33a6b296-c12c-4311-9abe-90ca4288e871)
lookin ahh predicate factory

</details>
---

# TODO

done:
- [x] Make proper use of `MobStateSystem.IsIncapacitated()`.
done: some checks were changed, some left as they did what was (more or
less) intended.
<details>Previous `IsIncapacitated()` implementation simply checked if
person was in crit or dead. Now there is a `IsIncapacitated` flag in the
parameters, but it's heavily underutilized. I may need some help on this
one, since I don't know where would be a good place to check for it and
I absolutely will not just scour the entire build in search for them.
</details>

- [x] Separate force-dropping items from being downed
done: dropItemsOnEntering bool field. If true, will drop items upon
entering linked mobstate.
- [x] Don't drop items if `ForceDown` is true but `PickingUp` is also
true.
done: dropItemsOnEntering bool field. If true, will drop items upon
entering linked mobstate.
- [x] Actually check what are "conscious attempts" are used for
done: whether or not mob is conscious. Renamed the bool field
accordingly.
- [x] Look into adding a way to make people choke "slowly" in softcrit
as opposed to choking at "regular speed" in crit. Make that into a param
option? Make that into a float so the speed can be finetuned?
done: `BreathingMultiplier` float field added.
<details>
1f is regular breathing, 0.25 is "quarter-breathing". Air taken is
multiplied by `BreathingMultiplier` and suffocation damage taken (that
is dealt by RespiratorSystem, not all oxy damage) is multiplied by
`1-BreathingMultiplier`.
</details>

- [x] make sure the serializer actually does its job
done: it doesn't. Removed.
- [x] Make an option to prohibit using radio headsets while in softcrit
done: Requires Incapacitated parameter to be false to be able to use
headset radio.
- [x] Make sure it at least compiles

not done:
- [ ] probably move some other stuff to Params if it makes sense. Same
thing as with `IsIncapacitated` though: I kinda don't want to, at least
for now.

---

<details><summary><h1>No media</h1></summary>
<p>

:p

</p>
</details>

---

# Changelog

🆑
- add: Soft critical state. Crawl to safety, or to your doom - whatever
is closer.

---------

Signed-off-by: RedFoxIV <38788538+RedFoxIV@users.noreply.github.com>
Co-authored-by: VMSolidus <evilexecutive@gmail.com>

(cherry picked from commit 9a357c1774f1a783844a07b5414f504ca574d84c)
2025-02-15 00:12:50 +03:00

359 lines
15 KiB
C#

using Content.Shared.Abilities.Psionics;
using Content.Shared.StatusEffect;
using Content.Shared.Psionics;
using Content.Shared.Psionics.Glimmer;
using Content.Shared.Random;
using Content.Shared.Weapons.Melee.Events;
using Content.Shared.Damage.Events;
using Content.Shared.CCVar;
using Content.Server.Abilities.Psionics;
using Content.Server.Electrocution;
using Content.Server.NPC.Components;
using Content.Server.NPC.Systems;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Configuration;
using Robust.Shared.Random;
using Content.Shared.Popups;
using Content.Shared.Chat;
using Robust.Server.Player;
using Content.Server.Chat.Managers;
using Robust.Shared.Prototypes;
using Content.Shared.Mobs;
using Content.Shared.Damage;
using Content.Shared.Interaction.Events;
using Timer = Robust.Shared.Timing.Timer;
using Content.Shared.Alert;
using Content.Shared.NPC.Components;
using Content.Shared.NPC.Systems;
using Content.Shared.Rounding;
namespace Content.Server.Psionics;
public sealed class PsionicsSystem : EntitySystem
{
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly PsionicAbilitiesSystem _psionicAbilitiesSystem = default!;
[Dependency] private readonly StatusEffectsSystem _statusEffects = default!;
[Dependency] private readonly ElectrocutionSystem _electrocutionSystem = default!;
[Dependency] private readonly MindSwapPowerSystem _mindSwapPowerSystem = default!;
[Dependency] private readonly GlimmerSystem _glimmerSystem = default!;
[Dependency] private readonly NpcFactionSystem _npcFactonSystem = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedPopupSystem _popups = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IChatManager _chatManager = default!;
[Dependency] private readonly IPrototypeManager _protoMan = default!;
[Dependency] private readonly PsionicFamiliarSystem _psionicFamiliar = default!;
[Dependency] private readonly NPCRetaliationSystem _retaliationSystem = default!;
[Dependency] private readonly AlertsSystem _alerts = default!;
private const string BaselineAmplification = "Baseline Amplification";
private const string BaselineDampening = "Baseline Dampening";
// Yes these are a mirror of what's normally default datafields on the PsionicPowerPrototype.
// We haven't generated a prototype yet, and I'm not going to duplicate them on the PsionicComponent.
private const string PsionicRollFailedMessage = "psionic-roll-failed";
private const string PsionicRollFailedColor = "#8A00C2";
private const int PsionicRollFailedFontSize = 12;
private const ChatChannel PsionicRollFailedChatChannel = ChatChannel.Emotes;
/// <summary>
/// Unfortunately, since spawning as a normal role and anything else is so different,
/// this is the only way to unify them, for now at least.
/// </summary>
Queue<(PsionicComponent component, EntityUid uid)> _rollers = new();
public override void Update(float frameTime)
{
base.Update(frameTime);
if (!_cfg.GetCVar(CCVars.PsionicRollsEnabled))
return;
foreach (var roller in _rollers)
RollPsionics(roller.uid, roller.component, true);
_rollers.Clear();
}
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<PsionicComponent, MapInitEvent>(OnStartup);
SubscribeLocalEvent<AntiPsionicWeaponComponent, MeleeHitEvent>(OnMeleeHit);
SubscribeLocalEvent<AntiPsionicWeaponComponent, TakeStaminaDamageEvent>(OnStamHit);
SubscribeLocalEvent<PsionicComponent, MobStateChangedEvent>(OnMobstateChanged);
SubscribeLocalEvent<PsionicComponent, DamageChangedEvent>(OnDamageChanged);
SubscribeLocalEvent<PsionicComponent, AttackAttemptEvent>(OnAttackAttempt);
SubscribeLocalEvent<PsionicComponent, ComponentStartup>(OnInit);
SubscribeLocalEvent<PsionicComponent, ComponentRemove>(OnRemove);
}
private void OnStartup(EntityUid uid, PsionicComponent component, MapInitEvent args)
{
if (!component.Removable
|| !component.CanReroll)
return;
Timer.Spawn(TimeSpan.FromSeconds(30), () => DeferRollers(uid));
}
/// <summary>
/// We wait a short time before starting up the rolled powers, so that other systems have a chance to modify the list first.
/// This is primarily for the sake of TraitSystem and AddJobSpecial.
/// </summary>
private void DeferRollers(EntityUid uid)
{
if (!Exists(uid)
|| !TryComp(uid, out PsionicComponent? component))
return;
CheckPowerCost(uid, component);
GenerateAvailablePowers(component);
_rollers.Enqueue((component, uid));
}
/// <summary>
/// On MapInit, PsionicComponent isn't going to contain any powers.
/// So before we send a Latent Psychic into the roundstart roll queue, we need to calculate their power cost in advance.
/// </summary>
private void CheckPowerCost(EntityUid uid, PsionicComponent component)
{
if (!TryComp<InnatePsionicPowersComponent>(uid, out var innate))
return;
var powerCount = 0;
foreach (var powerId in innate.PowersToAdd)
if (_protoMan.TryIndex(powerId, out var power))
powerCount += power.PowerSlotCost;
component.NextPowerCost = 100 * MathF.Pow(2, powerCount);
}
/// <summary>
/// The power pool is itself a DataField, and things like Traits/Antags are allowed to modify or replace the pool.
/// </summary>
private void GenerateAvailablePowers(PsionicComponent component)
{
if (!_protoMan.TryIndex<WeightedRandomPrototype>(component.PowerPool.Id, out var pool))
return;
foreach (var id in pool.Weights)
{
if (!_protoMan.TryIndex<PsionicPowerPrototype>(id.Key, out var power)
|| component.ActivePowers.Contains(power))
continue;
component.AvailablePowers.Add(id.Key, id.Value);
}
}
private void OnMeleeHit(EntityUid uid, AntiPsionicWeaponComponent component, MeleeHitEvent args)
{
foreach (var entity in args.HitEntities)
CheckAntiPsionic(entity, component, args);
}
private void CheckAntiPsionic(EntityUid entity, AntiPsionicWeaponComponent component, MeleeHitEvent args)
{
if (HasComp<PsionicComponent>(entity))
{
_audio.PlayPvs("/Audio/Effects/lightburn.ogg", entity);
args.ModifiersList.Add(component.Modifiers);
if (!_random.Prob(component.DisableChance))
return;
_statusEffects.TryAddStatusEffect(entity, component.DisableStatus, TimeSpan.FromSeconds(component.DisableDuration), true, component.DisableStatus);
}
if (TryComp<MindSwappedComponent>(entity, out var swapped))
_mindSwapPowerSystem.Swap(entity, swapped.OriginalEntity, true);
if (!component.Punish
|| HasComp<PsionicComponent>(entity)
|| !_random.Prob(component.PunishChances))
return;
_electrocutionSystem.TryDoElectrocution(args.User, null, component.PunishSelfDamage, TimeSpan.FromSeconds(component.PunishStunDuration), false);
}
private void OnInit(EntityUid uid, PsionicComponent component, ComponentStartup args)
{
component.AmplificationSources.Add(BaselineAmplification, _random.NextFloat(component.BaselineAmplification.Item1, component.BaselineAmplification.Item2));
component.DampeningSources.Add(BaselineDampening, _random.NextFloat(component.BaselineDampening.Item1, component.BaselineDampening.Item2));
if (!component.Removable
|| !TryComp<NpcFactionMemberComponent>(uid, out var factions)
|| _npcFactonSystem.ContainsFaction(uid, "GlimmerMonster", factions))
return;
_npcFactonSystem.AddFaction(uid, "PsionicInterloper");
}
private void OnRemove(EntityUid uid, PsionicComponent component, ComponentRemove args)
{
if (!HasComp<NpcFactionMemberComponent>(uid))
return;
_npcFactonSystem.RemoveFaction(uid, "PsionicInterloper");
}
private void OnStamHit(EntityUid uid, AntiPsionicWeaponComponent component, TakeStaminaDamageEvent args)
{
if (!HasComp<PsionicComponent>(args.Target))
return;
args.FlatModifier += component.PsychicStaminaDamage;
}
/// <summary>
/// Now we handle Potentia calculations, the more powers you have, the harder it is to obtain psionics, but the content of your roll carries over to the next roll.
/// Your first power costs 100(2^0 is always 1), your second power costs 200, your 3rd power costs 400, and so on. This also considers people with roundstart powers.
/// Such that a Mystagogue(who has 3 powers at roundstart) needs 800 Potentia to gain his 4th power.
/// </summary>
/// <remarks>
/// This exponential cost is mainly done to prevent stations from becoming "Space Hogwarts",
/// which was a common complaint with Psionic Refactor opening up the opportunity for people to have multiple powers.
/// </remarks>
private bool HandlePotentiaCalculations(EntityUid uid, PsionicComponent component, float psionicChance)
{
component.Potentia += _random.NextFloat(0 + psionicChance, 100 + psionicChance);
if (component.Potentia < component.NextPowerCost)
return false;
component.Potentia -= component.NextPowerCost;
_psionicAbilitiesSystem.AddPsionics(uid);
component.NextPowerCost = component.BaselinePowerCost * MathF.Pow(2, component.PowerSlotsTaken);
return true;
}
/// <summary>
/// Provide the player with feedback about their roll failure, so they don't just think nothing happened.
/// TODO: Add an audio cue to this and other areas of psionic player feedback.
/// </summary>
private void HandleRollFeedback(EntityUid uid)
{
if (!_playerManager.TryGetSessionByEntity(uid, out var session)
|| !Loc.TryGetString(PsionicRollFailedMessage, out var rollFailedMessage))
return;
_popups.PopupEntity(rollFailedMessage, uid, uid, PopupType.MediumCaution);
// Popups only last a few seconds, and are easily ignored.
// So we also put a message in chat to make it harder to miss.
var feedbackMessage = $"[font size={PsionicRollFailedFontSize}][color={PsionicRollFailedColor}]{rollFailedMessage}[/color][/font]";
_chatManager.ChatMessageToOne(
PsionicRollFailedChatChannel,
feedbackMessage,
feedbackMessage,
EntityUid.Invalid,
false,
session.Channel);
}
/// <summary>
/// This function attempts to generate a psionic power by incrementing a Psion's Potentia stat by a random amount, then checking if it beats a certain threshold.
/// Please consider going through RerollPsionics or PsionicAbilitiesSystem.InitializePsionicPower instead of this function, particularly if you don't have a good reason to call this directly.
/// </summary>
public void RollPsionics(EntityUid uid, PsionicComponent component, bool applyGlimmer = true, float rollEventMultiplier = 1f)
{
if (!_cfg.GetCVar(CCVars.PsionicRollsEnabled)
|| !component.Removable)
return;
// Calculate the initial odds based on the innate potential
var baselineChance = component.Chance
* component.PowerRollMultiplier
+ component.PowerRollFlatBonus
+ _random.NextFloat(0, 100);
// Increase the initial odds based on Glimmer.
baselineChance += applyGlimmer
? _glimmerSystem.GetGlimmerEquilibriumRatio() * 25
: 0;
// Certain sources of power rolls provide their own multiplier.
baselineChance *= rollEventMultiplier;
// Ask if the Roller has any other effects to contribute, such as Traits.
var ev = new OnRollPsionicsEvent(uid, baselineChance);
RaiseLocalEvent(uid, ref ev);
if (HandlePotentiaCalculations(uid, component, ev.BaselineChance))
return;
HandleRollFeedback(uid);
}
/// <summary>
/// Each person has a single free reroll for their Psionics, which certain conditions can restore.
/// This function attempts to "Spend" a reroll, if one is available.
/// </summary>
public void RerollPsionics(EntityUid uid, PsionicComponent? psionic = null, float bonusMuliplier = 1f)
{
if (!Resolve(uid, ref psionic, false)
|| !psionic.Removable
|| !psionic.CanReroll)
return;
RollPsionics(uid, psionic, true, bonusMuliplier);
psionic.CanReroll = false;
}
private void OnMobstateChanged(EntityUid uid, PsionicComponent component, MobStateChangedEvent args)
{
if (component.Familiars.Count <= 0
|| !args.IsDead())
return;
foreach (var familiar in component.Familiars)
{
if (!TryComp<PsionicFamiliarComponent>(familiar, out var familiarComponent)
|| !familiarComponent.DespawnOnMasterDeath)
continue;
_psionicFamiliar.DespawnFamiliar(familiar, familiarComponent);
}
}
/// <summary>
/// When a caster with active summons is attacked, aggro their familiars to the attacker.
/// </summary>
private void OnDamageChanged(EntityUid uid, PsionicComponent component, DamageChangedEvent args)
{
if (component.Familiars.Count <= 0
|| !args.DamageIncreased
|| args.Origin is not { } origin
|| origin == uid)
return;
SetFamiliarTarget(origin, component);
}
/// <summary>
/// When a caster with active summons attempts to attack something, aggro their familiars to the target.
/// </summary>
private void OnAttackAttempt(EntityUid uid, PsionicComponent component, AttackAttemptEvent args)
{
if (component.Familiars.Count <= 0
|| args.Target == uid
|| args.Target is not { } target
|| component.Familiars.Contains(target))
return;
SetFamiliarTarget(target, component);
}
private void SetFamiliarTarget(EntityUid target, PsionicComponent component)
{
foreach (var familiar in component.Familiars)
{
if (!TryComp<NPCRetaliationComponent>(familiar, out var retaliationComponent))
continue;
_retaliationSystem.TryRetaliate((familiar, retaliationComponent), target);
}
}
}