Files
wwdpublic/Content.Shared/Actions/SharedActionsSystem.cs
Skubman a46b4f2b5a New Species: Plasmaman (#1291)
# Description

Adds the Plasmamen as a playable species. Plasmamen are a skeletal
species who depend on Plasma to live, and oxygen is highly fatal to
them. Being exposed to oxygen will set them on fire, unless they wear
their envirosuits.

## Species Guidebook

<img width=500px
src="https://github.com/user-attachments/assets/a1ef91ef-87b2-4ae0-8b5c-922a0c34777f">
<img width=500px
src="https://github.com/user-attachments/assets/110f0fa0-7dc4-410b-a2c0-a517f0311484">

**SPECIAL:**

- Plasmamen speak the language Calcic, a language they share with
Skeletons.

## Shitmed Integration

Plasmamen are the first ever species designed with Shitmed in mind, with
one of their core mechanics (self-ignition) powered entirely by Shitmed.

Whether or not a Plasmaman ignites from oxygen exposure depends only on
their body parts. A Plasmaman with only their head exposed will not burn
as much as an entirely naked Plasmaman. You can **transfer** Plasmaman
body parts to non-Plasmamen through **surgery** so that they also ignite
from oxygen exposure. Meanwhile, a Plasmaman with a non-Plasmaman head
can expose their head without self-igniting.

https://github.com/user-attachments/assets/0aa33070-04be-4ded-b668-3afb9f4ddd7c

## Technical Details

This also cherry-picks
https://github.com/space-wizards/space-station-14/pull/28595 as a
quality-of-life feature to ensure Plasmamen keep their internals on upon
toggling their helmet with a breath mask on.

## TODO

### RELEASE-NECESSARY

<details>

- [x] Port more envirosuits / enviro helms (job-specific) and their
sprites
- [x] Remove breath masks from default plasmaman loadouts because the
envirohelms already allow them to breathe internals
- [x] Change default plasma tank to higher-capacity version
- [x] Prevent plasmamen from buying jumpsuits and helmets other than
envirosuits
- ~~[ ] **Client UI update for loadout groups min/max items and default
items**~~
- [x] Plasmaman-specific mask sprites from TG
- [x] Disable too cold alert for plasmamen
- [x] Create/port sprites for these jobs
  - [x] Courier
  - [x] Forensic Mantis
  - [x] Corpsman (Resprite security envirosuit)
  - [x] Prison Guard (Resprite security envirosuit)
- [x] Magistrate (No Paradise envirosuit so use new colorable
envirosuit)
  - [x] Blueshield (Port from Paradise and tg-ify?)
- [x] NanoTrasen Representative (No Paradise envirosuit so use new
colorable envirosuit)
- [x] Martial Artist (use new colorable envirosuit and make pure white)
  - [x] Musician (use new colorable envirosuit)
  - [x] Reporter (use new colorable envirosuit)
  - [x] Zookeeper (use new colorable envirosuit)
  - [x] Service Worker (use new colorable envirosuit)
  - [x] Gladiator
  - [x] Technical Assistant
  - [x] Medical Intern
  - [x] Acolyte / Research Assistant
  - [x] Security Cadet
  - [x] Assistant
- You know what. These intern jobs are fine. They can use their normal
equivalent's envirosuits.
  - [x] Logistics Officer (use new colorable envirosuit)
- [x] Adjust sprites to be closer to actual job
  - [x] Captain (Shift color to be closer to ss14 captain)
  - [x] ~~CMO (Remove yellow accents)~~
  - [x] Port HoP envirogloves sprite
- [x] unique sprite for self-extinguish verb
- [x] Refactor conditional gear stuff to live only in
StartingGearPrototype with `SubGear`
`List<ProtoId<StartingGearPrototype>>` field and `List<Requirement>`
field for sub-gear requirements
- [x] Add starting gear for paradox anomaly, and antags and ghost roles
  - [x] Paradox
  - [x] Nukies
  - [x] Disaster victims
  - [x] Listening post operative
- [x] Make all envirosuit helmets have a glowing (unshaded) visor
- [x] Envirosuit extinguish visuals
- [x] JobPrototype: AfterLoadoutSpecial
- [x] Set prisoner envirohelm battery to potato, command/sec/dignitary
to high-powered
  - [x] Set base envirosuit extinguishes to 4, sec 6 and command 8
- [x] Improve plasmaman organ extraction experience
  - [x] Body parts now give 1 plasma sheet each, while Torso gives 3
  - [x] Organs can be juiced to get plasma
- [x] Make envirohelm flashlights battery-powered
- [x] Plasmamen visuals
- [x] Grayscale sprites for color customization, and set default
skintone color to Plasmaman classic skintone
  - [x] Plasmaman eye organ sprite
- [x] Add basic loadouts
- [x] Add way to refill envirosuit charges (refill at medical protolathe
after some research)

</details>

### Low Importance

<details>

- [x] Envirogloves
- [ ] (SCOPE CREEP) Plasma tanks sprite (only normal emergency/extended,
rather low priority)
- [ ] (SCOPE CREEP) Modify envirosuit helmet sprites to have a
transparent visor
- [ ] Glowing eyes/mouth marking
- [x] More cargo content with plasma tanks / envirosuits
  - [x] Plasmaman survival kit like slime
  - [x] Additional plasma tanks
  - [ ] (SCOPE CREEP) Plasmaman EVA suits
- [x] ~~Add envirosuits to clothesmate~~
- [x] Add more plasma tanks to random lockers and job lockers
- [x] Turn envirosuit auto-extinguish into extinguish action
- [x] move self-extinguish verb stuff to shared for prediction of the
verb
- [x] move self-extinguisher stuff away from extinguisher namespace
- [x] unique sprite for self-extinguish icon
  - [x] ~~IDEA: purple glowy fire extinguisher ~~
- [x] on self-extinguish, check for pressure immunity OR ignite from gas
immunity properly
- [x] See envirosuit extinguish charges in examine
- [x] Milk heals on ingestion
- [x] Plasma heals on ingestion
- [x] Self-ignition doesn't occur on a stasis bed
- [x] ~~Self-ignition doesn't occur when dead~~
- [x] Guidebook entry
- [x] Make self-ignition ignore damage resistances from fire suits
- [x] ~~Make self-ignition ignore damage resistances from armor~~
- [x] ~~Unable to rot?~~
- [x] Make the envirosuit helmet toggle on for the character dummy in
lobby
- [ ] (SCOPE CREEP) One additional Plasmaman trait
- [x] ~~Showers extinguish water as well as water tiles~~
- Unnecessary as stasis beds now prevent ignition, allowing surgery on a
plasmaman on stasis beds.
- [x] Unique punch animations for Plasmafire Punch/Toxoplasmic Punch
traits
- [x] Actually remove toxoplasmic it's just slop filler tbh
- [ ] Talk sounds
  - [ ] Normal
  - [ ] Question
  - [ ] Yell
- [x] Positive moodlet for drinking milk / more positive moodlet for
drinking plasma
- [x] Increase moodlet bonus and also minimum reagent required for the
plasma/milk moodlets
- [x] Increase fire rate base stacks on ignite cause putting out your
helmet for a few secs isn't that dangerous due to the fire stacks
immediately decaying
- [x] I think halving firestack fade from -0.1 to -0.05 might work to do
the same thing too
- [ ] (SCOPE CREEP) Get bone laugh sounds from monke
'monkestation/sound/voice/laugh/skeleton/skeleton_laugh.ogg'
- [ ] (SCOPE CREEP) When EVA plasmaman suit is added, 25% caustic resist
- [x]  Envirosuit helmet
  - [x] Equivalent of 100% bio / 100% fire / 75% acid resist
- [x] Envirosuit
  - [x] Equivalent of 100% bio / 100% fire / 75% acid resist
- [x] Envirogloves
  - [x] Equivalent of 100% bio / 95% fire / 95% acid resist
- [x] Put breath mask back on
- [x] Refactor: put body parts covered data into component instead of
being hardcoded

</details>

## Media

**Custom Plasmaman Outfits**

All of these use the same **absolutely massive** [envirosuit
RSI](0c3af432df/Resources/Textures/Clothing/Uniforms/Envirosuits/color.rsi)
and [envirohelm
RSI](0c3af432df/Resources/Textures/Clothing/Head/Envirohelms/color.rsi)
to quickly create the envirosuits that didn't exist in SS13 where the
envirosuit sprites were ported.

From Left to Right: Magistrate, Prison Guard, Boxer, Reporter, Logistics
Officer

<img width=200px
src="https://github.com/user-attachments/assets/bf990841-7d9e-4f4e-abae-8f29a3980ca1">
<img width=200px
src="https://github.com/user-attachments/assets/07ca7af7-4f43-4504-9eac-4ca9188ae98e">
<img width=200px
src="https://github.com/user-attachments/assets/0d20332c-826f-4fec-8396-74e84c23b074">
<img width=200px
src="https://github.com/user-attachments/assets/1634364e-7cb3-457b-b638-e1b562b7c0c5">
<img width=200px
src="https://github.com/user-attachments/assets/c2881764-f2fa-4e40-9fbf-35d1b717c432">

**Plasmaman Melee Attack**

https://github.com/user-attachments/assets/6e694f2c-3e03-40bf-ae27-fc58a3e4cb6c

**Chat bubble**

<img width=240px
src="https://github.com/user-attachments/assets/e3c17e6d-5050-410f-a42c-339f0bfa30a1">

**Plasmaman Body**

<img width=140px
src="https://github.com/user-attachments/assets/7ed90a47-9c33-487d-bd44-c50cec9f16dd">

With different colors:

<img width=140px
src="https://github.com/user-attachments/assets/0a28068e-7392-4062-950b-f60d2602da84">
<img width=140px
src="https://github.com/user-attachments/assets/9b652311-0305-4ec0-be60-e404697617a2">

**Skeleton Language**

![image](https://github.com/user-attachments/assets/89b2b047-3bfa-4106-926e-6c412ed6e57c)

**(Bonus) Skeleton chat bubble**

<img width=240px
src="https://github.com/user-attachments/assets/a2e2be5c-f3ae-49d9-b655-8688de45b512">

**Self-Extinguish**

https://github.com/user-attachments/assets/6c68e2ef-8010-4f00-8c24-dce8a8065be8

The self-extinguish is also accessible as a verb, which also means that
others can activate your self-extinguish if they open the strip menu.

<img width=200px
src="https://github.com/user-attachments/assets/291ab86d-2250-46ec-ae0c-80084ab04407">

The self-extinguish action has different icons depending on the status
of the self extinguish.

Left to right: Ready, On Cooldown, Out Of Charges

<img
src="https://github.com/user-attachments/assets/0340de8a-9440-43b1-8bff-1c8f962faa0c">

<img
src="https://github.com/user-attachments/assets/11f73558-6dc1-444d-b2ef-2f15f55174ca">

<img
src="https://github.com/user-attachments/assets/030ed737-f178-4c60-ba0c-109659e7d9cb">

**Envirosuit Extinguisher Refill**

<img width=300px
src="https://github.com/user-attachments/assets/9379294b-e3f3-436d-81bc-2584631869ef">
<img width=300px
src="https://github.com/user-attachments/assets/807b9e9e-7b4b-4593-aa1f-d9d24ac6985c">

**Loadouts**

<img width=400px
src="https://github.com/user-attachments/assets/55713b87-29bb-41b3-b7a3-88fbc6e5e797">
<img width=400px
src="https://github.com/user-attachments/assets/ab1757fa-9b70-4a66-b5ae-20fd9cabe935">
<img width=400px
src="https://github.com/user-attachments/assets/aacc4cf7-9ce1-4099-b8c7-108bef1f3bde">
<img width=400px
src="https://github.com/user-attachments/assets/58604dc2-82ef-4d42-b9e2-639548c93f40">

**Plasma Envirosuit Crate**
<img width=400px
src="https://github.com/user-attachments/assets/fa362387-9c10-47c3-b1af-2c11e6b00163">

<img width=400px
src="https://github.com/user-attachments/assets/bf773722-9034-4469-967d-e00dbf8c77a7">

**Internals Crate (Plasma)**
<img width=400px
src="https://github.com/user-attachments/assets/fcd4ff2e-09e9-423a-9b21-96817f6042a4">

<img width=400px
src="https://github.com/user-attachments/assets/bf773722-9034-4469-967d-e00dbf8c77a7">

**Glow In The Dark**

![image](https://github.com/user-attachments/assets/9728eb33-55d5-4f82-92ac-3a7756068577)

## Changelog

🆑 Skubman
- add: The Plasmaman species has arrived! They need to breathe plasma to
live, and a special jumpsuit to prevent oxygen from igniting them. In
exchange, they deal formidable unarmed Heat damage, are never hungry nor
thirsty, and are immune to cold and radiation damage. Read more about
Plasmamen in their Guidebook entry.
- tweak: Internals are no longer toggled off if you take your helmet off
but still have a gas mask on and vice versa.
- tweak: Paradox Anomalies will now spawn with the original person's
Loadout items.
- fix: Fixed prisoners not being able to have custom Loadout names and
descriptions, and heirlooms if they didn't have a backpack when joining.

---------

Signed-off-by: Skubman <ba.fallaria@gmail.com>
Signed-off-by: VMSolidus <evilexecutive@gmail.com>
Co-authored-by: Plykiya <58439124+Plykiya@users.noreply.github.com>
Co-authored-by: VMSolidus <evilexecutive@gmail.com>

(cherry picked from commit e68e0c3f4b9cf263e07efc888b32a091df62fb51)
2025-01-29 20:19:20 +03:00

1098 lines
39 KiB
C#

using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Shared.ActionBlocker;
using Content.Shared.Actions.Events;
using Content.Shared.Administration.Logs;
using Content.Shared.Database;
using Content.Shared.Hands;
using Content.Shared.Interaction;
using Content.Shared.Inventory.Events;
using Content.Shared.Mind;
using Content.Shared.Rejuvenate;
using Content.Shared.Whitelist;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Containers;
using Robust.Shared.GameStates;
using Robust.Shared.Map;
using Robust.Shared.Network;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Shared.Actions;
public abstract class SharedActionsSystem : EntitySystem
{
[Dependency] protected readonly IGameTiming GameTiming = default!;
[Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
[Dependency] private readonly INetManager _net = default!;
[Dependency] private readonly SharedInteractionSystem _interactionSystem = default!;
[Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!;
[Dependency] private readonly RotateToFaceSystem _rotateToFaceSystem = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedTransformSystem _transformSystem = default!;
[Dependency] private readonly ActionContainerSystem _actionContainer = default!;
[Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<InstantActionComponent, MapInitEvent>(OnActionMapInit);
SubscribeLocalEvent<EntityTargetActionComponent, MapInitEvent>(OnActionMapInit);
SubscribeLocalEvent<WorldTargetActionComponent, MapInitEvent>(OnActionMapInit);
SubscribeLocalEvent<EntityWorldTargetActionComponent, MapInitEvent>(OnActionMapInit);
SubscribeLocalEvent<InstantActionComponent, ComponentShutdown>(OnActionShutdown);
SubscribeLocalEvent<EntityTargetActionComponent, ComponentShutdown>(OnActionShutdown);
SubscribeLocalEvent<WorldTargetActionComponent, ComponentShutdown>(OnActionShutdown);
SubscribeLocalEvent<EntityWorldTargetActionComponent, ComponentShutdown>(OnActionShutdown);
SubscribeLocalEvent<ActionsComponent, ActionComponentChangeEvent>(OnActionCompChange);
SubscribeLocalEvent<ActionsComponent, RelayedActionComponentChangeEvent>(OnRelayActionCompChange);
SubscribeLocalEvent<ActionsComponent, DidEquipEvent>(OnDidEquip);
SubscribeLocalEvent<ActionsComponent, DidEquipHandEvent>(OnHandEquipped);
SubscribeLocalEvent<ActionsComponent, DidUnequipEvent>(OnDidUnequip);
SubscribeLocalEvent<ActionsComponent, DidUnequipHandEvent>(OnHandUnequipped);
SubscribeLocalEvent<ActionsComponent, RejuvenateEvent>(OnRejuventate);
SubscribeLocalEvent<ActionsComponent, ComponentShutdown>(OnShutdown);
SubscribeLocalEvent<ActionsComponent, ComponentGetState>(OnActionsGetState);
SubscribeLocalEvent<InstantActionComponent, ComponentGetState>(OnInstantGetState);
SubscribeLocalEvent<EntityTargetActionComponent, ComponentGetState>(OnEntityTargetGetState);
SubscribeLocalEvent<WorldTargetActionComponent, ComponentGetState>(OnWorldTargetGetState);
SubscribeLocalEvent<EntityWorldTargetActionComponent, ComponentGetState>(OnEntityWorldTargetGetState);
SubscribeLocalEvent<InstantActionComponent, GetActionDataEvent>(OnGetActionData);
SubscribeLocalEvent<EntityTargetActionComponent, GetActionDataEvent>(OnGetActionData);
SubscribeLocalEvent<WorldTargetActionComponent, GetActionDataEvent>(OnGetActionData);
SubscribeLocalEvent<EntityWorldTargetActionComponent, GetActionDataEvent>(OnGetActionData);
SubscribeAllEvent<RequestPerformActionEvent>(OnActionRequest);
}
private void OnActionMapInit(EntityUid uid, BaseActionComponent component, MapInitEvent args)
{
if (component.Charges == null)
return;
component.MaxCharges ??= component.Charges.Value;
Dirty(uid, component);
}
private void OnActionShutdown(EntityUid uid, BaseActionComponent component, ComponentShutdown args)
{
if (component.AttachedEntity != null && !TerminatingOrDeleted(component.AttachedEntity.Value))
RemoveAction(component.AttachedEntity.Value, uid, action: component);
}
private void OnShutdown(EntityUid uid, ActionsComponent component, ComponentShutdown args)
{
foreach (var act in component.Actions)
{
RemoveAction(uid, act, component);
}
}
private void OnInstantGetState(EntityUid uid, InstantActionComponent component, ref ComponentGetState args)
{
args.State = new InstantActionComponentState(component, EntityManager);
}
private void OnEntityTargetGetState(EntityUid uid, EntityTargetActionComponent component, ref ComponentGetState args)
{
args.State = new EntityTargetActionComponentState(component, EntityManager);
}
private void OnWorldTargetGetState(EntityUid uid, WorldTargetActionComponent component, ref ComponentGetState args)
{
args.State = new WorldTargetActionComponentState(component, EntityManager);
}
private void OnEntityWorldTargetGetState(EntityUid uid, EntityWorldTargetActionComponent component, ref ComponentGetState args)
{
args.State = new EntityWorldTargetActionComponentState(component, EntityManager);
}
private void OnGetActionData<T>(EntityUid uid, T component, ref GetActionDataEvent args) where T : BaseActionComponent
{
args.Action = component;
}
public bool TryGetActionData(
[NotNullWhen(true)] EntityUid? uid,
[NotNullWhen(true)] out BaseActionComponent? result,
bool logError = true)
{
result = null;
if (!Exists(uid))
return false;
var ev = new GetActionDataEvent();
RaiseLocalEvent(uid.Value, ref ev);
result = ev.Action;
if (result != null)
return true;
if (logError)
Log.Error($"Failed to get action from action entity: {ToPrettyString(uid.Value)}. Trace: {Environment.StackTrace}");
return false;
}
public bool ResolveActionData(
[NotNullWhen(true)] EntityUid? uid,
[NotNullWhen(true)] ref BaseActionComponent? result,
bool logError = true)
{
if (result != null)
{
DebugTools.AssertOwner(uid, result);
return true;
}
return TryGetActionData(uid, out result, logError);
}
public void SetCooldown(EntityUid? actionId, TimeSpan start, TimeSpan end)
{
if (!TryGetActionData(actionId, out var action))
return;
action.Cooldown = (start, end);
Dirty(actionId.Value, action);
}
public void SetCooldown(EntityUid? actionId, TimeSpan cooldown)
{
var start = GameTiming.CurTime;
SetCooldown(actionId, start, start + cooldown);
}
public void ClearCooldown(EntityUid? actionId)
{
if (!TryGetActionData(actionId, out var action))
return;
if (action.Cooldown is not { } cooldown)
return;
action.Cooldown = (cooldown.Start, GameTiming.CurTime);
Dirty(actionId.Value, action);
}
/// <summary>
/// Sets the cooldown for this action only if it is bigger than the one it already has.
/// </summary>
public void SetIfBiggerCooldown(EntityUid? actionId, TimeSpan? cooldown)
{
if (cooldown == null ||
cooldown.Value <= TimeSpan.Zero ||
!TryGetActionData(actionId, out var action))
{
return;
}
var start = GameTiming.CurTime;
var end = start + cooldown;
if (action.Cooldown?.End > end)
return;
action.Cooldown = (start, end.Value);
Dirty(actionId.Value, action);
}
public void StartUseDelay(EntityUid? actionId)
{
if (actionId == null)
return;
if (!TryGetActionData(actionId, out var action) || action.UseDelay == null)
return;
action.Cooldown = (GameTiming.CurTime, GameTiming.CurTime + action.UseDelay.Value);
Dirty(actionId.Value, action);
}
public void SetUseDelay(EntityUid? actionId, TimeSpan? delay)
{
if (!TryGetActionData(actionId, out var action) || action.UseDelay == delay)
return;
action.UseDelay = delay;
UpdateAction(actionId, action);
Dirty(actionId.Value, action);
}
public void ReduceUseDelay(EntityUid? actionId, TimeSpan? lowerDelay)
{
if (!TryGetActionData(actionId, out var action))
return;
if (action.UseDelay != null && lowerDelay != null)
action.UseDelay = action.UseDelay - lowerDelay;
if (action.UseDelay < TimeSpan.Zero)
action.UseDelay = null;
UpdateAction(actionId, action);
Dirty(actionId.Value, action);
}
private void OnRejuventate(EntityUid uid, ActionsComponent component, RejuvenateEvent args)
{
foreach (var act in component.Actions)
{
ClearCooldown(act);
}
}
#region ComponentStateManagement
protected virtual void UpdateAction(EntityUid? actionId, BaseActionComponent? action = null)
{
// See client-side code.
}
public void SetToggled(EntityUid? actionId, bool toggled)
{
if (!TryGetActionData(actionId, out var action) ||
action.Toggled == toggled)
{
return;
}
action.Toggled = toggled;
UpdateAction(actionId, action);
Dirty(actionId.Value, action);
}
public void SetEnabled(EntityUid? actionId, bool enabled)
{
if (!TryGetActionData(actionId, out var action) ||
action.Enabled == enabled)
{
return;
}
action.Enabled = enabled;
UpdateAction(actionId, action);
Dirty(actionId.Value, action);
}
public void SetCharges(EntityUid? actionId, int? charges)
{
if (!TryGetActionData(actionId, out var action) ||
action.Charges == charges)
{
return;
}
action.Charges = charges;
UpdateAction(actionId, action);
Dirty(actionId.Value, action);
}
public int? GetCharges(EntityUid? actionId)
{
if (!TryGetActionData(actionId, out var action))
return null;
return action.Charges;
}
public void AddCharges(EntityUid? actionId, int addCharges)
{
if (!TryGetActionData(actionId, out var action) || action.Charges == null || addCharges < 1)
return;
action.Charges += addCharges;
UpdateAction(actionId, action);
Dirty(actionId.Value, action);
}
public void RemoveCharges(EntityUid? actionId, int? removeCharges)
{
if (!TryGetActionData(actionId, out var action) || action.Charges == null)
return;
if (removeCharges == null)
action.Charges = removeCharges;
else
action.Charges -= removeCharges;
if (action.Charges is < 0)
action.Charges = null;
UpdateAction(actionId, action);
Dirty(actionId.Value, action);
}
public void ResetCharges(EntityUid? actionId)
{
if (!TryGetActionData(actionId, out var action))
return;
action.Charges = action.MaxCharges;
UpdateAction(actionId, action);
Dirty(actionId.Value, action);
}
public void SetMaxCharges(EntityUid? actionId, int? maxCharges)
{
if (!TryGetActionData(actionId, out var action) ||
action.MaxCharges == maxCharges)
return;
action.MaxCharges = maxCharges;
UpdateAction(actionId, action);
Dirty(actionId.Value, action);
}
private void OnActionsGetState(EntityUid uid, ActionsComponent component, ref ComponentGetState args)
{
args.State = new ActionsComponentState(GetNetEntitySet(component.Actions));
}
#endregion
#region Execution
/// <summary>
/// When receiving a request to perform an action, this validates whether the action is allowed. If it is, it
/// will raise the relevant <see cref="InstantActionEvent"/>
/// </summary>
private void OnActionRequest(RequestPerformActionEvent ev, EntitySessionEventArgs args)
{
if (args.SenderSession.AttachedEntity is not { } user)
return;
if (!TryComp(user, out ActionsComponent? component))
return;
var actionEnt = GetEntity(ev.Action);
if (!TryComp(actionEnt, out MetaDataComponent? metaData))
return;
var name = Name(actionEnt, metaData);
// Does the user actually have the requested action?
if (!component.Actions.Contains(actionEnt))
{
_adminLogger.Add(LogType.Action,
$"{ToPrettyString(user):user} attempted to perform an action that they do not have: {name}.");
return;
}
if (!TryGetActionData(actionEnt, out var action))
return;
DebugTools.Assert(action.AttachedEntity == user);
if (!action.Enabled)
return;
// check for action use prevention
// TODO: make code below use this event with a dedicated component
var attemptEv = new ActionAttemptEvent(user);
RaiseLocalEvent(actionEnt, ref attemptEv);
if (attemptEv.Cancelled)
return;
var curTime = GameTiming.CurTime;
// TODO: Check for charge recovery timer
if (action.Cooldown.HasValue && action.Cooldown.Value.End > curTime)
return;
// TODO: Replace with individual charge recovery when we have the visuals to aid it
if (action is { Charges: < 1, RenewCharges: true })
ResetCharges(actionEnt);
BaseActionEvent? performEvent = null;
if (action.CheckConsciousness && !_actionBlockerSystem.CanConsciouslyPerformAction(user))
return;
// Validate request by checking action blockers and the like:
switch (action)
{
case EntityTargetActionComponent entityAction:
if (ev.EntityTarget is not { Valid: true } netTarget)
{
Log.Error($"Attempted to perform an entity-targeted action without a target! Action: {name}");
return;
}
var entityTarget = GetEntity(netTarget);
var targetWorldPos = _transformSystem.GetWorldPosition(entityTarget);
_rotateToFaceSystem.TryFaceCoordinates(user, targetWorldPos);
if (!ValidateEntityTarget(user, entityTarget, (actionEnt, entityAction)))
return;
_adminLogger.Add(LogType.Action,
$"{ToPrettyString(user):user} is performing the {name:action} action (provided by {ToPrettyString(action.Container ?? user):provider}) targeted at {ToPrettyString(entityTarget):target}.");
if (entityAction.Event != null)
{
entityAction.Event.Target = entityTarget;
Dirty(actionEnt, entityAction);
performEvent = entityAction.Event;
}
break;
case WorldTargetActionComponent worldAction:
if (ev.EntityCoordinatesTarget is not { } netCoordinatesTarget)
{
Log.Error($"Attempted to perform a world-targeted action without a target! Action: {name}");
return;
}
var entityCoordinatesTarget = GetCoordinates(netCoordinatesTarget);
_rotateToFaceSystem.TryFaceCoordinates(user, entityCoordinatesTarget.ToMapPos(EntityManager, _transformSystem));
if (!ValidateWorldTarget(user, entityCoordinatesTarget, (actionEnt, worldAction)))
return;
_adminLogger.Add(LogType.Action,
$"{ToPrettyString(user):user} is performing the {name:action} action (provided by {ToPrettyString(action.Container ?? user):provider}) targeted at {entityCoordinatesTarget:target}.");
if (worldAction.Event != null)
{
worldAction.Event.Target = entityCoordinatesTarget;
Dirty(actionEnt, worldAction);
performEvent = worldAction.Event;
}
break;
case EntityWorldTargetActionComponent entityWorldAction:
{
var actionEntity = GetEntity(ev.EntityTarget);
var actionCoords = GetCoordinates(ev.EntityCoordinatesTarget);
if (actionEntity is null && actionCoords is null)
{
Log.Error($"Attempted to perform an entity-world-targeted action without an entity or world coordinates! Action: {name}");
return;
}
var entWorldAction = new Entity<EntityWorldTargetActionComponent>(actionEnt, entityWorldAction);
if (!ValidateEntityWorldTarget(user, actionEntity, actionCoords, entWorldAction))
return;
_adminLogger.Add(LogType.Action,
$"{ToPrettyString(user):user} is performing the {name:action} action (provided by {ToPrettyString(action.Container ?? user):provider}) targeted at {ToPrettyString(actionEntity):target} {actionCoords:target}.");
if (entityWorldAction.Event != null)
{
entityWorldAction.Event.Entity = actionEntity;
entityWorldAction.Event.Coords = actionCoords;
Dirty(actionEnt, entityWorldAction);
performEvent = entityWorldAction.Event;
}
break;
}
case InstantActionComponent instantAction:
if (action.CheckCanInteract && !_actionBlockerSystem.CanInteract(user, null))
return;
_adminLogger.Add(LogType.Action,
$"{ToPrettyString(user):user} is performing the {name:action} action provided by {ToPrettyString(action.Container ?? user):provider}.");
performEvent = instantAction.Event;
break;
}
// All checks passed. Perform the action!
PerformAction(user, component, actionEnt, action, performEvent, curTime);
}
public bool ValidateEntityTarget(EntityUid user, EntityUid target, Entity<EntityTargetActionComponent> actionEnt)
{
var comp = actionEnt.Comp;
if (!ValidateEntityTargetBase(user,
target,
comp.Whitelist,
comp.CheckCanInteract,
comp.CanTargetSelf,
comp.CheckCanAccess,
comp.Range))
return false;
var ev = new ValidateActionEntityTargetEvent(user, target);
RaiseLocalEvent(actionEnt, ref ev);
return !ev.Cancelled;
}
private bool ValidateEntityTargetBase(EntityUid user,
EntityUid? targetEntity,
EntityWhitelist? whitelist,
bool checkCanInteract,
bool canTargetSelf,
bool checkCanAccess,
float range)
{
if (targetEntity is not { } target || !target.IsValid() || Deleted(target))
return false;
if (_whitelistSystem.IsWhitelistFail(whitelist, target))
return false;
if (checkCanInteract && !_actionBlockerSystem.CanInteract(user, target))
return false;
if (user == target)
return canTargetSelf;
if (!checkCanAccess)
{
// even if we don't check for obstructions, we may still need to check the range.
var xform = Transform(user);
var targetXform = Transform(target);
if (xform.MapID != targetXform.MapID)
return false;
if (range <= 0)
return true;
var distance = (_transformSystem.GetWorldPosition(xform) - _transformSystem.GetWorldPosition(targetXform)).Length();
return distance <= range;
}
return _interactionSystem.InRangeAndAccessible(user, target, range: range);
}
public bool ValidateWorldTarget(EntityUid user, EntityCoordinates coords, Entity<WorldTargetActionComponent> action)
{
var comp = action.Comp;
if (!ValidateWorldTargetBase(user, coords, comp.CheckCanInteract, comp.CheckCanAccess, comp.Range))
return false;
var ev = new ValidateActionWorldTargetEvent(user, coords);
RaiseLocalEvent(action, ref ev);
return !ev.Cancelled;
}
private bool ValidateWorldTargetBase(EntityUid user,
EntityCoordinates? entityCoordinates,
bool checkCanInteract,
bool checkCanAccess,
float range)
{
if (entityCoordinates is not { } coords)
return false;
if (checkCanInteract && !_actionBlockerSystem.CanInteract(user, null))
return false;
if (!checkCanAccess)
{
// even if we don't check for obstructions, we may still need to check the range.
var xform = Transform(user);
if (xform.MapID != coords.GetMapId(EntityManager))
return false;
if (range <= 0)
return true;
return coords.InRange(EntityManager, _transformSystem, Transform(user).Coordinates, range);
}
return _interactionSystem.InRangeUnobstructed(user, coords, range: range);
}
public bool ValidateEntityWorldTarget(EntityUid user,
EntityUid? entity,
EntityCoordinates? coords,
Entity<EntityWorldTargetActionComponent> action)
{
var comp = action.Comp;
var entityValidated = ValidateEntityTargetBase(user,
entity,
comp.Whitelist,
comp.CheckCanInteract,
comp.CanTargetSelf,
comp.CheckCanAccess,
comp.Range);
var worldValidated
= ValidateWorldTargetBase(user, coords, comp.CheckCanInteract, comp.CheckCanAccess, comp.Range);
if (!entityValidated && !worldValidated)
return false;
var ev = new ValidateActionEntityWorldTargetEvent(user,
entityValidated ? entity : null,
worldValidated ? coords : null);
RaiseLocalEvent(action, ref ev);
return !ev.Cancelled;
}
public void PerformAction(EntityUid performer, ActionsComponent? component, EntityUid actionId, BaseActionComponent action, BaseActionEvent? actionEvent, TimeSpan curTime, bool predicted = true)
{
var handled = false;
var toggledBefore = action.Toggled;
// Note that attached entity and attached container are allowed to be null here.
if (action.AttachedEntity != null && action.AttachedEntity != performer)
{
Log.Error($"{ToPrettyString(performer)} is attempting to perform an action {ToPrettyString(actionId)} that is attached to another entity {ToPrettyString(action.AttachedEntity.Value)}");
return;
}
if (actionEvent != null)
{
// This here is required because of client-side prediction (RaisePredictiveEvent results in event re-use).
actionEvent.Handled = false;
var target = performer;
actionEvent.Performer = performer;
actionEvent.Action = (actionId, action);
if (!action.RaiseOnUser && action.Container != null && !HasComp<MindComponent>(action.Container))
target = action.Container.Value;
RaiseLocalEvent(target, (object) actionEvent, broadcast: true);
handled = actionEvent.Handled;
}
if (!handled)
return; // no interaction occurred.
// play sound, reduce charges, start cooldown, and mark as dirty (if required).
if (actionEvent?.Toggle == true)
{
action.Toggled = !action.Toggled;
}
_audio.PlayPredicted(action.Sound, performer, predicted ? performer : null);
var dirty = toggledBefore != action.Toggled;
if (action.Charges != null)
{
dirty = true;
action.Charges--;
if (action is { Charges: 0, RenewCharges: false })
{
var disabledEv = new ActionGettingDisabledEvent(performer);
RaiseLocalEvent(actionId, ref disabledEv);
action.Enabled = false;
}
}
action.Cooldown = null;
if (action is { UseDelay: not null, Charges: null or < 1 })
{
dirty = true;
action.Cooldown = (curTime, curTime + action.UseDelay.Value);
}
if (dirty)
{
Dirty(actionId, action);
UpdateAction(actionId, action);
}
var ev = new ActionPerformedEvent(performer);
RaiseLocalEvent(actionId, ref ev);
}
#endregion
#region AddRemoveActions
public EntityUid? AddAction(EntityUid performer,
string? actionPrototypeId,
EntityUid container = default,
ActionsComponent? component = null)
{
EntityUid? actionId = null;
AddAction(performer, ref actionId, out _, actionPrototypeId, container, component);
return actionId;
}
/// <summary>
/// Adds an action to an action holder. If the given entity does not exist, it will attempt to spawn one.
/// If the holder has no actions component, this will give them one.
/// </summary>
/// <param name="performer">Entity to receive the actions</param>
/// <param name="actionId">Action entity to add</param>
/// <param name="component">The <see cref="performer"/>'s action component of </param>
/// <param name="actionPrototypeId">The action entity prototype id to use if <see cref="actionId"/> is invalid.</param>
/// <param name="container">The entity that contains/enables this action (e.g., flashlight).</param>
public bool AddAction(EntityUid performer,
[NotNullWhen(true)] ref EntityUid? actionId,
string? actionPrototypeId,
EntityUid container = default,
ActionsComponent? component = null)
{
return AddAction(performer, ref actionId, out _, actionPrototypeId, container, component);
}
/// <inheritdoc cref="AddAction(Robust.Shared.GameObjects.EntityUid,ref System.Nullable{Robust.Shared.GameObjects.EntityUid},string?,Robust.Shared.GameObjects.EntityUid,Content.Shared.Actions.ActionsComponent?)"/>
public bool AddAction(EntityUid performer,
[NotNullWhen(true)] ref EntityUid? actionId,
[NotNullWhen(true)] out BaseActionComponent? action,
string? actionPrototypeId,
EntityUid container = default,
ActionsComponent? component = null)
{
if (!container.IsValid())
container = performer;
if (!_actionContainer.EnsureAction(container, ref actionId, out action, actionPrototypeId))
return false;
return AddActionDirect(performer, actionId.Value, component, action);
}
/// <summary>
/// Adds a pre-existing action.
/// </summary>
public bool AddAction(EntityUid performer,
EntityUid actionId,
EntityUid container,
ActionsComponent? comp = null,
BaseActionComponent? action = null,
ActionsContainerComponent? containerComp = null
)
{
if (!ResolveActionData(actionId, ref action))
return false;
if (action.Container != container
|| !Resolve(container, ref containerComp)
|| !containerComp.Container.Contains(actionId))
{
Log.Error($"Attempted to add an action with an invalid container: {ToPrettyString(actionId)}");
return false;
}
return AddActionDirect(performer, actionId, comp, action);
}
/// <summary>
/// Adds a pre-existing action. This also bypasses the requirement that the given action must be stored in a
/// valid action container.
/// </summary>
public bool AddActionDirect(EntityUid performer,
EntityUid actionId,
ActionsComponent? comp = null,
BaseActionComponent? action = null)
{
if (!ResolveActionData(actionId, ref action))
return false;
DebugTools.Assert(_net.IsClient || action.Container == null ||
(TryComp(action.Container, out ActionsContainerComponent? containerComp)
&& containerComp.Container.Contains(actionId)));
if (action.AttachedEntity != null)
RemoveAction(action.AttachedEntity.Value, actionId, action: action);
if (action.StartDelay && action.UseDelay != null)
SetCooldown(actionId, action.UseDelay.Value);
DebugTools.AssertOwner(performer, comp);
comp ??= EnsureComp<ActionsComponent>(performer);
action.AttachedEntity = performer;
comp.Actions.Add(actionId);
Dirty(actionId, action);
Dirty(performer, comp);
ActionAdded(performer, actionId, comp, action);
return true;
}
/// <summary>
/// This method gets called after a new action got added.
/// </summary>
protected virtual void ActionAdded(EntityUid performer, EntityUid actionId, ActionsComponent comp, BaseActionComponent action)
{
// See client-side system for UI code.
}
/// <summary>
/// Grant pre-existing actions. If the entity has no action component, this will give them one.
/// </summary>
/// <param name="performer">Entity to receive the actions</param>
/// <param name="actions">The actions to add</param>
/// <param name="container">The entity that enables these actions (e.g., flashlight). May be null (innate actions).</param>
public void GrantActions(EntityUid performer, IEnumerable<EntityUid> actions, EntityUid container, ActionsComponent? comp = null, ActionsContainerComponent? containerComp = null)
{
if (!Resolve(container, ref containerComp))
return;
DebugTools.AssertOwner(performer, comp);
comp ??= EnsureComp<ActionsComponent>(performer);
foreach (var actionId in actions)
{
AddAction(performer, actionId, container, comp, containerComp: containerComp);
}
}
/// <summary>
/// Grants all actions currently contained in some action-container. If the target entity has no action
/// component, this will give them one.
/// </summary>
/// <param name="performer">Entity to receive the actions</param>
/// <param name="container">The entity that contains thee actions.</param>
public void GrantContainedActions(Entity<ActionsComponent?> performer, Entity<ActionsContainerComponent?> container)
{
if (!Resolve(container, ref container.Comp))
return;
performer.Comp ??= EnsureComp<ActionsComponent>(performer);
foreach (var actionId in container.Comp.Container.ContainedEntities)
{
if (TryGetActionData(actionId, out var action))
AddActionDirect(performer, actionId, performer.Comp, action);
}
}
/// <summary>
/// Grants the provided action from the container to the target entity. If the target entity has no action
/// component, this will give them one.
/// </summary>
/// <param name="performer"></param>
/// <param name="container"></param>
/// <param name="actionId"></param>
public void GrantContainedAction(Entity<ActionsComponent?> performer, Entity<ActionsContainerComponent?> container, EntityUid actionId)
{
if (!Resolve(container, ref container.Comp))
return;
performer.Comp ??= EnsureComp<ActionsComponent>(performer);
if (TryGetActionData(actionId, out var action))
AddActionDirect(performer, actionId, performer.Comp, action);
}
public IEnumerable<(EntityUid Id, BaseActionComponent Comp)> GetActions(EntityUid holderId, ActionsComponent? actions = null)
{
if (!Resolve(holderId, ref actions, false))
yield break;
foreach (var actionId in actions.Actions)
{
if (!TryGetActionData(actionId, out var action))
continue;
yield return (actionId, action);
}
}
/// <summary>
/// Remove any actions that were enabled by some other entity. Useful when unequiping items that grant actions.
/// </summary>
public void RemoveProvidedActions(EntityUid performer, EntityUid container, ActionsComponent? comp = null)
{
if (!Resolve(performer, ref comp, false))
return;
foreach (var actionId in comp.Actions.ToArray())
{
if (!TryGetActionData(actionId, out var action))
return;
if (action.Container == container)
RemoveAction(performer, actionId, comp);
}
}
/// <summary>
/// Removes a single provided action provided by another entity.
/// </summary>
public void RemoveProvidedAction(EntityUid performer, EntityUid container, EntityUid actionId, ActionsComponent? comp = null)
{
if (!Resolve(performer, ref comp, false) || !TryGetActionData(actionId, out var action))
return;
if (action.Container == container)
RemoveAction(performer, actionId, comp);
}
public void RemoveAction(EntityUid? actionId)
{
if (actionId == null)
return;
if (!TryGetActionData(actionId, out var action))
return;
if (!TryComp(action.AttachedEntity, out ActionsComponent? comp))
return;
RemoveAction(action.AttachedEntity.Value, actionId, comp, action);
}
public void RemoveAction(EntityUid performer, EntityUid? actionId, ActionsComponent? comp = null, BaseActionComponent? action = null)
{
if (actionId == null)
return;
if (!ResolveActionData(actionId, ref action))
return;
if (action.AttachedEntity != performer)
{
DebugTools.Assert(!Resolve(performer, ref comp, false)
|| comp.LifeStage >= ComponentLifeStage.Stopping
|| !comp.Actions.Contains(actionId.Value));
if (!GameTiming.ApplyingState)
Log.Error($"Attempted to remove an action {ToPrettyString(actionId)} from an entity that it was never attached to: {ToPrettyString(performer)}. Trace: {Environment.StackTrace}");
return;
}
if (!Resolve(performer, ref comp, false))
{
DebugTools.Assert(action.AttachedEntity == null || TerminatingOrDeleted(action.AttachedEntity.Value));
action.AttachedEntity = null;
return;
}
if (action.AttachedEntity == null)
{
// action was already removed?
DebugTools.Assert(!comp.Actions.Contains(actionId.Value) || GameTiming.ApplyingState);
return;
}
comp.Actions.Remove(actionId.Value);
action.AttachedEntity = null;
Dirty(actionId.Value, action);
Dirty(performer, comp);
ActionRemoved(performer, actionId.Value, comp, action);
if (action.Temporary && GameTiming.IsFirstTimePredicted)
Del(actionId.Value);
}
/// <summary>
/// This method gets called after an action got removed.
/// </summary>
protected virtual void ActionRemoved(EntityUid performer, EntityUid actionId, ActionsComponent comp, BaseActionComponent action)
{
// See client-side system for UI code.
}
public bool ValidAction(BaseActionComponent action, bool canReach = true)
{
if (!action.Enabled)
return false;
if (action.Charges.HasValue && action.Charges <= 0)
return false;
var curTime = GameTiming.CurTime;
if (action.Cooldown.HasValue && action.Cooldown.Value.End > curTime)
return false;
return canReach || action is BaseTargetActionComponent { CheckCanAccess: false };
}
#endregion
private void OnRelayActionCompChange(Entity<ActionsComponent> ent, ref RelayedActionComponentChangeEvent args)
{
if (args.Handled)
return;
var ev = new AttemptRelayActionComponentChangeEvent();
RaiseLocalEvent(ent.Owner, ref ev);
var target = ev.Target ?? ent.Owner;
args.Handled = true;
args.Toggle = true;
if (!args.Action.Comp.Toggled)
{
EntityManager.AddComponents(target, args.Components);
}
else
{
EntityManager.RemoveComponents(target, args.Components);
}
}
private void OnActionCompChange(Entity<ActionsComponent> ent, ref ActionComponentChangeEvent args)
{
if (args.Handled)
return;
args.Handled = true;
args.Toggle = true;
var target = ent.Owner;
if (!args.Action.Comp.Toggled)
{
EntityManager.AddComponents(target, args.Components);
}
else
{
EntityManager.RemoveComponents(target, args.Components);
}
}
#region EquipHandlers
private void OnDidEquip(EntityUid uid, ActionsComponent component, DidEquipEvent args)
{
if (GameTiming.ApplyingState)
return;
var ev = new GetItemActionsEvent(_actionContainer, args.Equipee, args.Equipment, args.SlotFlags);
RaiseLocalEvent(args.Equipment, ev);
if (ev.Actions.Count == 0)
return;
GrantActions(args.Equipee, ev.Actions, args.Equipment, component);
}
private void OnHandEquipped(EntityUid uid, ActionsComponent component, DidEquipHandEvent args)
{
if (GameTiming.ApplyingState)
return;
var ev = new GetItemActionsEvent(_actionContainer, args.User, args.Equipped);
RaiseLocalEvent(args.Equipped, ev);
if (ev.Actions.Count == 0)
return;
GrantActions(args.User, ev.Actions, args.Equipped, component);
}
private void OnDidUnequip(EntityUid uid, ActionsComponent component, DidUnequipEvent args)
{
if (GameTiming.ApplyingState)
return;
RemoveProvidedActions(uid, args.Equipment, component);
}
private void OnHandUnequipped(EntityUid uid, ActionsComponent component, DidUnequipHandEvent args)
{
if (GameTiming.ApplyingState)
return;
RemoveProvidedActions(uid, args.Unequipped, component);
}
#endregion
public void SetEntityIcon(EntityUid uid, EntityUid? icon, BaseActionComponent? action = null)
{
if (!Resolve(uid, ref action))
return;
action.EntityIcon = icon;
Dirty(uid, action);
}
}