Merge branch 'master' into offmed-staging

This commit is contained in:
Janet Blackquill
2025-11-05 16:52:49 -05:00
1731 changed files with 38109 additions and 46754 deletions

View File

@@ -10,6 +10,7 @@ using Content.Shared.Hands.EntitySystems;
using Content.Shared.IdentityManagement;
using Content.Shared.Inventory;
using Content.Shared.Localizations;
using Content.Shared.Lock;
using Content.Shared.NameIdentifier;
using Content.Shared.PDA;
using Content.Shared.StationRecords;
@@ -44,6 +45,8 @@ public sealed class AccessReaderSystem : EntitySystem
SubscribeLocalEvent<AccessReaderComponent, GotEmaggedEvent>(OnEmagged);
SubscribeLocalEvent<AccessReaderComponent, LinkAttemptEvent>(OnLinkAttempt);
SubscribeLocalEvent<AccessReaderComponent, AccessReaderConfigurationAttemptEvent>(OnConfigurationAttempt);
SubscribeLocalEvent<AccessReaderComponent, FindAvailableLocksEvent>(OnFindAvailableLocks);
SubscribeLocalEvent<AccessReaderComponent, CheckUserHasLockAccessEvent>(OnCheckLockAccess);
SubscribeLocalEvent<AccessReaderComponent, ComponentGetState>(OnGetState);
SubscribeLocalEvent<AccessReaderComponent, ComponentHandleState>(OnHandleState);
@@ -169,6 +172,22 @@ public sealed class AccessReaderSystem : EntitySystem
ent.Comp.AccessListsOriginal ??= new(ent.Comp.AccessLists);
}
private void OnFindAvailableLocks(Entity<AccessReaderComponent> ent, ref FindAvailableLocksEvent args)
{
args.FoundReaders |= LockTypes.Access;
}
private void OnCheckLockAccess(Entity<AccessReaderComponent> ent, ref CheckUserHasLockAccessEvent args)
{
// Are we looking for an access lock?
if (!args.FoundReaders.HasFlag(LockTypes.Access))
return;
// If the user has access to this lock, we pass it into the event.
if (IsAllowed(args.User, ent))
args.HasAccess |= LockTypes.Access;
}
/// <summary>
/// Searches the source for access tags
/// then compares it with the all targets accesses to see if it is allowed.

View File

@@ -167,15 +167,21 @@ namespace Content.Shared.ActionBlocker
return !ev.Cancelled;
}
public bool CanPickup(EntityUid user, EntityUid item)
/// <summary>
/// Whether a user can pickup the given item.
/// </summary>
/// <param name="user">The mob trying to pick up the item.</param>
/// <param name="item">The item being picked up.</param>
/// <param name="showPopup">Whether or not to show a popup to the player telling them why the attempt failed.</param>
public bool CanPickup(EntityUid user, EntityUid item, bool showPopup = false)
{
var userEv = new PickupAttemptEvent(user, item);
var userEv = new PickupAttemptEvent(user, item, showPopup);
RaiseLocalEvent(user, userEv);
if (userEv.Cancelled)
return false;
var itemEv = new GettingPickedUpAttemptEvent(user, item);
var itemEv = new GettingPickedUpAttemptEvent(user, item, showPopup);
RaiseLocalEvent(item, itemEv);
return !itemEv.Cancelled;

View File

@@ -0,0 +1,14 @@
using Content.Shared.Rejuvenate;
namespace Content.Shared.Administration.Systems;
public sealed class RejuvenateSystem : EntitySystem
{
/// <summary>
/// Fully heals the target, removing all damage, debuffs or other negative status effects.
/// </summary>
public void PerformRejuvenate(EntityUid target)
{
RaiseLocalEvent(target, new RejuvenateEvent());
}
}

View File

@@ -1,5 +1,6 @@
using Content.Shared.Clothing.Components;
using Content.Shared.Damage;
using Content.Shared.Damage.Systems;
using Content.Shared.Examine;
using Content.Shared.Inventory;
using Content.Shared.Silicons.Borgs;

View File

@@ -0,0 +1,139 @@
using Content.Shared.Atmos.EntitySystems;
using Content.Shared.Damage;
using Content.Shared.FixedPoint;
using Content.Shared.Guidebook;
using Robust.Shared.GameStates;
namespace Content.Shared.Atmos.Components;
/// <summary>
/// Entities that have this component will have damage done to them depending on the local pressure
/// environment that they reside in.
///
/// Atmospherics.DeltaPressure batch-processes entities with this component in a list on
/// the grid's GridAtmosphereComponent.
/// The entities are automatically added and removed from this list, and automatically
/// added on initialization.
/// </summary>
/// <remarks> Note that the entity should have an AirtightComponent and be a grid structure.</remarks>
[RegisterComponent]
[NetworkedComponent, AutoGenerateComponentState]
[Access(typeof(SharedAtmosphereSystem), typeof(SharedDeltaPressureSystem))]
public sealed partial class DeltaPressureComponent : Component
{
/// <summary>
/// Whether the entity is currently in the processing list of the grid's GridAtmosphereComponent.
/// </summary>
[DataField(readOnly: true)]
[ViewVariables(VVAccess.ReadOnly)]
public bool InProcessingList;
/// <summary>
/// Whether this entity is currently taking damage from pressure.
/// </summary>
[DataField, AutoNetworkedField]
public bool IsTakingDamage;
/// <summary>
/// The grid this entity is currently joined to for processing.
/// Required for proper deletion, as we cannot reference the grid
/// for removal while the entity is being deleted.
/// </summary>
/// <remarks>Note that while AirtightComponent already stores the grid,
/// we cannot trust it to be available on init or when the entity is being deleted. Tragic.
/// Double note: this is set during ComponentInit and thus does not need to be a datafield
/// or else it will spam serialization.</remarks>
/// TODO ATMOS: Simply use AirtightComponent's GridUID caching and handle entity removal from the processing list on an invalidation system similar to InvalidTiles.
[ViewVariables(VVAccess.ReadOnly)]
public EntityUid? GridUid;
/// <summary>
/// The percent chance that the entity will take damage each atmos tick,
/// when the entity is above the damage threshold.
/// Makes it so that windows don't all break in one go.
/// Float is from 0 to 1, where 1 means 100% chance.
/// If this is set to 0, the entity will never take damage.
/// </summary>
[DataField]
public float RandomDamageChance = 1f;
/// <summary>
/// The base damage applied to the entity per atmos tick when it is above the damage threshold.
/// This damage will be scaled as defined by the <see cref="DeltaPressureDamageScalingType"/> enum
/// depending on the current effective pressure this entity is experiencing.
/// Note that this damage will scale depending on the pressure above the minimum pressure,
/// not at the current pressure.
/// </summary>
[DataField]
public DamageSpecifier BaseDamage = new()
{
DamageDict = new Dictionary<string, FixedPoint2>
{
{ "Structural", 10 },
},
};
/// <summary>
/// The minimum pressure in kPa at which the entity will start taking damage.
/// This doesn't depend on the difference in pressure.
/// The entity will start to take damage if it is exposed to this pressure.
/// This is needed because we don't correctly handle 2-layer windows yet.
/// </summary>
[DataField]
public float MinPressure = 10000;
/// <summary>
/// The minimum difference in pressure between any side required for the entity to start taking damage.
/// </summary>
[DataField]
[GuidebookData]
public float MinPressureDelta = 7500;
/// <summary>
/// The maximum pressure at which damage will no longer scale.
/// If the effective pressure goes beyond this, the damage will be considered at this pressure.
/// </summary>
[DataField]
public float MaxEffectivePressure = 10000;
/// <summary>
/// Simple constant to affect the scaling behavior.
/// See comments in the <see cref="DeltaPressureDamageScalingType"/> types to see how this affects scaling.
/// </summary>
[DataField]
public float ScalingPower = 1;
/// <summary>
/// Defines the scaling behavior for the damage.
/// </summary>
[DataField]
public DeltaPressureDamageScalingType ScalingType = DeltaPressureDamageScalingType.Threshold;
}
/// <summary>
/// An enum that defines how the damage dealt by the <see cref="DeltaPressureComponent"/> scales
/// depending on the pressure experienced by the entity.
/// The scaling is done on the effective pressure, which is the pressure above the minimum pressure.
/// See https://www.desmos.com/calculator/9ctlq3zpnt for a visual representation of the scaling types.
/// </summary>
[Serializable]
public enum DeltaPressureDamageScalingType : byte
{
/// <summary>
/// Damage dealt will be constant as long as the minimum values are met.
/// Scaling power is ignored.
/// </summary>
Threshold,
/// <summary>
/// Damage dealt will be a linear function.
/// Scaling power determines the slope of the function.
/// </summary>
Linear,
/// <summary>
/// Damage dealt will be a logarithmic function.
/// Scaling power determines the base of the log.
/// </summary>
Log,
}

View File

@@ -0,0 +1,20 @@
using Content.Shared.Atmos.Components;
using Content.Shared.Examine;
namespace Content.Shared.Atmos.EntitySystems;
public abstract partial class SharedDeltaPressureSystem : EntitySystem
{
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<DeltaPressureComponent, ExaminedEvent>(OnExaminedEvent);
}
private void OnExaminedEvent(Entity<DeltaPressureComponent> ent, ref ExaminedEvent args)
{
if (ent.Comp.IsTakingDamage)
args.PushMarkup(Loc.GetString("window-taking-damage"));
}
}

View File

@@ -0,0 +1,10 @@
namespace Content.Shared.Atmos;
/// <summary>
/// Event raised on an entity when it is standing on a tile that's on fire.
/// </summary>
/// <param name="Temperature">Current temperature of the hotspot this entity is exposed to.</param>
/// <param name="Volume">Current volume of the hotspot this entity is exposed to.
/// This is not the volume of the tile this entity is on.</param>
[ByRefEvent]
public readonly record struct TileFireEvent(float Temperature, float Volume);

View File

@@ -4,6 +4,7 @@ using Content.Shared.Buckle.Components;
using Content.Shared.Damage;
using Content.Shared.Damage.Events;
using Content.Shared.Damage.ForceSay;
using Content.Shared.Damage.Systems;
using Content.Shared.Emoting;
using Content.Shared.Examine;
using Content.Shared.Eye.Blinding.Systems;

View File

@@ -1,6 +1,6 @@
using Content.Shared.Damage;
using Content.Shared.Damage.Prototypes;
using Robust.Shared.Audio;
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Systems;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Containers;
@@ -42,31 +42,31 @@ public sealed partial class BlockingSystem
private void OnUserDamageModified(EntityUid uid, BlockingUserComponent component, DamageModifyEvent args)
{
if (TryComp<BlockingComponent>(component.BlockingItem, out var blocking))
if (component.BlockingItem is not { } item || !TryComp<BlockingComponent>(item, out var blocking))
return;
if (args.Damage.GetTotal() <= 0)
return;
// A shield should only block damage it can itself absorb. To determine that we need the Damageable component on it.
if (!TryComp<DamageableComponent>(item, out var dmgComp))
return;
var blockFraction = blocking.IsBlocking ? blocking.ActiveBlockFraction : blocking.PassiveBlockFraction;
blockFraction = Math.Clamp(blockFraction, 0, 1);
_damageable.TryChangeDamage((item, dmgComp), blockFraction * args.OriginalDamage);
var modify = new DamageModifierSet();
foreach (var key in dmgComp.Damage.DamageDict.Keys)
{
if (args.Damage.GetTotal() <= 0)
return;
modify.Coefficients.TryAdd(key, 1 - blockFraction);
}
// A shield should only block damage it can itself absorb. To determine that we need the Damageable component on it.
if (!TryComp<DamageableComponent>(component.BlockingItem, out var dmgComp))
return;
args.Damage = DamageSpecifier.ApplyModifierSet(args.Damage, modify);
var blockFraction = blocking.IsBlocking ? blocking.ActiveBlockFraction : blocking.PassiveBlockFraction;
blockFraction = Math.Clamp(blockFraction, 0, 1);
_damageable.TryChangeDamage(component.BlockingItem, blockFraction * args.OriginalDamage);
var modify = new DamageModifierSet();
foreach (var key in dmgComp.Damage.DamageDict.Keys)
{
modify.Coefficients.TryAdd(key, 1 - blockFraction);
}
args.Damage = DamageSpecifier.ApplyModifierSet(args.Damage, modify);
if (blocking.IsBlocking && !args.Damage.Equals(args.OriginalDamage))
{
_audio.PlayPvs(blocking.BlockSound, uid);
}
if (blocking.IsBlocking && !args.Damage.Equals(args.OriginalDamage))
{
_audio.PlayPvs(blocking.BlockSound, uid);
}
}

View File

@@ -22,7 +22,7 @@ public sealed partial class LungComponent : Component
/// The name/key of the solution on this entity which these lungs act on.
/// </summary>
[DataField]
public string SolutionName = LungSystem.LungSolutionName;
public string SolutionName = "Lung";
/// <summary>
/// The solution on this entity that these lungs act on.

View File

@@ -8,7 +8,7 @@ using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
namespace Content.Shared.Body.Components
{
[RegisterComponent, NetworkedComponent, Access(typeof(StomachSystem), typeof(FoodSystem))]
[RegisterComponent, NetworkedComponent, Access(typeof(StomachSystem))]
public sealed partial class StomachComponent : Component
{
/// <summary>

View File

@@ -1,11 +1,14 @@
using Content.Shared.Atmos;
using Content.Shared.Atmos.Components;
using Content.Shared.Atmos.EntitySystems;
using Content.Shared.Body.Components;
using Content.Shared.Chemistry.EntitySystems;
using Content.Shared.Atmos;
using Content.Shared.Body.Prototypes;
using Content.Shared.Chemistry.Components;
using Content.Shared.Clothing;
using Content.Shared.Chemistry.EntitySystems;
using Content.Shared.Inventory.Events;
using Robust.Shared.Prototypes;
using BreathToolComponent = Content.Shared.Atmos.Components.BreathToolComponent;
using InternalsComponent = Content.Shared.Body.Components.InternalsComponent;
namespace Content.Shared.Body.Systems;
@@ -15,8 +18,6 @@ public sealed class LungSystem : EntitySystem
[Dependency] private readonly SharedInternalsSystem _internals = default!;
[Dependency] private readonly SharedSolutionContainerSystem _solutionContainerSystem = default!;
public static string LungSolutionName = "Lung";
public override void Initialize()
{
base.Initialize();
@@ -53,6 +54,7 @@ public sealed class LungSystem : EntitySystem
}
}
// TODO: JUST METABOLIZE GASES DIRECTLY DON'T CONVERT TO REAGENTS!!! (Needs Metabolism refactor :B)
public void GasToReagent(EntityUid uid, LungComponent lung)
{
if (!_solutionContainerSystem.ResolveSolution(uid, lung.SolutionName, ref lung.Solution, out var solution))

View File

@@ -6,7 +6,8 @@ using Content.Shared.Chemistry.EntitySystems;
using Content.Shared.Chemistry.Reaction;
using Content.Shared.Chemistry.Reagent;
using Content.Shared.Damage;
using Content.Shared.EntityEffects.Effects;
using Content.Shared.Damage.Systems;
using Content.Shared.EntityEffects.Effects.Solution;
using Content.Shared.FixedPoint;
using Content.Shared.Fluids;
using Content.Shared.Forensics.Components;
@@ -110,8 +111,7 @@ public abstract class SharedBloodstreamSystem : EntitySystem
// bloodloss damage is based on the base value, and modified by how low your blood level is.
var amt = bloodstream.BloodlossDamage / (0.1f + bloodPercentage);
_damageableSystem.TryChangeDamage(uid, amt,
ignoreResistances: false, interruptsDoAfters: false);
_damageableSystem.TryChangeDamage(uid, amt, ignoreResistances: false, interruptsDoAfters: false);
// Apply dizziness as a symptom of bloodloss.
// The effect is applied in a way that it will never be cleared without being healthy.
@@ -161,7 +161,9 @@ public abstract class SharedBloodstreamSystem : EntitySystem
{
switch (effect)
{
case CreateEntityReactionEffect: // Prevent entities from spawning in the bloodstream
// TODO: Rather than this, ReactionAttempt should allow systems to remove effects from the list before the reaction.
// TODO: I think there's a PR up on the repo for this and if there isn't I'll make one -Princess
case EntityEffects.Effects.EntitySpawning.SpawnEntity: // Prevent entities from spawning in the bloodstream
case AreaReactionEffect: // No spontaneous smoke or foam leaking out of blood vessels.
args.Cancelled = true;
return;
@@ -226,7 +228,7 @@ public abstract class SharedBloodstreamSystem : EntitySystem
// TODO: Replace with RandomPredicted once the engine PR is merged
// Use both the receiver and the damage causing entity for the seed so that we have different results for multiple attacks in the same tick
var seed = SharedRandomExtensions.HashCodeCombine(new() { (int)_timing.CurTick.Value, GetNetEntity(ent).Id, GetNetEntity(args.Origin)?.Id ?? 0 });
var seed = SharedRandomExtensions.HashCodeCombine((int)_timing.CurTick.Value, GetNetEntity(ent).Id, GetNetEntity(args.Origin)?.Id ?? 0 );
var rand = new System.Random(seed);
var prob = Math.Clamp(totalFloat / 25, 0, 1);
if (totalFloat > 0 && rand.Prob(prob))

View File

@@ -181,7 +181,7 @@ public partial class SharedBodySystem
{
// TODO BODY SYSTEM KILL : remove this when wounding and required parts are implemented properly
var damage = new DamageSpecifier(Prototypes.Index(BloodlossDamageType), 300);
Damageable.TryChangeDamage(bodyEnt, damage);
Damageable.ChangeDamage(bodyEnt.Owner, damage);
}
}

View File

@@ -1,4 +1,4 @@
using Content.Shared.Damage;
using Content.Shared.Damage.Systems;
using Content.Shared.Movement.Systems;
using Content.Shared.Standing;
using Robust.Shared.Containers;

View File

@@ -7,6 +7,7 @@ using Content.Shared.Hands.Components;
using Content.Shared.IdentityManagement;
using Content.Shared.Internals;
using Content.Shared.Inventory;
using Content.Shared.Movement.Components;
using Content.Shared.Popups;
using Content.Shared.Verbs;
using Robust.Shared.Containers;
@@ -258,11 +259,15 @@ public abstract class SharedInternalsSystem : EntitySystem
Entity<HandsComponent?, InventoryComponent?, ContainerManagerComponent?> user)
{
// TODO use _respirator.CanMetabolizeGas() to prioritize metabolizable gasses
// Prioritise
// 1. back equipped tanks
// 2. exo-slot tanks
// 3. in-hand tanks
// 4. pocket/belt tanks
// Lookup order:
// 1. Back
// 2. Exo-slot
// 3. In-hand
// 4. Pocket/belt
// Jetpacks will only be used as a fallback if no other tank is found
// Store the first jetpack seen
Entity<GasTankComponent>? found = null;
if (!Resolve(user, ref user.Comp2, ref user.Comp3))
return null;
@@ -271,22 +276,36 @@ public abstract class SharedInternalsSystem : EntitySystem
TryComp<GasTankComponent>(backEntity, out var backGasTank) &&
_gasTank.CanConnectToInternals((backEntity.Value, backGasTank)))
{
return (backEntity.Value, backGasTank);
found = (backEntity.Value, backGasTank);
if (!HasComp<JetpackComponent>(backEntity.Value))
{
return found;
}
}
if (_inventory.TryGetSlotEntity(user, "suitstorage", out var entity, user.Comp2, user.Comp3) &&
TryComp<GasTankComponent>(entity, out var gasTank) &&
_gasTank.CanConnectToInternals((entity.Value, gasTank)))
{
return (entity.Value, gasTank);
found ??= (entity.Value, gasTank);
if (!HasComp<JetpackComponent>(entity.Value))
{
return (entity.Value, gasTank);
}
}
foreach (var item in _inventory.GetHandOrInventoryEntities((user.Owner, user.Comp1, user.Comp2)))
{
if (TryComp(item, out gasTank) && _gasTank.CanConnectToInternals((item, gasTank)))
return (item, gasTank);
{
found ??= (item, gasTank);
if (!HasComp<JetpackComponent>(item))
{
return (item, gasTank);
}
}
}
return null;
return found;
}
}

View File

@@ -467,7 +467,7 @@ public abstract partial class SharedBuckleSystem
// TODO: This is doing 4 moveevents this is why I left the warning in, if you're going to remove it make it only do 1 moveevent.
if (strap.Comp.BuckleOffset != Vector2.Zero)
{
buckleXform.Coordinates = oldBuckledXform.Coordinates.Offset(strap.Comp.BuckleOffset);
_transform.SetCoordinates(buckle, buckleXform, oldBuckledXform.Coordinates.Offset(strap.Comp.BuckleOffset));
}
}

View File

@@ -37,7 +37,7 @@ public sealed class ItemCabinetSystem : EntitySystem
private void OnMapInit(Entity<ItemCabinetComponent> ent, ref MapInitEvent args)
{
// update at mapinit to avoid copy pasting locked: true and locked: false for each closed/open prototype
SetSlotLock(ent, !_openable.IsOpen(ent));
SetSlotLock(ent, _openable.IsClosed(ent, null));
}
private void UpdateAppearance(Entity<ItemCabinetComponent> ent)

View File

@@ -1,5 +1,6 @@
using Content.Shared.Cargo.Components;
using Content.Shared.Cargo.Prototypes;
using JetBrains.Annotations;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
using Robust.Shared.Timing;
@@ -55,6 +56,151 @@ public abstract class SharedCargoSystem : EntitySystem
}
return distribution;
}
/// <summary>
/// Returns information about the given bank account.
/// </summary>
/// <param name="station">Station to get bank account info from.</param>
/// <param name="accountPrototypeId">Bank account prototype ID to get info for.</param>
/// <param name="money">The amount of money in the account</param>
/// <returns>Whether or not the bank account exists.</returns>
public bool TryGetAccount(Entity<StationBankAccountComponent?> station, ProtoId<CargoAccountPrototype> accountPrototypeId, out int money)
{
money = 0;
if (!Resolve(station, ref station.Comp))
return false;
return station.Comp.Accounts.TryGetValue(accountPrototypeId, out money);
}
/// <summary>
/// Returns a readonly dictionary of all accounts and their money info.
/// </summary>
/// <param name="station">Station to get bank account info from.</param>
/// <returns>Whether or not the bank account exists.</returns>
public IReadOnlyDictionary<ProtoId<CargoAccountPrototype>, int> GetAccounts(Entity<StationBankAccountComponent?> station)
{
if (!Resolve(station, ref station.Comp))
return new Dictionary<ProtoId<CargoAccountPrototype>, int>();
return station.Comp.Accounts;
}
/// <summary>
/// Attempts to adjust the money of a certain bank account.
/// </summary>
/// <param name="station">Station where the bank account is from</param>
/// <param name="accountPrototypeId">the id of the bank account</param>
/// <param name="money">how much money to set the account to</param>
/// <param name="createAccount">Whether or not it should create the account if it doesn't exist.</param>
/// <param name="dirty">Whether to mark the bank account component as dirty.</param>
/// <returns>Whether or not setting the value succeeded.</returns>
public bool TryAdjustBankAccount(
Entity<StationBankAccountComponent?> station,
ProtoId<CargoAccountPrototype> accountPrototypeId,
int money,
bool createAccount = false,
bool dirty = true)
{
if (!Resolve(station, ref station.Comp))
return false;
var accounts = station.Comp.Accounts;
if (!accounts.ContainsKey(accountPrototypeId) && !createAccount)
return false;
accounts[accountPrototypeId] += money;
var ev = new BankBalanceUpdatedEvent(station, station.Comp.Accounts);
RaiseLocalEvent(station, ref ev, true);
if (!dirty)
return true;
Dirty(station);
return true;
}
/// <summary>
/// Attempts to set the money of a certain bank account.
/// </summary>
/// <param name="station">Station where the bank account is from</param>
/// <param name="accountPrototypeId">the id of the bank account</param>
/// <param name="money">how much money to set the account to</param>
/// <param name="createAccount">Whether or not it should create the account if it doesn't exist.</param>
/// <param name="dirty">Whether to mark the bank account component as dirty.</param>
/// <returns>Whether or not setting the value succeeded.</returns>
public bool TrySetBankAccount(
Entity<StationBankAccountComponent?> station,
ProtoId<CargoAccountPrototype> accountPrototypeId,
int money,
bool createAccount = false,
bool dirty = true)
{
if (!Resolve(station, ref station.Comp))
return false;
var accounts = station.Comp.Accounts;
if (!accounts.ContainsKey(accountPrototypeId) && !createAccount)
return false;
accounts[accountPrototypeId] = money;
var ev = new BankBalanceUpdatedEvent(station, station.Comp.Accounts);
RaiseLocalEvent(station, ref ev, true);
if (!dirty)
return true;
Dirty(station);
return true;
}
public void UpdateBankAccount(
Entity<StationBankAccountComponent?> ent,
int balanceAdded,
ProtoId<CargoAccountPrototype> account,
bool dirty = true)
{
UpdateBankAccount(
ent,
balanceAdded,
new Dictionary<ProtoId<CargoAccountPrototype>, double> { {account, 1} },
dirty: dirty);
}
/// <summary>
/// Adds or removes funds from the <see cref="StationBankAccountComponent"/>.
/// </summary>
/// <param name="ent">The station.</param>
/// <param name="balanceAdded">The amount of funds to add or remove.</param>
/// <param name="accountDistribution">The distribution between individual <see cref="CargoAccountPrototype"/>.</param>
/// <param name="dirty">Whether to mark the bank account component as dirty.</param>
[PublicAPI]
public void UpdateBankAccount(
Entity<StationBankAccountComponent?> ent,
int balanceAdded,
Dictionary<ProtoId<CargoAccountPrototype>, double> accountDistribution,
bool dirty = true)
{
if (!Resolve(ent, ref ent.Comp))
return;
foreach (var (account, percent) in accountDistribution)
{
var accountBalancedAdded = (int) Math.Round(percent * balanceAdded);
ent.Comp.Accounts[account] += accountBalancedAdded;
}
var ev = new BankBalanceUpdatedEvent(ent, ent.Comp.Accounts);
RaiseLocalEvent(ent, ref ev, true);
if (!dirty)
return;
Dirty(ent);
}
}
[NetSerializable, Serializable]

View File

@@ -0,0 +1,100 @@
using Content.Shared.Charges.Components;
using Content.Shared.Cloning;
using Robust.Shared.Audio;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
namespace Content.Shared.Changeling.Components;
/// <summary>
/// Changeling transformation in item form!
/// An entity with this component works like an implanter:
/// First you use it on a humanoid to make a copy of their identity, along with all species relevant components,
/// then use it on someone else to tranform them into a clone of them.
/// Can be used in combination with <see cref="LimitedChargesComponent"/>
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
public sealed partial class ChangelingClonerComponent : Component
{
/// <summary>
/// A clone of the player you have copied the identity from.
/// This is a full humanoid backup, stored on a paused map.
/// </summary>
/// <remarks>
/// Since this entity is stored on a separate map it will be outside PVS range.
/// </remarks>
[DataField, AutoNetworkedField]
public EntityUid? ClonedBackup;
/// <summary>
/// Current state of the item.
/// </summary>
[DataField, AutoNetworkedField]
public ChangelingClonerState State = ChangelingClonerState.Empty;
/// <summary>
/// The cloning settings to use.
/// </summary>
[DataField, AutoNetworkedField]
public ProtoId<CloningSettingsPrototype> Settings = "ChangelingCloningSettings";
/// <summary>
/// Doafter time for drawing and injecting.
/// </summary>
[DataField, AutoNetworkedField]
public TimeSpan DoAfter = TimeSpan.FromSeconds(5);
/// <summary>
/// Can this item be used more than once?
/// </summary>
[DataField, AutoNetworkedField]
public bool Reusable = true;
/// <summary>
/// Whether or not to add a reset verb to purge the stored identity,
/// allowing you to draw a new one.
/// </summary>
[DataField, AutoNetworkedField]
public bool CanReset = true;
/// <summary>
/// Raise events when renaming the target?
/// This will change their ID card, crew manifest entry, and so on.
/// For admeme purposes.
/// </summary>
[DataField, AutoNetworkedField]
public bool RaiseNameChangeEvents;
/// <summary>
/// The sound to play when taking someone's identity with the item.
/// </summary>
[DataField, AutoNetworkedField]
public SoundSpecifier? DrawSound;
/// <summary>
/// The sound to play when someone is transformed.
/// </summary>
[DataField, AutoNetworkedField]
public SoundSpecifier? InjectSound;
}
/// <summary>
/// Current state of the item.
/// </summary>
[Serializable, NetSerializable]
public enum ChangelingClonerState : byte
{
/// <summary>
/// No sample taken yet.
/// </summary>
Empty,
/// <summary>
/// Filled with a DNA sample.
/// </summary>
Filled,
/// <summary>
/// Has been used (single use only).
/// </summary>
Spent,
}

View File

@@ -0,0 +1,308 @@
using Content.Shared.Administration.Logs;
using Content.Shared.Changeling.Components;
using Content.Shared.Cloning;
using Content.Shared.Database;
using Content.Shared.DoAfter;
using Content.Shared.Examine;
using Content.Shared.Forensics.Systems;
using Content.Shared.Humanoid;
using Content.Shared.IdentityManagement;
using Content.Shared.Interaction;
using Content.Shared.Popups;
using Content.Shared.Verbs;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
namespace Content.Shared.Changeling.Systems;
public sealed class ChangelingClonerSystem : EntitySystem
{
[Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
[Dependency] private readonly SharedHumanoidAppearanceSystem _humanoidAppearance = default!;
[Dependency] private readonly MetaDataSystem _metaData = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedCloningSystem _cloning = default!;
[Dependency] private readonly IPrototypeManager _prototype = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly SharedChangelingIdentitySystem _changelingIdentity = default!;
[Dependency] private readonly SharedForensicsSystem _forensics = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<ChangelingClonerComponent, ExaminedEvent>(OnExamine);
SubscribeLocalEvent<ChangelingClonerComponent, GetVerbsEvent<Verb>>(OnGetVerbs);
SubscribeLocalEvent<ChangelingClonerComponent, AfterInteractEvent>(OnAfterInteract);
SubscribeLocalEvent<ChangelingClonerComponent, ClonerDrawDoAfterEvent>(OnDraw);
SubscribeLocalEvent<ChangelingClonerComponent, ClonerInjectDoAfterEvent>(OnInject);
SubscribeLocalEvent<ChangelingClonerComponent, ComponentShutdown>(OnShutDown);
}
private void OnShutDown(Entity<ChangelingClonerComponent> ent, ref ComponentShutdown args)
{
// Delete the stored clone.
PredictedQueueDel(ent.Comp.ClonedBackup);
}
private void OnExamine(Entity<ChangelingClonerComponent> ent, ref ExaminedEvent args)
{
if (!args.IsInDetailsRange)
return;
var msg = ent.Comp.State switch
{
ChangelingClonerState.Empty => "changeling-cloner-component-empty",
ChangelingClonerState.Filled => "changeling-cloner-component-filled",
ChangelingClonerState.Spent => "changeling-cloner-component-spent",
_ => "error"
};
args.PushMarkup(Loc.GetString(msg));
}
private void OnGetVerbs(Entity<ChangelingClonerComponent> ent, ref GetVerbsEvent<Verb> args)
{
if (!args.CanInteract || !args.CanAccess || args.Hands == null)
return;
if (!ent.Comp.CanReset || ent.Comp.State == ChangelingClonerState.Spent)
return;
var user = args.User;
args.Verbs.Add(new Verb
{
Text = Loc.GetString("changeling-cloner-component-reset-verb"),
Disabled = ent.Comp.ClonedBackup == null,
Act = () => Reset(ent.AsNullable(), user),
DoContactInteraction = true,
});
}
private void OnAfterInteract(Entity<ChangelingClonerComponent> ent, ref AfterInteractEvent args)
{
if (args.Handled || !args.CanReach || args.Target == null)
return;
switch (ent.Comp.State)
{
case ChangelingClonerState.Empty:
args.Handled |= TryDraw(ent.AsNullable(), args.Target.Value, args.User);
break;
case ChangelingClonerState.Filled:
args.Handled |= TryInject(ent.AsNullable(), args.Target.Value, args.User);
break;
case ChangelingClonerState.Spent:
default:
break;
}
}
private void OnDraw(Entity<ChangelingClonerComponent> ent, ref ClonerDrawDoAfterEvent args)
{
if (args.Handled || args.Cancelled || args.Target == null)
return;
Draw(ent.AsNullable(), args.Target.Value, args.User);
args.Handled = true;
}
private void OnInject(Entity<ChangelingClonerComponent> ent, ref ClonerInjectDoAfterEvent args)
{
if (args.Handled || args.Cancelled || args.Target == null)
return;
Inject(ent.AsNullable(), args.Target.Value, args.User);
args.Handled = true;
}
/// <summary>
/// Start a DoAfter to draw a DNA sample from the target.
/// </summary>
public bool TryDraw(Entity<ChangelingClonerComponent?> ent, EntityUid target, EntityUid user)
{
if (!Resolve(ent, ref ent.Comp))
return false;
if (ent.Comp.State != ChangelingClonerState.Empty)
return false;
if (!HasComp<HumanoidAppearanceComponent>(target))
return false; // cloning only works for humanoids at the moment
var args = new DoAfterArgs(EntityManager, user, ent.Comp.DoAfter, new ClonerDrawDoAfterEvent(), ent, target: target, used: ent)
{
BreakOnDamage = true,
BreakOnMove = true,
NeedHand = true,
};
if (!_doAfter.TryStartDoAfter(args))
return false;
var userIdentity = Identity.Entity(user, EntityManager);
var targetIdentity = Identity.Entity(target, EntityManager);
var userMsg = Loc.GetString("changeling-cloner-component-draw-user", ("user", userIdentity), ("target", targetIdentity));
var targetMsg = Loc.GetString("changeling-cloner-component-draw-target", ("user", userIdentity), ("target", targetIdentity));
_popup.PopupClient(userMsg, target, user);
if (user != target) // don't show the warning if using the item on yourself
_popup.PopupEntity(targetMsg, user, target, PopupType.LargeCaution);
return true;
}
/// <summary>
/// Start a DoAfter to inject a DNA sample into someone, turning them into a clone of the original.
/// </summary>
public bool TryInject(Entity<ChangelingClonerComponent?> ent, EntityUid target, EntityUid user)
{
if (!Resolve(ent, ref ent.Comp))
return false;
if (ent.Comp.State != ChangelingClonerState.Filled)
return false;
if (!HasComp<HumanoidAppearanceComponent>(target))
return false; // cloning only works for humanoids at the moment
var args = new DoAfterArgs(EntityManager, user, ent.Comp.DoAfter, new ClonerInjectDoAfterEvent(), ent, target: target, used: ent)
{
BreakOnDamage = true,
BreakOnMove = true,
NeedHand = true,
};
if (!_doAfter.TryStartDoAfter(args))
return false;
var userIdentity = Identity.Entity(user, EntityManager);
var targetIdentity = Identity.Entity(target, EntityManager);
var userMsg = Loc.GetString("changeling-cloner-component-inject-user", ("user", userIdentity), ("target", targetIdentity));
var targetMsg = Loc.GetString("changeling-cloner-component-inject-target", ("user", userIdentity), ("target", targetIdentity));
_popup.PopupClient(userMsg, target, user);
if (user != target) // don't show the warning if using the item on yourself
_popup.PopupEntity(targetMsg, user, target, PopupType.LargeCaution);
return true;
}
/// <summary>
/// Draw a DNA sample from the target.
/// This will create a clone stored on a paused map for data storage.
/// </summary>
public void Draw(Entity<ChangelingClonerComponent?> ent, EntityUid target, EntityUid user)
{
if (!Resolve(ent, ref ent.Comp))
return;
if (ent.Comp.State != ChangelingClonerState.Empty)
return;
if (!HasComp<HumanoidAppearanceComponent>(target))
return; // cloning only works for humanoids at the moment
if (!_prototype.Resolve(ent.Comp.Settings, out var settings))
return;
_adminLogger.Add(LogType.Identity,
$"{user} is using {ent.Owner} to draw DNA from {target}.");
// Make a copy of the target on a paused map, so that we can apply their components later.
ent.Comp.ClonedBackup = _changelingIdentity.CloneToPausedMap(settings, target);
ent.Comp.State = ChangelingClonerState.Filled;
_appearance.SetData(ent.Owner, ChangelingClonerVisuals.State, ChangelingClonerState.Filled);
Dirty(ent);
_audio.PlayPredicted(ent.Comp.DrawSound, target, user);
_forensics.TransferDna(ent, target);
}
/// <summary>
/// Inject a DNA sample into someone, turning them into a clone of the original.
/// </summary>
public void Inject(Entity<ChangelingClonerComponent?> ent, EntityUid target, EntityUid user)
{
if (!Resolve(ent, ref ent.Comp))
return;
if (ent.Comp.State != ChangelingClonerState.Filled)
return;
if (!HasComp<HumanoidAppearanceComponent>(target))
return; // cloning only works for humanoids at the moment
if (!_prototype.Resolve(ent.Comp.Settings, out var settings))
return;
_audio.PlayPredicted(ent.Comp.InjectSound, target, user);
_forensics.TransferDna(ent, target); // transfer DNA before overwriting it
if (!ent.Comp.Reusable)
{
ent.Comp.State = ChangelingClonerState.Spent;
_appearance.SetData(ent.Owner, ChangelingClonerVisuals.State, ChangelingClonerState.Spent);
Dirty(ent);
}
if (!Exists(ent.Comp.ClonedBackup))
return; // the entity is likely out of PVS range on the client
_adminLogger.Add(LogType.Identity,
$"{user} is using {ent.Owner} to inject DNA into {target} changing their identity to {ent.Comp.ClonedBackup.Value}.");
// Do the actual transformation.
_humanoidAppearance.CloneAppearance(ent.Comp.ClonedBackup.Value, target);
_cloning.CloneComponents(ent.Comp.ClonedBackup.Value, target, settings);
_metaData.SetEntityName(target, Name(ent.Comp.ClonedBackup.Value), raiseEvents: ent.Comp.RaiseNameChangeEvents);
}
/// <summary>
/// Purge the stored DNA and allow to draw again.
/// </summary>
public void Reset(Entity<ChangelingClonerComponent?> ent, EntityUid? user)
{
if (!Resolve(ent, ref ent.Comp))
return;
// Delete the stored clone.
PredictedQueueDel(ent.Comp.ClonedBackup);
ent.Comp.ClonedBackup = null;
ent.Comp.State = ChangelingClonerState.Empty;
_appearance.SetData(ent.Owner, ChangelingClonerVisuals.State, ChangelingClonerState.Empty);
Dirty(ent);
if (user == null)
return;
_popup.PopupClient(Loc.GetString("changeling-cloner-component-reset-popup"), user.Value, user.Value);
}
}
/// <summary>
/// Doafter event for drawing a DNA sample.
/// </summary>
[Serializable, NetSerializable]
public sealed partial class ClonerDrawDoAfterEvent : SimpleDoAfterEvent;
/// <summary>
/// DoAfterEvent for injecting a DNA sample, turning a player into someone else.
/// </summary>
[Serializable, NetSerializable]
public sealed partial class ClonerInjectDoAfterEvent : SimpleDoAfterEvent;
/// <summary>
/// Key for the generic visualizer.
/// </summary>
[Serializable, NetSerializable]
public enum ChangelingClonerVisuals : byte
{
State,
}

View File

@@ -4,7 +4,8 @@ using Content.Shared.Armor;
using Content.Shared.Atmos.Rotting;
using Content.Shared.Body.Components;
using Content.Shared.Changeling.Components;
using Content.Shared.Damage;
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Systems;
using Content.Shared.Database;
using Content.Shared.DoAfter;
using Content.Shared.Humanoid;
@@ -92,7 +93,7 @@ public sealed class ChangelingDevourSystem : EntitySystem
if (damage.Damage.DamageDict.TryGetValue(damagePoints.Key, out var val) && val > comp.DevourConsumeDamageCap)
return;
}
_damageable.TryChangeDamage(target, comp.DamagePerTick, true, true, damage, user);
_damageable.ChangeDamage((target.Value, damage), comp.DamagePerTick, true, true, user);
}
/// <summary>

View File

@@ -83,20 +83,19 @@ public abstract class SharedChangelingIdentitySystem : EntitySystem
}
/// <summary>
/// Clone a target humanoid into nullspace and add it to the Changelings list of identities.
/// It creates a perfect copy of the target and can be used to pull components down for future use
/// Clone a target humanoid to a paused map.
/// It creates a perfect copy of the target and can be used to pull components down for future use.
/// </summary>
/// <param name="ent">the Changeling</param>
/// <param name="target">the targets uid</param>
public EntityUid? CloneToPausedMap(Entity<ChangelingIdentityComponent> ent, EntityUid target)
/// <param name="settings">The settings to use for cloning.</param>
/// <param name="target">The target to clone.</param>
public EntityUid? CloneToPausedMap(CloningSettingsPrototype settings, EntityUid target)
{
// Don't create client side duplicate clones or a clientside map.
if (_net.IsClient)
return null;
if (!TryComp<HumanoidAppearanceComponent>(target, out var humanoid)
|| !_prototype.Resolve(humanoid.Species, out var speciesPrototype)
|| !_prototype.Resolve(ent.Comp.IdentityCloningSettings, out var settings))
|| !_prototype.Resolve(humanoid.Species, out var speciesPrototype))
return null;
EnsurePausedMap();
@@ -117,10 +116,30 @@ public abstract class SharedChangelingIdentitySystem : EntitySystem
var targetName = _nameMod.GetBaseName(target);
_metaSystem.SetEntityName(clone, targetName);
ent.Comp.ConsumedIdentities.Add(clone);
return clone;
}
/// <summary>
/// Clone a target humanoid to a paused map and add it to the Changelings list of identities.
/// It creates a perfect copy of the target and can be used to pull components down for future use.
/// </summary>
/// <param name="ent">The Changeling.</param>
/// <param name="target">The target to clone.</param>
public EntityUid? CloneToPausedMap(Entity<ChangelingIdentityComponent> ent, EntityUid target)
{
if (!_prototype.Resolve(ent.Comp.IdentityCloningSettings, out var settings))
return null;
var clone = CloneToPausedMap(settings, target);
if (clone == null)
return null;
ent.Comp.ConsumedIdentities.Add(clone.Value);
Dirty(ent);
HandlePvsOverride(ent, clone);
HandlePvsOverride(ent, clone.Value);
return clone;
}

View File

@@ -1,6 +1,7 @@
using Content.Shared.Actions.Events;
using Content.Shared.Charges.Components;
using Content.Shared.Examine;
using Content.Shared.Rejuvenate;
using JetBrains.Annotations;
using Robust.Shared.Timing;
@@ -19,7 +20,7 @@ public abstract class SharedChargesSystem : EntitySystem
base.Initialize();
SubscribeLocalEvent<LimitedChargesComponent, ExaminedEvent>(OnExamine);
SubscribeLocalEvent<LimitedChargesComponent, RejuvenateEvent>(OnRejuvenate);
SubscribeLocalEvent<LimitedChargesComponent, ActionAttemptEvent>(OnChargesAttempt);
SubscribeLocalEvent<LimitedChargesComponent, MapInitEvent>(OnChargesMapInit);
SubscribeLocalEvent<LimitedChargesComponent, ActionPerformedEvent>(OnChargesPerformed);
@@ -48,6 +49,11 @@ public abstract class SharedChargesSystem : EntitySystem
args.PushMarkup(Loc.GetString("limited-charges-recharging", ("seconds", timeRemaining.TotalSeconds.ToString("F1"))));
}
private void OnRejuvenate(Entity<LimitedChargesComponent> ent, ref RejuvenateEvent args)
{
ResetCharges(ent.AsNullable());
}
private void OnChargesAttempt(Entity<LimitedChargesComponent> ent, ref ActionAttemptEvent args)
{
if (args.Cancelled)

View File

@@ -1,9 +1,49 @@
using Content.Shared.Chat.Prototypes;
using Content.Shared.Chat.Prototypes;
using Content.Shared.Inventory;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
namespace Content.Shared.Chat;
/// <summary>
/// An event raised just before an emote is performed, providing systems with an opportunity to cancel the emote's performance.
/// </summary>
[ByRefEvent]
public sealed class BeforeEmoteEvent(EntityUid source, EmotePrototype emote)
: CancellableEntityEventArgs, IInventoryRelayEvent
{
public readonly EntityUid Source = source;
public readonly EmotePrototype Emote = emote;
/// <summary>
/// The equipment that is blocking emoting. Should only be non-null if the event was canceled.
/// </summary>
public EntityUid? Blocker = null;
public SlotFlags TargetSlots => SlotFlags.WITHOUT_POCKET;
}
/// <summary>
/// Raised by the chat system when an entity made some emote.
/// Use it to play sound, change sprite or something else.
/// </summary>
[ByRefEvent]
public record struct EmoteEvent(EmotePrototype Emote)
{
/// <summary>
/// The used emote.
/// </summary>
public EmotePrototype Emote = Emote;
/// <summary>
/// If this message has already been "handled" by a previous system.
/// </summary>
public bool Handled;
}
/// <summary>
/// Sent by the client when requesting the server to play a specific emote selected from the emote radial menu.
/// </summary>
[Serializable, NetSerializable]
public sealed class PlayEmoteMessage(ProtoId<EmotePrototype> protoId) : EntityEventArgs
{

View File

@@ -3,6 +3,28 @@ namespace Content.Shared.Chat;
public interface ISharedChatManager
{
void Initialize();
/// <summary>
/// Send an admin alert to the admin chat channel.
/// </summary>
/// <param name="message">The message to send.</param>
void SendAdminAlert(string message);
/// <summary>
/// Send an admin alert to the admin chat channel specifically about the given player.
/// Will include info extra like their antag status and name.
/// </summary>
/// <param name="player">The player that the message is about.</param>
/// <param name="message">The message to send.</param>
void SendAdminAlert(EntityUid player, string message);
/// <summary>
/// This is a dangerous function! Only pass in property escaped text.
/// See: <see cref="SendAdminAlert(string)"/>
/// <br/><br/>
/// Use this for things that need to be unformatted (like tpto links) but ensure that everything else
/// is formated properly. If it's not, players could sneak in ban links or other nasty commands that the admins
/// could clink on.
/// </summary>
void SendAdminAlertNoFormatOrEscape(string message);
}

View File

@@ -14,30 +14,44 @@ public sealed partial class AutoEmotePrototype : IPrototype
/// The ID of the emote prototype.
/// </summary>
[DataField("emote", required: true, customTypeSerializer: typeof(PrototypeIdSerializer<EmotePrototype>))]
public string EmoteId = String.Empty;
public string EmoteId = string.Empty;
/// <summary>
/// How often an attempt at the emote will be made.
/// </summary>
[DataField("interval", required: true)]
[DataField(required: true)]
public TimeSpan Interval;
/// <summary>
/// Probability of performing the emote each interval.
/// <summary>
/// </summary>
[DataField("chance")]
public float Chance = 1;
/// <summary>
/// Also send the emote in chat.
/// <summary>
[DataField("withChat")]
/// </summary>
[DataField]
public bool WithChat = true;
/// <summary>
/// Should we ignore action blockers?
/// This does nothing if WithChat is false.
/// </summary>
[DataField]
public bool IgnoreActionBlocker;
/// <summary>
/// Should we ignore whitelists and force the emote?
/// This does nothing if WithChat is false.
/// </summary>
[DataField]
public bool Force;
/// <summary>
/// Hide the chat message from the chat window, only showing the popup.
/// This does nothing if WithChat is false.
/// <summary>
[DataField("hiddenFromChatWindow")]
public bool HiddenFromChatWindow = false;
/// </summary>
[DataField]
public bool HiddenFromChatWindow;
}

View File

@@ -1,12 +1,13 @@
using Content.Shared.Inventory;
using Content.Shared.Radio;
using Content.Shared.Speech;
using Robust.Shared.Prototypes;
using Content.Shared.Inventory;
namespace Content.Shared.Chat;
/// <summary>
/// This event should be sent everytime an entity talks (Radio, local chat, etc...).
/// The event is sent to both the entity itself, and all clothing (For stuff like voice masks).
/// This event should be sent everytime an entity talks (Radio, local chat, etc...).
/// The event is sent to both the entity itself, and all clothing (For stuff like voice masks).
/// </summary>
public sealed class TransformSpeakerNameEvent : EntityEventArgs, IInventoryRelayEvent
{
@@ -22,3 +23,54 @@ public sealed class TransformSpeakerNameEvent : EntityEventArgs, IInventoryRelay
SpeechVerb = null;
}
}
/// <summary>
/// Raised broadcast in order to transform speech.transmit
/// </summary>
public sealed class TransformSpeechEvent : EntityEventArgs
{
public EntityUid Sender;
public string Message;
public TransformSpeechEvent(EntityUid sender, string message)
{
Sender = sender;
Message = message;
}
}
public sealed class CheckIgnoreSpeechBlockerEvent : EntityEventArgs
{
public EntityUid Sender;
public bool IgnoreBlocker;
public CheckIgnoreSpeechBlockerEvent(EntityUid sender, bool ignoreBlocker)
{
Sender = sender;
IgnoreBlocker = ignoreBlocker;
}
}
/// <summary>
/// Raised on an entity when it speaks, either through 'say' or 'whisper'.
/// </summary>
public sealed class EntitySpokeEvent : EntityEventArgs
{
public readonly EntityUid Source;
public readonly string Message;
public readonly string? ObfuscatedMessage; // not null if this was a whisper
/// <summary>
/// If the entity was trying to speak into a radio, this was the channel they were trying to access. If a radio
/// message gets sent on this channel, this should be set to null to prevent duplicate messages.
/// </summary>
public RadioChannelPrototype? Channel;
public EntitySpokeEvent(EntityUid source, string message, RadioChannelPrototype? channel, string? obfuscatedMessage)
{
Source = source;
Message = message;
Channel = channel;
ObfuscatedMessage = obfuscatedMessage;
}
}

View File

@@ -0,0 +1,276 @@
using System.Collections.Frozen;
using Content.Shared.Chat.Prototypes;
using Content.Shared.Speech;
using Robust.Shared.Audio;
using Robust.Shared.Random;
namespace Content.Shared.Chat;
public abstract partial class SharedChatSystem
{
private FrozenDictionary<string, EmotePrototype> _wordEmoteDict = FrozenDictionary<string, EmotePrototype>.Empty;
private void CacheEmotes()
{
var dict = new Dictionary<string, EmotePrototype>();
var emotes = _prototypeManager.EnumeratePrototypes<EmotePrototype>();
foreach (var emote in emotes)
{
foreach (var word in emote.ChatTriggers)
{
var lowerWord = word.ToLower();
if (dict.TryGetValue(lowerWord, out var value))
{
var errMsg = $"Duplicate of emote word {lowerWord} in emotes {emote.ID} and {value.ID}";
Log.Error(errMsg);
continue;
}
dict.Add(lowerWord, emote);
}
}
_wordEmoteDict = dict.ToFrozenDictionary();
}
/// <summary>
/// Makes the selected entity emote using the given <see cref="EmotePrototype"/> and sends a message to chat.
/// </summary>
/// <param name="source">The entity that is speaking</param>
/// <param name="emoteId">The id of emote prototype. Should have valid <see cref="EmotePrototype.ChatMessages"/></param>
/// <param name="hideLog">Whether this message should appear in the adminlog window, or not.</param>
/// <param name="range">Conceptual range of transmission, if it shows in the chat window, if it shows to far-away ghosts or ghosts at all...</param>
/// <param name="ignoreActionBlocker">Whether emote action blocking should be ignored or not.</param>
/// <param name="nameOverride">
/// The name to use for the speaking entity. Usually this should just be modified via <see cref="TransformSpeakerNameEvent"/>.
/// If this is set, the event will not get raised.
/// </param>
/// <param name="forceEmote">Bypasses whitelist/blacklist/availibility checks for if the entity can use this emote</param>
/// <returns>True if an emote was performed. False if the emote is unavailable, cancelled, etc.</returns>
public bool TryEmoteWithChat(
EntityUid source,
string emoteId,
ChatTransmitRange range = ChatTransmitRange.Normal,
bool hideLog = false,
string? nameOverride = null,
bool ignoreActionBlocker = false,
bool forceEmote = false
)
{
if (!_prototypeManager.Resolve<EmotePrototype>(emoteId, out var proto))
return false;
return TryEmoteWithChat(source, proto, range, hideLog: hideLog, nameOverride, ignoreActionBlocker: ignoreActionBlocker, forceEmote: forceEmote);
}
/// <summary>
/// Makes the selected entity emote using the given <see cref="EmotePrototype"/> and sends a message to chat.
/// </summary>
/// <param name="source">The entity that is speaking.</param>
/// <param name="emote">The emote prototype. Should have valid <see cref="EmotePrototype.ChatMessages"/>.</param>
/// <param name="hideLog">Whether this message should appear in the adminlog window or not.</param>
/// <param name="ignoreActionBlocker">Whether emote action blocking should be ignored or not.</param>
/// <param name="range">Conceptual range of transmission, if it shows in the chat window, if it shows to far-away ghosts or ghosts at all...</param>
/// <param name="nameOverride">
/// The name to use for the speaking entity. Usually this should just be modified via <see cref="TransformSpeakerNameEvent"/>.
/// If this is set, the event will not get raised.
/// </param>
/// <param name="forceEmote">Bypasses whitelist/blacklist/availibility checks for if the entity can use this emote</param>
/// <returns>True if an emote was performed. False if the emote is unavailable, cancelled, etc.</returns>
public bool TryEmoteWithChat(
EntityUid source,
EmotePrototype emote,
ChatTransmitRange range = ChatTransmitRange.Normal,
bool hideLog = false,
string? nameOverride = null,
bool ignoreActionBlocker = false,
bool forceEmote = false
)
{
if (!forceEmote && !AllowedToUseEmote(source, emote))
return false;
var didEmote = TryEmoteWithoutChat(source, emote, ignoreActionBlocker);
// check if proto has valid message for chat
if (didEmote && emote.ChatMessages.Count != 0)
{
// not all emotes are loc'd, but for the ones that are we pass in entity
var action = Loc.GetString(_random.Pick(emote.ChatMessages), ("entity", source));
SendEntityEmote(source, action, range, nameOverride, hideLog: hideLog, checkEmote: false, ignoreActionBlocker: ignoreActionBlocker);
}
return didEmote;
}
/// <summary>
/// Makes the selected entity emote using the given <see cref="EmotePrototype"/> without sending any messages to chat.
/// </summary>
/// <returns>True if an emote was performed. False if the emote is unavailable, cancelled, etc.</returns>
public bool TryEmoteWithoutChat(EntityUid uid, string emoteId, bool ignoreActionBlocker = false)
{
if (!_prototypeManager.Resolve<EmotePrototype>(emoteId, out var proto))
return false;
return TryEmoteWithoutChat(uid, proto, ignoreActionBlocker);
}
/// <summary>
/// Makes the selected entity emote using the given <see cref="EmotePrototype"/> without sending any messages to chat.
/// </summary>
/// <returns>True if an emote was performed. False if the emote is unavailable, cancelled, etc.</returns>
public bool TryEmoteWithoutChat(EntityUid uid, EmotePrototype proto, bool ignoreActionBlocker = false)
{
if (!_actionBlocker.CanEmote(uid) && !ignoreActionBlocker)
return false;
return TryInvokeEmoteEvent(uid, proto);
}
/// <summary>
/// Tries to find and play the relevant emote sound in an emote sounds collection.
/// </summary>
/// <returns>True if emote sound was played.</returns>
public bool TryPlayEmoteSound(EntityUid uid, EmoteSoundsPrototype? proto, EmotePrototype emote, AudioParams? audioParams = null)
{
return TryPlayEmoteSound(uid, proto, emote.ID, audioParams);
}
/// <summary>
/// Tries to find and play the relevant emote sound in an emote sounds collection.
/// </summary>
/// <returns>True if emote sound was played.</returns>
public bool TryPlayEmoteSound(EntityUid uid, EmoteSoundsPrototype? proto, string emoteId, AudioParams? audioParams = null)
{
if (proto == null)
return false;
// try to get specific sound for this emote
if (!proto.Sounds.TryGetValue(emoteId, out var sound))
{
// no specific sound - check fallback
sound = proto.FallbackSound;
if (sound == null)
return false;
}
// optional override params > general params for all sounds in set > individual sound params
var param = audioParams ?? proto.GeneralParams ?? sound.Params;
_audio.PlayPvs(sound, uid, param);
return true;
}
/// <summary>
/// Checks if a valid emote was typed, to play sounds and etc and invokes an event.
/// </summary>
/// <param name="source">The entity that is speaking</param>
/// <param name="textInput">Formatted emote message.</param>
/// <returns>True if the chat message should be displayed (because the emote was explicitly cancelled), false if it should not be.</returns>
protected bool TryEmoteChatInput(EntityUid source, string textInput)
{
var actionTrimmedLower = TrimPunctuation(textInput.ToLower());
if (!_wordEmoteDict.TryGetValue(actionTrimmedLower, out var emote))
return true;
if (!AllowedToUseEmote(source, emote))
return true;
return TryInvokeEmoteEvent(source, emote);
}
/// <summary>
/// Checks if we can use this emote based on the emotes whitelist, blacklist, and availability to the entity.
/// </summary>
/// <param name="source">The entity that is speaking</param>
/// <param name="emote">The emote being used</param>
public bool AllowedToUseEmote(EntityUid source, EmotePrototype emote)
{
// If emote is in AllowedEmotes, it will bypass whitelist and blacklist
if (TryComp<SpeechComponent>(source, out var speech) &&
speech.AllowedEmotes.Contains(emote.ID))
{
return true;
}
// Check the whitelist and blacklist
if (_whitelist.IsWhitelistFail(emote.Whitelist, source) ||
_whitelist.IsBlacklistPass(emote.Blacklist, source))
{
return false;
}
// Check if the emote is available for all
if (!emote.Available)
{
return false;
}
return true;
}
/// <summary>
/// Creates and raises <see cref="BeforeEmoteEvent"/> and then <see cref="EmoteEvent"/> to let other systems do things like play audio.
/// In the case that the Before event is cancelled, EmoteEvent will NOT be raised, and will optionally show a message to the player
/// explaining why the emote didn't happen.
/// </summary>
/// <param name="uid">The entity which is emoting</param>
/// <param name="proto">The emote which is being performed</param>
/// <returns>True if the emote was performed, false otherwise.</returns>
private bool TryInvokeEmoteEvent(EntityUid uid, EmotePrototype proto)
{
var beforeEv = new BeforeEmoteEvent(uid, proto);
RaiseLocalEvent(uid, ref beforeEv);
if (beforeEv.Cancelled)
{
// Chat is not predicted anyways, so no need to predict this popup either.
if (_net.IsClient)
return false;
if (beforeEv.Blocker != null)
{
_popup.PopupEntity(
Loc.GetString(
"chat-system-emote-cancelled-blocked",
("emote", Loc.GetString(proto.Name).ToLower()),
("blocker", beforeEv.Blocker.Value)
),
uid,
uid
);
}
else
{
_popup.PopupEntity(
Loc.GetString("chat-system-emote-cancelled-generic",
("emote", Loc.GetString(proto.Name).ToLower())),
uid,
uid
);
}
return false;
}
var ev = new EmoteEvent(proto);
RaiseLocalEvent(uid, ref ev);
return true;
}
private string TrimPunctuation(string textInput)
{
var trimEnd = textInput.Length;
while (trimEnd > 0 && char.IsPunctuation(textInput[trimEnd - 1]))
{
trimEnd--;
}
var trimStart = 0;
while (trimStart < trimEnd && char.IsPunctuation(textInput[trimStart]))
{
trimStart++;
}
return textInput[trimStart..trimEnd];
}
}

View File

@@ -1,15 +1,23 @@
using System.Collections.Frozen;
using System.Text.RegularExpressions;
using Content.Shared.ActionBlocker;
using Content.Shared.Chat.Prototypes;
using Content.Shared.Popups;
using Content.Shared.Radio;
using Content.Shared.Speech;
using Content.Shared.Whitelist;
using Robust.Shared.Audio;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Console;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Utility;
namespace Content.Shared.Chat;
public abstract class SharedChatSystem : EntitySystem
public abstract partial class SharedChatSystem : EntitySystem
{
public const char RadioCommonPrefix = ';';
public const char RadioChannelPrefix = ':';
@@ -38,6 +46,11 @@ public abstract class SharedChatSystem : EntitySystem
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly EntityWhitelistSystem _whitelist = default!;
[Dependency] private readonly ActionBlockerSystem _actionBlocker = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly INetManager _net = default!;
/// <summary>
/// Cache of the keycodes for faster lookup.
@@ -47,15 +60,21 @@ public abstract class SharedChatSystem : EntitySystem
public override void Initialize()
{
base.Initialize();
DebugTools.Assert(_prototypeManager.HasIndex(CommonChannel));
SubscribeLocalEvent<PrototypesReloadedEventArgs>(OnPrototypeReload);
CacheRadios();
CacheEmotes();
}
protected virtual void OnPrototypeReload(PrototypesReloadedEventArgs obj)
{
if (obj.WasModified<RadioChannelPrototype>())
CacheRadios();
if (obj.WasModified<EmotePrototype>())
CacheEmotes();
}
private void CacheRadios()
@@ -127,7 +146,7 @@ public abstract class SharedChatSystem : EntitySystem
/// <param name="channel">The channel that was requested, if any</param>
/// <param name="quiet">Whether or not to generate an informative pop-up message.</param>
/// <returns></returns>
public bool TryProccessRadioMessage(
public bool TryProcessRadioMessage(
EntityUid source,
string input,
out string output,
@@ -293,4 +312,177 @@ public abstract class SharedChatSystem : EntitySystem
tagStart += tag.Length + 2;
return rawmsg.Substring(tagStart, tagEnd - tagStart);
}
protected virtual void SendEntityEmote(
EntityUid source,
string action,
ChatTransmitRange range,
string? nameOverride,
bool hideLog = false,
bool checkEmote = true,
bool ignoreActionBlocker = false,
NetUserId? author = null
)
{ }
/// <summary>
/// Sends an in-character chat message to relevant clients.
/// </summary>
/// <param name="source">The entity that is speaking.</param>
/// <param name="message">The message being spoken or emoted.</param>
/// <param name="desiredType">The chat type.</param>
/// <param name="hideChat">Whether or not this message should appear in the chat window.</param>
/// <param name="hideLog">Whether or not this message should appear in the adminlog window.</param>
/// <param name="shell"></param>
/// <param name="player">The player doing the speaking.</param>
/// <param name="nameOverride">The name to use for the speaking entity. Usually this should just be modified via <see cref="TransformSpeakerNameEvent"/>. If this is set, the event will not get raised.</param>
/// <param name="checkRadioPrefix">Whether or not <paramref name="message"/> should be parsed with consideration of radio channel prefix text at start the start.</param>
/// <param name="ignoreActionBlocker">If set to true, action blocker will not be considered for whether an entity can send this message.</param>
public virtual void TrySendInGameICMessage(
EntityUid source,
string message,
InGameICChatType desiredType,
bool hideChat,
bool hideLog = false,
IConsoleShell? shell = null,
ICommonSession? player = null,
string? nameOverride = null,
bool checkRadioPrefix = true,
bool ignoreActionBlocker = false)
{ }
/// <summary>
/// Sends an in-character chat message to relevant clients.
/// </summary>
/// <param name="source">The entity that is speaking.</param>
/// <param name="message">The message being spoken or emoted.</param>
/// <param name="desiredType">The chat type.</param>
/// <param name="range">Conceptual range of transmission, if it shows in the chat window, if it shows to far-away ghosts or ghosts at all...</param>
/// <param name="hideLog">Disables the admin log for this message if true. Used for entities that are not players, like vendors, cloning, etc.</param>
/// <param name="shell"></param>
/// <param name="player">The player doing the speaking.</param>
/// <param name="nameOverride">The name to use for the speaking entity. Usually this should just be modified via <see cref="TransformSpeakerNameEvent"/>. If this is set, the event will not get raised.</param>
/// <param name="ignoreActionBlocker">If set to true, action blocker will not be considered for whether an entity can send this message.</param>
public virtual void TrySendInGameICMessage(
EntityUid source,
string message,
InGameICChatType desiredType,
ChatTransmitRange range,
bool hideLog = false,
IConsoleShell? shell = null,
ICommonSession? player = null,
string? nameOverride = null,
bool checkRadioPrefix = true,
bool ignoreActionBlocker = false
)
{ }
/// <summary>
/// Sends an out-of-character chat message to relevant clients.
/// </summary>
/// <param name="source">The entity that is speaking.</param>
/// <param name="message">The message being spoken or emoted.</param>
/// <param name="type">The chat type.</param>
/// <param name="hideChat">Whether or not to show the message in the chat window.</param>
/// <param name="shell"></param>
/// <param name="player">The player doing the speaking.</param>
public virtual void TrySendInGameOOCMessage(
EntityUid source,
string message,
InGameOOCChatType type,
bool hideChat,
IConsoleShell? shell = null,
ICommonSession? player = null
)
{ }
/// <summary>
/// Dispatches an announcement to all.
/// </summary>
/// <param name="message">The contents of the message.</param>
/// <param name="sender">The sender (Communications Console in Communications Console Announcement).</param>
/// <param name="playSound">Play the announcement sound.</param>
/// <param name="announcementSound">Sound to play.</param>
/// <param name="colorOverride">Optional color for the announcement message.</param>
public virtual void DispatchGlobalAnnouncement(
string message,
string? sender = null,
bool playSound = true,
SoundSpecifier? announcementSound = null,
Color? colorOverride = null
)
{ }
/// <summary>
/// Dispatches an announcement to players selected by filter.
/// </summary>
/// <param name="filter">Filter to select players who will recieve the announcement.</param>
/// <param name="message">The contents of the message.</param>
/// <param name="source">The entity making the announcement (used to determine the station).</param>
/// <param name="sender">The sender (Communications Console in Communications Console Announcement).</param>
/// <param name="playSound">Play the announcement sound.</param>
/// <param name="announcementSound">Sound to play.</param>
/// <param name="colorOverride">Optional color for the announcement message.</param>
public virtual void DispatchFilteredAnnouncement(
Filter filter,
string message,
EntityUid? source = null,
string? sender = null,
bool playSound = true,
SoundSpecifier? announcementSound = null,
Color? colorOverride = null)
{ }
/// <summary>
/// Dispatches an announcement on a specific station.
/// </summary>
/// <param name="source">The entity making the announcement (used to determine the station).</param>
/// <param name="message">The contents of the message.</param>
/// <param name="sender">The sender (Communications Console in Communications Console Announcement).</param>
/// <param name="playDefaultSound">Play the announcement sound.</param>
/// <param name="announcementSound">Sound to play.</param>
/// <param name="colorOverride">Optional color for the announcement message.</param>
public virtual void DispatchStationAnnouncement(
EntityUid source,
string message,
string? sender = null,
bool playDefaultSound = true,
SoundSpecifier? announcementSound = null,
Color? colorOverride = null)
{ }
}
/// <summary>
/// Controls transmission of chat.
/// </summary>
public enum ChatTransmitRange : byte
{
/// Acts normal, ghosts can hear across the map, etc.
Normal,
/// Normal but ghosts are still range-limited.
GhostRangeLimit,
/// Hidden from the chat window.
HideChat,
/// Ghosts can't hear or see it at all. Regular players can if in-range.
NoGhosts
}
/// <summary>
/// InGame IC chat is for chat that is specifically ingame (not lobby) but is also in character, i.e. speaking.
/// </summary>
// ReSharper disable once InconsistentNaming
public enum InGameICChatType : byte
{
Speak,
Emote,
Whisper
}
/// <summary>
/// InGame OOC chat is for chat that is specifically ingame (not lobby) but is OOC, like deadchat or LOOC.
/// </summary>
public enum InGameOOCChatType : byte
{
Looc,
Dead
}

View File

@@ -1,8 +1,10 @@
using System.Linq;
using Content.Shared.Damage;
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Prototypes;
using Content.Shared.Damage.Systems;
using Content.Shared.Mobs.Components;
using Robust.Shared.Prototypes;
using System.Linq;
namespace Content.Shared.Chat;
@@ -40,7 +42,7 @@ public sealed class SharedSuicideSystem : EntitySystem
appliedDamageSpecifier.DamageDict[key] = Math.Ceiling((double) (value * lethalAmountOfDamage / totalDamage));
}
_damageableSystem.TryChangeDamage(target, appliedDamageSpecifier, true, origin: target);
_damageableSystem.ChangeDamage(target.AsNullable(), appliedDamageSpecifier, true, origin: target);
}
/// <summary>
@@ -64,6 +66,6 @@ public sealed class SharedSuicideSystem : EntitySystem
}
var damage = new DamageSpecifier(damagePrototype, lethalAmountOfDamage);
_damageableSystem.TryChangeDamage(target, damage, true, origin: target);
_damageableSystem.ChangeDamage(target.AsNullable(), damage, true, origin: target);
}
}

View File

@@ -3,8 +3,9 @@ using Robust.Shared.GameStates;
namespace Content.Shared.Chemistry.Components;
/// <summary>
/// Denotes the solution that can be easily removed through any reagent container.
/// Think pouring this or draining from a water tank.
/// Denotes a specific solution contained within this entity that can can be
/// easily "drained". This means things with taps/spigots, or easily poured
/// items.
/// </summary>
[RegisterComponent, NetworkedComponent]
public sealed partial class DrainableSolutionComponent : Component
@@ -12,6 +13,12 @@ public sealed partial class DrainableSolutionComponent : Component
/// <summary>
/// Solution name that can be drained.
/// </summary>
[DataField, ViewVariables(VVAccess.ReadWrite)]
[DataField]
public string Solution = "default";
/// <summary>
/// The drain doafter time required to transfer reagents from the solution.
/// </summary>
[DataField]
public TimeSpan DrainTime = TimeSpan.Zero;
}

View File

@@ -3,8 +3,11 @@ using Robust.Shared.GameStates;
namespace Content.Shared.Chemistry.Components;
/// <summary>
/// Denotes the solution that can be easily dumped into (completely removed from the dumping container into this one)
/// Think pouring a container fully into this.
/// Denotes that there is a solution contained in this entity that can be
/// easily dumped into (that is, completely removed from the dumping container
/// into this one). Think pouring a container fully into this. The action for this is represented via drag & drop.
///
/// To represent it being possible to controllably pour volumes into the entity, see <see cref="RefillableSolutionComponent"/>.
/// </summary>
[RegisterComponent, NetworkedComponent]
public sealed partial class DumpableSolutionComponent : Component
@@ -12,12 +15,13 @@ public sealed partial class DumpableSolutionComponent : Component
/// <summary>
/// Solution name that can be dumped into.
/// </summary>
[DataField, ViewVariables(VVAccess.ReadWrite)]
[DataField]
public string Solution = "default";
/// <summary>
/// Whether the solution can be dumped into infinitely.
/// </summary>
[DataField, ViewVariables(VVAccess.ReadWrite)]
/// <remarks>Note that this is what makes the ChemMaster's buffer a stasis buffer as well!</remarks>
[DataField]
public bool Unlimited = false;
}

View File

@@ -24,6 +24,13 @@ public sealed partial class HyposprayComponent : Component
[DataField]
public FixedPoint2 TransferAmount = FixedPoint2.New(5);
/// <summary>
/// The delay to draw reagents using the hypospray.
/// If set, <see cref="RefillableSolutionComponent"/> RefillTime should probably have the same value.
/// </summary>
[DataField]
public float DrawTime = 0f;
/// <summary>
/// Sound that will be played when injecting.
/// </summary>

View File

@@ -4,9 +4,11 @@ using Robust.Shared.GameStates;
namespace Content.Shared.Chemistry.Components;
/// <summary>
/// Reagents that can be added easily. For example like
/// pouring something into another beaker, glass, or into the gas
/// tank of a car.
/// Denotes that the entity has a solution contained which can be easily added
/// to in controlled volumes. This should go on things that are meant to be refilled, including
/// pouring things into a beaker. The action for this is represented via clicking.
///
/// To represent it being possible to just dump entire volumes at once into an entity, see <see cref="DumpableSolutionComponent"/>.
/// </summary>
[RegisterComponent, NetworkedComponent]
public sealed partial class RefillableSolutionComponent : Component
@@ -14,12 +16,18 @@ public sealed partial class RefillableSolutionComponent : Component
/// <summary>
/// Solution name that can added to easily.
/// </summary>
[DataField, ViewVariables(VVAccess.ReadWrite)]
[DataField]
public string Solution = "default";
/// <summary>
/// The maximum amount that can be transferred to the solution at once
/// </summary>
[DataField, ViewVariables(VVAccess.ReadWrite)]
[DataField]
public FixedPoint2? MaxRefill = null;
/// <summary>
/// The refill doafter time required to transfer reagents into the solution.
/// </summary>
[DataField]
public TimeSpan RefillTime = TimeSpan.Zero;
}

View File

@@ -37,7 +37,6 @@ namespace Content.Shared.Chemistry.Components
/// systems use this.
/// </remarks>
[DataField("maxVol")]
[ViewVariables(VVAccess.ReadWrite)]
public FixedPoint2 MaxVolume { get; set; } = FixedPoint2.Zero;
public float FillFraction => MaxVolume == 0 ? 1 : Volume.Float() / MaxVolume.Float();
@@ -45,8 +44,7 @@ namespace Content.Shared.Chemistry.Components
/// <summary>
/// If reactions will be checked for when adding reagents to the container.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
[DataField("canReact")]
[DataField]
public bool CanReact { get; set; } = true;
/// <summary>
@@ -58,8 +56,7 @@ namespace Content.Shared.Chemistry.Components
/// <summary>
/// The temperature of the reagents in the solution.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
[DataField("temperature")]
[DataField]
public float Temperature { get; set; } = 293.15f;
/// <summary>
@@ -100,7 +97,7 @@ namespace Content.Shared.Chemistry.Components
_heatCapacity = 0;
foreach (var (reagent, quantity) in Contents)
{
_heatCapacity += (float) quantity *
_heatCapacity += (float)quantity *
protoMan.Index<ReagentPrototype>(reagent.Prototype).SpecificHeat;
}
@@ -148,7 +145,7 @@ namespace Content.Shared.Chemistry.Components
/// </summary>
/// <param name="prototype">The prototype ID of the reagent to add.</param>
/// <param name="quantity">The quantity in milli-units.</param>
public Solution(string prototype, FixedPoint2 quantity, List<ReagentData>? data = null) : this()
public Solution([ForbidLiteral] string prototype, FixedPoint2 quantity, List<ReagentData>? data = null) : this()
{
AddReagent(new ReagentId(prototype, data), quantity);
}
@@ -190,7 +187,7 @@ namespace Content.Shared.Chemistry.Components
public void ValidateSolution()
{
// sandbox forbids: [Conditional("DEBUG")]
#if DEBUG
#if DEBUG
// Correct volume
DebugTools.Assert(Contents.Select(x => x.Quantity).Sum() == Volume);
@@ -208,7 +205,7 @@ namespace Content.Shared.Chemistry.Components
UpdateHeatCapacity(null);
DebugTools.Assert(MathHelper.CloseTo(_heatCapacity, cur, tolerance: 0.01));
}
#endif
#endif
}
void ISerializationHooks.AfterDeserialization()
@@ -223,7 +220,7 @@ namespace Content.Shared.Chemistry.Components
MaxVolume = Volume;
}
public bool ContainsPrototype(string prototype)
public bool ContainsPrototype([ForbidLiteral] string prototype)
{
foreach (var (reagent, _) in Contents)
{
@@ -245,7 +242,7 @@ namespace Content.Shared.Chemistry.Components
return false;
}
public bool ContainsReagent(string reagentId, List<ReagentData>? data)
public bool ContainsReagent([ForbidLiteral] string reagentId, List<ReagentData>? data)
=> ContainsReagent(new(reagentId, data));
public bool TryGetReagent(ReagentId id, out ReagentQuantity quantity)
@@ -352,7 +349,7 @@ namespace Content.Shared.Chemistry.Components
/// </summary>
/// <param name="prototype">The prototype ID of the reagent to add.</param>
/// <param name="quantity">The quantity in milli-units.</param>
public void AddReagent(string prototype, FixedPoint2 quantity, bool dirtyHeatCap = true)
public void AddReagent([ForbidLiteral] string prototype, FixedPoint2 quantity, bool dirtyHeatCap = true)
=> AddReagent(new ReagentId(prototype, null), quantity, dirtyHeatCap);
/// <summary>
@@ -673,6 +670,12 @@ namespace Content.Shared.Chemistry.Components
return sol;
}
/// <summary>
/// Splits a solution into two by moving reagents from the given solution into a new one.
/// This modifies the original solution.
/// </summary>
/// <param name="toTake">The quantity of this solution to remove.</param>
/// <returns>A new solution containing the removed reagents.</returns>
public Solution SplitSolution(FixedPoint2 toTake)
{
if (toTake <= FixedPoint2.Zero)
@@ -690,7 +693,7 @@ namespace Content.Shared.Chemistry.Components
var origVol = Volume;
var effVol = Volume.Value;
newSolution = new Solution(Contents.Count) { Temperature = Temperature };
var remaining = (long) toTake.Value;
var remaining = (long)toTake.Value;
for (var i = Contents.Count - 1; i >= 0; i--) // iterate backwards because of remove swap.
{
@@ -706,7 +709,7 @@ namespace Content.Shared.Chemistry.Components
continue;
}
var splitQuantity = FixedPoint2.FromCents((int) split);
var splitQuantity = FixedPoint2.FromCents((int)split);
var newQuantity = quantity - splitQuantity;
DebugTools.Assert(newQuantity >= 0);
@@ -753,7 +756,7 @@ namespace Content.Shared.Chemistry.Components
var effVol = Volume.Value;
Volume -= toTake;
var remaining = (long) toTake.Value;
var remaining = (long)toTake.Value;
for (var i = Contents.Count - 1; i >= 0; i--)// iterate backwards because of remove swap.
{
var (reagent, quantity) = Contents[i];
@@ -768,7 +771,7 @@ namespace Content.Shared.Chemistry.Components
continue;
}
var splitQuantity = FixedPoint2.FromCents((int) split);
var splitQuantity = FixedPoint2.FromCents((int)split);
var newQuantity = quantity - splitQuantity;
if (newQuantity > FixedPoint2.Zero)

View File

@@ -1,10 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using Content.Shared.Administration.Logs;
using Content.Shared.Body.Components;
using Content.Shared.Chemistry.EntitySystems;
using Content.Shared.Chemistry.Components;
using Content.Shared.Chemistry.Components.SolutionManager;
using Content.Shared.Chemistry.Hypospray.Events;
using Content.Shared.Database;
using Content.Shared.DoAfter;
using Content.Shared.FixedPoint;
using Content.Shared.Forensics;
using Content.Shared.IdentityManagement;
@@ -16,6 +16,7 @@ using Content.Shared.Timing;
using Content.Shared.Verbs;
using Content.Shared.Weapons.Melee.Events;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Serialization;
namespace Content.Shared.Chemistry.EntitySystems;
@@ -27,6 +28,7 @@ public sealed class HypospraySystem : EntitySystem
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly SharedSolutionContainerSystem _solutionContainers = default!;
[Dependency] private readonly UseDelaySystem _useDelay = default!;
[Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
public override void Initialize()
{
@@ -36,6 +38,7 @@ public sealed class HypospraySystem : EntitySystem
SubscribeLocalEvent<HyposprayComponent, MeleeHitEvent>(OnAttack);
SubscribeLocalEvent<HyposprayComponent, UseInHandEvent>(OnUseInHand);
SubscribeLocalEvent<HyposprayComponent, GetVerbsEvent<AlternativeVerb>>(AddToggleModeVerb);
SubscribeLocalEvent<HyposprayComponent, HyposprayDrawDoAfterEvent>(OnDrawDoAfter);
}
#region Ref events
@@ -63,6 +66,20 @@ public sealed class HypospraySystem : EntitySystem
TryDoInject(entity, args.HitEntities[0], args.User);
}
private void OnDrawDoAfter(Entity<HyposprayComponent> entity, ref HyposprayDrawDoAfterEvent args)
{
if (args.Cancelled)
return;
if (entity.Comp.CanContainerDraw
&& args.Target.HasValue
&& !EligibleEntity(args.Target.Value, entity)
&& _solutionContainers.TryGetDrawableSolution(args.Target.Value, out var drawableSolution, out _))
{
TryDraw(entity, args.Target.Value, drawableSolution.Value, args.User);
}
}
#endregion
#region Draw/Inject
@@ -73,7 +90,7 @@ public sealed class HypospraySystem : EntitySystem
&& !EligibleEntity(target, entity)
&& _solutionContainers.TryGetDrawableSolution(target, out var drawableSolution, out _))
{
return TryDraw(entity, target, drawableSolution.Value, user);
return TryStartDraw(entity, target, drawableSolution.Value, user);
}
return TryDoInject(entity, target, user);
@@ -186,17 +203,37 @@ public sealed class HypospraySystem : EntitySystem
return true;
}
private bool TryDraw(Entity<HyposprayComponent> entity, EntityUid target, Entity<SolutionComponent> targetSolution, EntityUid user)
public bool TryStartDraw(Entity<HyposprayComponent> entity, EntityUid target, Entity<SolutionComponent> targetSolution, EntityUid user)
{
if (!_solutionContainers.TryGetSolution(entity.Owner, entity.Comp.SolutionName, out var soln,
out var solution) || solution.AvailableVolume == 0)
if (!_solutionContainers.TryGetSolution(entity.Owner, entity.Comp.SolutionName, out var soln))
return false;
if (!TryGetDrawAmount(entity, target, targetSolution, user, soln.Value, out _))
return false;
var doAfterArgs = new DoAfterArgs(EntityManager, user, entity.Comp.DrawTime, new HyposprayDrawDoAfterEvent(), entity, target)
{
BreakOnDamage = true,
BreakOnMove = true,
NeedHand = true,
Hidden = true,
};
return _doAfter.TryStartDoAfter(doAfterArgs, out _);
}
private bool TryGetDrawAmount(Entity<HyposprayComponent> entity, EntityUid target, Entity<SolutionComponent> targetSolution, EntityUid user, Entity<SolutionComponent> solutionEntity, [NotNullWhen(true)] out FixedPoint2? amount)
{
amount = null;
if (solutionEntity.Comp.Solution.AvailableVolume == 0)
{
return false;
}
// Get transfer amount. May be smaller than _transferAmount if not enough room, also make sure there's room in the injector
var realTransferAmount = FixedPoint2.Min(entity.Comp.TransferAmount, targetSolution.Comp.Solution.Volume,
solution.AvailableVolume);
solutionEntity.Comp.Solution.AvailableVolume);
if (realTransferAmount <= 0)
{
@@ -207,7 +244,19 @@ public sealed class HypospraySystem : EntitySystem
return false;
}
var removedSolution = _solutionContainers.Draw(target, targetSolution, realTransferAmount);
amount = realTransferAmount;
return true;
}
private bool TryDraw(Entity<HyposprayComponent> entity, EntityUid target, Entity<SolutionComponent> targetSolution, EntityUid user)
{
if (!_solutionContainers.TryGetSolution(entity.Owner, entity.Comp.SolutionName, out var soln))
return false;
if (!TryGetDrawAmount(entity, target, targetSolution, user, soln.Value, out var amount))
return false;
var removedSolution = _solutionContainers.Draw(target, targetSolution, amount.Value);
if (!_solutionContainers.TryAddSolution(soln.Value, removedSolution))
{
@@ -275,3 +324,6 @@ public sealed class HypospraySystem : EntitySystem
#endregion
}
[Serializable, NetSerializable]
public sealed partial class HyposprayDrawDoAfterEvent : SimpleDoAfterEvent {}

View File

@@ -36,7 +36,7 @@ public sealed class ScoopableSolutionSystem : EntitySystem
!_solution.TryGetRefillableSolution(beaker, out var target, out _))
return false;
var scooped = _solutionTransfer.Transfer(user, ent, src.Value, beaker, target.Value, srcSolution.Volume);
var scooped = _solutionTransfer.Transfer(new SolutionTransferData(user, ent, src.Value, beaker, target.Value, srcSolution.Volume));
if (scooped == 0)
return false;

View File

@@ -588,7 +588,7 @@ public abstract partial class SharedSolutionContainerSystem : EntitySystem
/// Adds a solution to the container, if it can fully fit.
/// </summary>
/// <param name="targetUid">entity holding targetSolution</param>
/// <param name="targetSolution">entity holding targetSolution</param>
/// <param name="targetSolution">entity holding targetSolution</param>
/// <param name="toAdd">solution being added</param>
/// <returns>If the solution could be added.</returns>
public bool TryAddSolution(Entity<SolutionComponent> soln, Solution toAdd)
@@ -606,40 +606,44 @@ public abstract partial class SharedSolutionContainerSystem : EntitySystem
}
/// <summary>
/// Adds as much of a solution to a container as can fit.
/// Adds as much of a solution to a container as can fit and updates the container.
/// </summary>
/// <param name="targetUid">The entity containing <paramref cref="targetSolution"/></param>
/// <param name="targetSolution">The solution being added to.</param>
/// <param name="toAdd">The solution being added to <paramref cref="targetSolution"/></param>
/// <param name="toAdd">The solution being added to <paramref cref="targetSolution"/>. This solution is not modified.</param>
/// <returns>The quantity of the solution actually added.</returns>
public FixedPoint2 AddSolution(Entity<SolutionComponent> soln, Solution toAdd)
{
var (uid, comp) = soln;
var solution = comp.Solution;
var solution = soln.Comp.Solution;
if (toAdd.Volume == FixedPoint2.Zero)
return FixedPoint2.Zero;
var quantity = FixedPoint2.Max(FixedPoint2.Zero, FixedPoint2.Min(toAdd.Volume, solution.AvailableVolume));
if (quantity < toAdd.Volume)
TryTransferSolution(soln, toAdd, quantity);
{
// TODO: This should be made into a function that directly transfers reagents.
// Currently this is quite inefficient.
solution.AddSolution(toAdd.Clone().SplitSolution(quantity), PrototypeManager);
}
else
ForceAddSolution(soln, toAdd);
solution.AddSolution(toAdd, PrototypeManager);
UpdateChemicals(soln);
return quantity;
}
/// <summary>
/// Adds a solution to a container and updates the container.
/// This can exceed the maximum volume of the solution added to.
/// </summary>
/// <param name="targetUid">The entity containing <paramref cref="targetSolution"/></param>
/// <param name="targetSolution">The solution being added to.</param>
/// <param name="toAdd">The solution being added to <paramref cref="targetSolution"/></param>
/// <param name="toAdd">The solution being added to <paramref cref="targetSolution"/>. This solution is not modified.</param>
/// <returns>Whether any reagents were added to the solution.</returns>
public bool ForceAddSolution(Entity<SolutionComponent> soln, Solution toAdd)
{
var (uid, comp) = soln;
var solution = comp.Solution;
var solution = soln.Comp.Solution;
if (toAdd.Volume == FixedPoint2.Zero)
return false;
@@ -707,6 +711,7 @@ public abstract partial class SharedSolutionContainerSystem : EntitySystem
}
// Thermal energy and temperature management.
// TODO: ENERGY CONSERVATION!!! Nuke this once we have HeatContainers and use methods which properly conserve energy and model heat transfer correctly!
#region Thermal Energy and Temperature
@@ -763,6 +768,26 @@ public abstract partial class SharedSolutionContainerSystem : EntitySystem
UpdateChemicals(soln);
}
/// <summary>
/// Same as <see cref="AddThermalEnergy"/> but clamps the value between two temperature values.
/// </summary>
/// <param name="soln">Solution we're adjusting the energy of</param>
/// <param name="thermalEnergy">Thermal energy we're adding or removing</param>
/// <param name="min">Min desired temperature</param>
/// <param name="max">Max desired temperature</param>
public void AddThermalEnergyClamped(Entity<SolutionComponent> soln, float thermalEnergy, float min, float max)
{
var solution = soln.Comp.Solution;
if (thermalEnergy == 0.0f)
return;
var heatCap = solution.GetHeatCapacity(PrototypeManager);
var deltaT = thermalEnergy / heatCap;
solution.Temperature = Math.Clamp(solution.Temperature + deltaT, min, max);
UpdateChemicals(soln);
}
#endregion Thermal Energy and Temperature
#region Event Handlers

View File

@@ -1,19 +1,19 @@
using System.Diagnostics.CodeAnalysis;
using Content.Shared.Administration.Logs;
using Content.Shared.Chemistry;
using Content.Shared.Chemistry.Components;
using Content.Shared.Database;
using Content.Shared.DoAfter;
using Content.Shared.FixedPoint;
using Content.Shared.Interaction;
using Content.Shared.Popups;
using Content.Shared.Verbs;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Serialization;
namespace Content.Shared.Chemistry.EntitySystems;
/// <summary>
/// Allows an entity to transfer solutions with a customizable amount per click.
/// Also provides <see cref="Transfer"/> API for other systems.
/// Allows an entity to transfer solutions with a customizable amount -per click-.
/// Also provides <see cref="Transfer"/>, <see cref="RefillTransfer"/> and <see cref="DrainTransfer"/> API for other systems.
/// </summary>
public sealed class SolutionTransferSystem : EntitySystem
{
@@ -21,6 +21,10 @@ public sealed class SolutionTransferSystem : EntitySystem
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly SharedSolutionContainerSystem _solution = default!;
[Dependency] private readonly SharedUserInterfaceSystem _ui = default!;
[Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
private EntityQuery<RefillableSolutionComponent> _refillableQuery;
private EntityQuery<DrainableSolutionComponent> _drainableQuery;
/// <summary>
/// Default transfer amounts for the set-transfer verb.
@@ -32,28 +36,18 @@ public sealed class SolutionTransferSystem : EntitySystem
base.Initialize();
SubscribeLocalEvent<SolutionTransferComponent, GetVerbsEvent<AlternativeVerb>>(AddSetTransferVerbs);
SubscribeLocalEvent<SolutionTransferComponent, AfterInteractEvent>(OnAfterInteract);
SubscribeLocalEvent<SolutionTransferComponent, TransferAmountSetValueMessage>(OnTransferAmountSetValueMessage);
}
SubscribeLocalEvent<SolutionTransferComponent, AfterInteractEvent>(OnAfterInteract);
SubscribeLocalEvent<SolutionTransferComponent, SolutionDrainTransferDoAfterEvent>(OnSolutionDrainTransferDoAfter);
SubscribeLocalEvent<SolutionTransferComponent, SolutionRefillTransferDoAfterEvent>(OnSolutionFillTransferDoAfter);
private void OnTransferAmountSetValueMessage(Entity<SolutionTransferComponent> ent, ref TransferAmountSetValueMessage message)
{
var (uid, comp) = ent;
var newTransferAmount = FixedPoint2.Clamp(message.Value, comp.MinimumTransferAmount, comp.MaximumTransferAmount);
comp.TransferAmount = newTransferAmount;
if (message.Actor is { Valid: true } user)
_popup.PopupEntity(Loc.GetString("comp-solution-transfer-set-amount", ("amount", newTransferAmount)), uid, user);
Dirty(uid, comp);
_refillableQuery = GetEntityQuery<RefillableSolutionComponent>();
_drainableQuery = GetEntityQuery<DrainableSolutionComponent>();
}
private void AddSetTransferVerbs(Entity<SolutionTransferComponent> ent, ref GetVerbsEvent<AlternativeVerb> args)
{
var (uid, comp) = ent;
if (!args.CanAccess || !args.CanInteract || !comp.CanChangeTransferAmount || args.Hands == null)
if (!args.CanAccess || !args.CanInteract || !ent.Comp.CanChangeTransferAmount || args.Hands == null)
return;
// Custom transfer verb
@@ -66,7 +60,7 @@ public sealed class SolutionTransferSystem : EntitySystem
// TODO: remove server check when bui prediction is a thing
Act = () =>
{
_ui.OpenUi(uid, TransferAmountUiKey.Key, @event.User);
_ui.OpenUi(ent.Owner, TransferAmountUiKey.Key, @event.User);
},
Priority = 1
});
@@ -76,7 +70,7 @@ public sealed class SolutionTransferSystem : EntitySystem
var user = args.User;
foreach (var amount in DefaultTransferAmounts)
{
if (amount < comp.MinimumTransferAmount || amount > comp.MaximumTransferAmount)
if (amount < ent.Comp.MinimumTransferAmount || amount > ent.Comp.MaximumTransferAmount)
continue;
AlternativeVerb verb = new();
@@ -84,11 +78,11 @@ public sealed class SolutionTransferSystem : EntitySystem
verb.Category = VerbCategory.SetTransferAmount;
verb.Act = () =>
{
comp.TransferAmount = amount;
ent.Comp.TransferAmount = amount;
_popup.PopupClient(Loc.GetString("comp-solution-transfer-set-amount", ("amount", amount)), uid, user);
_popup.PopupClient(Loc.GetString("comp-solution-transfer-set-amount", ("amount", amount)), ent.Owner, user);
Dirty(uid, comp);
Dirty(ent.Owner, ent.Comp);
};
// we want to sort by size, not alphabetically by the verb text.
@@ -99,117 +93,301 @@ public sealed class SolutionTransferSystem : EntitySystem
}
}
private void OnTransferAmountSetValueMessage(Entity<SolutionTransferComponent> ent, ref TransferAmountSetValueMessage message)
{
var newTransferAmount = FixedPoint2.Clamp(message.Value, ent.Comp.MinimumTransferAmount, ent.Comp.MaximumTransferAmount);
ent.Comp.TransferAmount = newTransferAmount;
if (message.Actor is { Valid: true } user)
_popup.PopupEntity(Loc.GetString("comp-solution-transfer-set-amount", ("amount", newTransferAmount)), ent.Owner, user);
Dirty(ent.Owner, ent.Comp);
}
private void OnAfterInteract(Entity<SolutionTransferComponent> ent, ref AfterInteractEvent args)
{
if (!args.CanReach || args.Target is not {} target)
return;
var (uid, comp) = ent;
// We have two cases for interaction:
// Held Drainable --> Target Refillable
// Held Refillable <-- Target Drainable
//Special case for reagent tanks, because normally clicking another container will give solution, not take it.
if (comp.CanReceive
&& !HasComp<RefillableSolutionComponent>(target) // target must not be refillable (e.g. Reagent Tanks)
&& _solution.TryGetDrainableSolution(target, out var targetSoln, out _) // target must be drainable
&& TryComp<RefillableSolutionComponent>(uid, out var refill)
&& _solution.TryGetRefillableSolution((uid, refill, null), out var ownerSoln, out var ownerRefill))
// In the case where the target has both Refillable and Drainable, Held --> Target takes priority.
if (ent.Comp.CanSend
&& _drainableQuery.TryComp(ent.Owner, out var heldDrainable)
&& _refillableQuery.TryComp(target, out var targetRefillable)
&& TryGetTransferrableSolutions((ent.Owner, heldDrainable),
(target, targetRefillable),
out var ownerSoln,
out var targetSoln,
out _))
{
var transferAmount = comp.TransferAmount; // This is the player-configurable transfer amount of "uid," not the target reagent tank.
args.Handled = true; //If we reach this point, the interaction counts as handled.
// if the receiver has a smaller transfer limit, use that instead
if (refill?.MaxRefill is {} maxRefill)
var transferAmount = ent.Comp.TransferAmount;
if (targetRefillable.MaxRefill is {} maxRefill)
transferAmount = FixedPoint2.Min(transferAmount, maxRefill);
var transferred = Transfer(args.User, target, targetSoln.Value, uid, ownerSoln.Value, transferAmount);
args.Handled = true;
if (transferred > 0)
{
var toTheBrim = ownerRefill.AvailableVolume == 0;
var msg = toTheBrim
? "comp-solution-transfer-fill-fully"
: "comp-solution-transfer-fill-normal";
var transferData = new SolutionTransferData(args.User, ent.Owner, ownerSoln.Value, target, targetSoln.Value, transferAmount);
var transferTime = targetRefillable.RefillTime + heldDrainable.DrainTime;
_popup.PopupClient(Loc.GetString(msg, ("owner", args.Target), ("amount", transferred), ("target", uid)), uid, args.User);
return;
if (transferTime > TimeSpan.Zero)
{
if (!CanTransfer(transferData))
return;
var doAfterArgs = new DoAfterArgs(EntityManager, args.User, transferTime, new SolutionDrainTransferDoAfterEvent(transferAmount), ent.Owner, target)
{
BreakOnDamage = true,
BreakOnMove = true,
NeedHand = true,
Hidden = true,
};
_doAfter.TryStartDoAfter(doAfterArgs);
}
else
{
DrainTransfer(transferData);
}
return;
}
// if target is refillable, and owner is drainable
if (comp.CanSend
&& TryComp<RefillableSolutionComponent>(target, out var targetRefill)
&& _solution.TryGetRefillableSolution((target, targetRefill, null), out targetSoln, out _)
&& _solution.TryGetDrainableSolution(uid, out ownerSoln, out _))
if (ent.Comp.CanReceive
&& _refillableQuery.TryComp(ent.Owner, out var heldRefillable)
&& _drainableQuery.TryComp(target, out var targetDrainable)
&& TryGetTransferrableSolutions((target, targetDrainable),
(ent.Owner, heldRefillable),
out targetSoln,
out ownerSoln,
out var solution))
{
var transferAmount = comp.TransferAmount;
args.Handled = true; //If we reach this point, the interaction counts as handled.
if (targetRefill?.MaxRefill is {} maxRefill)
var transferAmount = ent.Comp.TransferAmount; // This is the player-configurable transfer amount of "uid," not the target drainable.
if (heldRefillable.MaxRefill is {} maxRefill) // if the receiver has a smaller transfer limit, use that instead
transferAmount = FixedPoint2.Min(transferAmount, maxRefill);
var transferred = Transfer(args.User, uid, ownerSoln.Value, target, targetSoln.Value, transferAmount);
args.Handled = true;
if (transferred > 0)
var transferData = new SolutionTransferData(args.User, target, targetSoln.Value, ent.Owner, ownerSoln.Value, transferAmount);
var transferTime = heldRefillable.RefillTime + targetDrainable.DrainTime;
if (transferTime > TimeSpan.Zero)
{
var message = Loc.GetString("comp-solution-transfer-transfer-solution", ("amount", transferred), ("target", target));
_popup.PopupClient(message, uid, args.User);
if (!CanTransfer(transferData))
return;
var doAfterArgs = new DoAfterArgs(EntityManager, args.User, transferTime, new SolutionRefillTransferDoAfterEvent(transferAmount), ent.Owner, target)
{
BreakOnDamage = true,
BreakOnMove = true,
NeedHand = true,
Hidden = true,
};
_doAfter.TryStartDoAfter(doAfterArgs);
}
else
{
RefillTransfer(transferData, solution);
}
}
}
private void OnSolutionDrainTransferDoAfter(Entity<SolutionTransferComponent> ent, ref SolutionDrainTransferDoAfterEvent args)
{
if (args.Cancelled || args.Target is not { } target)
return;
// Have to check again, in case something has changed.
if (CanSend(ent, target, out var ownerSoln, out var targetSoln))
{
DrainTransfer(new SolutionTransferData(args.User, ent.Owner, ownerSoln.Value, args.Target.Value, targetSoln.Value, args.Amount));
}
}
private void OnSolutionFillTransferDoAfter(Entity<SolutionTransferComponent> ent, ref SolutionRefillTransferDoAfterEvent args)
{
if (args.Cancelled || args.Target is not { } target)
return;
// Have to check again, in case something has changed.
if (!CanRecieve(ent, target, out var ownerSoln, out var targetSoln, out var solution))
return;
RefillTransfer(new SolutionTransferData(args.User, target, targetSoln.Value, ent.Owner, ownerSoln.Value, args.Amount), solution);
}
private bool CanSend(Entity<SolutionTransferComponent, DrainableSolutionComponent?> ent,
Entity<RefillableSolutionComponent?> target,
[NotNullWhen(true)] out Entity<SolutionComponent>? drainable,
[NotNullWhen(true)] out Entity<SolutionComponent>? refillable)
{
drainable = null;
refillable = null;
return ent.Comp1.CanReceive && TryGetTransferrableSolutions(ent.Owner, target, out drainable, out refillable, out _);
}
private bool CanRecieve(Entity<SolutionTransferComponent> ent,
EntityUid source,
[NotNullWhen(true)] out Entity<SolutionComponent>? drainable,
[NotNullWhen(true)] out Entity<SolutionComponent>? refillable,
[NotNullWhen(true)] out Solution? solution)
{
drainable = null;
refillable = null;
solution = null;
return ent.Comp.CanReceive && TryGetTransferrableSolutions(source, ent.Owner, out drainable, out refillable, out solution);
}
private bool TryGetTransferrableSolutions(Entity<DrainableSolutionComponent?> source,
Entity<RefillableSolutionComponent?> target,
[NotNullWhen(true)] out Entity<SolutionComponent>? drainable,
[NotNullWhen(true)] out Entity<SolutionComponent>? refillable,
[NotNullWhen(true)] out Solution? solution)
{
drainable = null;
refillable = null;
solution = null;
if (!_drainableQuery.Resolve(source, ref source.Comp) || !_refillableQuery.Resolve(target, ref target.Comp))
return false;
if (!_solution.TryGetDrainableSolution(source, out drainable, out _))
return false;
if (!_solution.TryGetRefillableSolution(target, out refillable, out solution))
return false;
return true;
}
/// <summary>
/// Transfer from a solution to another, allowing either entity to cancel it and show a popup.
/// Attempt to drain a solution into another, such as pouring a bottle into a glass.
/// Includes a pop-up if the transfer failed or succeeded
/// </summary>
/// <param name="data">The transfer data making up the transfer.</param>
/// <returns>The actual amount transferred.</returns>
private void DrainTransfer(SolutionTransferData data)
{
var transferred = Transfer(data);
if (transferred <= 0)
return;
var message = Loc.GetString("comp-solution-transfer-transfer-solution", ("amount", transferred), ("target", data.TargetEntity));
_popup.PopupClient(message, data.SourceEntity, data.User);
}
/// <summary>
/// Attempt to fill a solution from another container, such as tapping from a water tank.
/// Includes a pop-up if the transfer failed or succeeded.
/// </summary>
/// <param name="data">The transfer data making up the transfer.</param>
/// <param name="targetSolution">The target solution,included for LoC pop-up purposes.</param>
/// <returns>The actual amount transferred.</returns>
private void RefillTransfer(SolutionTransferData data, Solution targetSolution)
{
var transferred = Transfer(data);
if (transferred <= 0)
return;
var toTheBrim = targetSolution.AvailableVolume == 0;
var msg = toTheBrim
? "comp-solution-transfer-fill-fully"
: "comp-solution-transfer-fill-normal";
_popup.PopupClient(Loc.GetString(msg, ("owner", data.SourceEntity), ("amount", transferred), ("target", data.TargetEntity)), data.TargetEntity, data.User);
}
/// <summary>
/// Transfer from a solution to another, allowing either entity to cancel.
/// Includes a pop-up if the transfer failed.
/// </summary>
/// <returns>The actual amount transferred.</returns>
public FixedPoint2 Transfer(EntityUid user,
EntityUid sourceEntity,
Entity<SolutionComponent> source,
EntityUid targetEntity,
Entity<SolutionComponent> target,
FixedPoint2 amount)
public FixedPoint2 Transfer(SolutionTransferData data)
{
var transferAttempt = new SolutionTransferAttemptEvent(sourceEntity, targetEntity);
var sourceSolution = data.Source.Comp.Solution;
var targetSolution = data.Target.Comp.Solution;
// Check if the source is cancelling the transfer
RaiseLocalEvent(sourceEntity, ref transferAttempt);
if (transferAttempt.CancelReason is {} reason)
{
_popup.PopupClient(reason, sourceEntity, user);
if (!CanTransfer(data))
return FixedPoint2.Zero;
}
var sourceSolution = source.Comp.Solution;
if (sourceSolution.Volume == 0)
{
_popup.PopupClient(Loc.GetString("comp-solution-transfer-is-empty", ("target", sourceEntity)), sourceEntity, user);
return FixedPoint2.Zero;
}
var actualAmount = FixedPoint2.Min(data.Amount, FixedPoint2.Min(sourceSolution.Volume, targetSolution.AvailableVolume));
// Check if the target is cancelling the transfer
RaiseLocalEvent(targetEntity, ref transferAttempt);
if (transferAttempt.CancelReason is {} targetReason)
{
_popup.PopupClient(targetReason, targetEntity, user);
return FixedPoint2.Zero;
}
var solution = _solution.SplitSolution(data.Source, actualAmount);
_solution.AddSolution(data.Target, solution);
var targetSolution = target.Comp.Solution;
if (targetSolution.AvailableVolume == 0)
{
_popup.PopupClient(Loc.GetString("comp-solution-transfer-is-full", ("target", targetEntity)), targetEntity, user);
return FixedPoint2.Zero;
}
var ev = new SolutionTransferredEvent(data.SourceEntity, data.TargetEntity, data.User, actualAmount);
RaiseLocalEvent(data.TargetEntity, ref ev);
var actualAmount = FixedPoint2.Min(amount, FixedPoint2.Min(sourceSolution.Volume, targetSolution.AvailableVolume));
var solution = _solution.SplitSolution(source, actualAmount);
_solution.AddSolution(target, solution);
var ev = new SolutionTransferredEvent(sourceEntity, targetEntity, user, actualAmount);
RaiseLocalEvent(targetEntity, ref ev);
_adminLogger.Add(LogType.Action, LogImpact.Medium,
$"{ToPrettyString(user):player} transferred {SharedSolutionContainerSystem.ToPrettyString(solution)} to {ToPrettyString(targetEntity):target}, which now contains {SharedSolutionContainerSystem.ToPrettyString(targetSolution)}");
_adminLogger.Add(LogType.Action,
LogImpact.Medium,
$"{ToPrettyString(data.User):player} transferred {SharedSolutionContainerSystem.ToPrettyString(solution)} to {ToPrettyString(data.TargetEntity):target}, which now contains {SharedSolutionContainerSystem.ToPrettyString(targetSolution)}");
return actualAmount;
}
/// <summary>
/// Check if the source solution can transfer the amount to the target solution, and display a pop-up if it fails.
/// </summary>
private bool CanTransfer(SolutionTransferData data)
{
var transferAttempt = new SolutionTransferAttemptEvent(data.SourceEntity, data.TargetEntity);
// Check if the source is cancelling the transfer
RaiseLocalEvent(data.SourceEntity, ref transferAttempt);
if (transferAttempt.CancelReason is {} reason)
{
_popup.PopupClient(reason, data.SourceEntity, data.User);
return false;
}
var sourceSolution = data.Source.Comp.Solution;
if (sourceSolution.Volume == 0)
{
_popup.PopupClient(Loc.GetString("comp-solution-transfer-is-empty", ("target", data.SourceEntity)), data.SourceEntity, data.User);
return false;
}
// Check if the target is cancelling the transfer
RaiseLocalEvent(data.TargetEntity, ref transferAttempt);
if (transferAttempt.CancelReason is {} targetReason)
{
_popup.PopupClient(targetReason, data.TargetEntity, data.User);
return false;
}
var targetSolution = data.Target.Comp.Solution;
if (targetSolution.AvailableVolume == 0)
{
_popup.PopupClient(Loc.GetString("comp-solution-transfer-is-full", ("target", data.TargetEntity)), data.TargetEntity, data.User);
return false;
}
return true;
}
}
/// <summary>
/// A collection of data containing relevant entities and values for transferring reagents.
/// </summary>
/// <param name="user">The user performing the transfer.</param>
/// <param name="sourceEntity">The entity holding the solution container which reagents are being moved from.</param>
/// <param name="source">The entity holding the solution from which reagents are being moved away from.</param>
/// <param name="targetEntity">The entity holding the solution container which reagents are being moved to.</param>
/// <param name="target">The entity holding the solution which reagents are being moved to</param>
/// <param name="amount">The amount being moved.</param>
public struct SolutionTransferData(EntityUid user, EntityUid sourceEntity, Entity<SolutionComponent> source, EntityUid targetEntity, Entity<SolutionComponent> target, FixedPoint2 amount)
{
public EntityUid User = user;
public EntityUid SourceEntity = sourceEntity;
public Entity<SolutionComponent> Source = source;
public EntityUid TargetEntity = targetEntity;
public Entity<SolutionComponent> Target = target;
public FixedPoint2 Amount = amount;
}
/// <summary>
@@ -234,3 +412,35 @@ public record struct SolutionTransferAttemptEvent(EntityUid From, EntityUid To,
/// </summary>
[ByRefEvent]
public record struct SolutionTransferredEvent(EntityUid From, EntityUid To, EntityUid User, FixedPoint2 Amount);
/// <summary>
/// Doafter event for solution transfers where the held item is drained into the target. Checks for validity both when initiating and when finishing the event.
/// </summary>
[Serializable, NetSerializable]
public sealed partial class SolutionDrainTransferDoAfterEvent : DoAfterEvent
{
public FixedPoint2 Amount;
public SolutionDrainTransferDoAfterEvent(FixedPoint2 amount)
{
Amount = amount;
}
public override DoAfterEvent Clone() => this;
}
/// <summary>
/// Doafter event for solution transfers where the held item is filled from the target. Checks for validity both when initiating and when finishing the event.
/// </summary>
[Serializable, NetSerializable]
public sealed partial class SolutionRefillTransferDoAfterEvent : DoAfterEvent
{
public FixedPoint2 Amount;
public SolutionRefillTransferDoAfterEvent(FixedPoint2 amount)
{
Amount = amount;
}
public override DoAfterEvent Clone() => this;
}

View File

@@ -31,6 +31,7 @@ namespace Content.Shared.Chemistry.Reaction
[Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedTransformSystem _transformSystem = default!;
[Dependency] private readonly SharedEntityEffectsSystem _entityEffects = default!;
/// <summary>
/// A cache of all reactions indexed by at most ONE of their required reactants.
@@ -205,27 +206,12 @@ namespace Content.Shared.Chemistry.Reaction
private void OnReaction(Entity<SolutionComponent> soln, ReactionPrototype reaction, ReagentPrototype? reagent, FixedPoint2 unitReactions)
{
var args = new EntityEffectReagentArgs(soln, EntityManager, null, soln.Comp.Solution, unitReactions, reagent, null, 1f);
var posFound = _transformSystem.TryGetMapOrGridCoordinates(soln, out var gridPos);
_adminLogger.Add(LogType.ChemicalReaction, reaction.Impact,
$"Chemical reaction {reaction.ID:reaction} occurred with strength {unitReactions:strength} on entity {ToPrettyString(soln):metabolizer} at Pos:{(posFound ? $"{gridPos:coordinates}" : "[Grid or Map not Found]")}");
foreach (var effect in reaction.Effects)
{
if (!effect.ShouldApply(args))
continue;
if (effect.ShouldLog)
{
var entity = args.TargetEntity;
_adminLogger.Add(LogType.ReagentEffect, effect.LogImpact,
$"Reaction effect {effect.GetType().Name:effect} of reaction {reaction.ID:reaction} applied on entity {ToPrettyString(entity):entity} at Pos:{(posFound ? $"{gridPos:coordinates}" : "[Grid or Map not Found")}");
}
effect.Effect(args);
}
_entityEffects.ApplyEffects(soln, reaction.Effects, unitReactions.Float());
// Someday, some brave soul will thread through an optional actor
// argument in from every call of OnReaction up, all just to pass

View File

@@ -60,7 +60,7 @@ namespace Content.Shared.Chemistry.Reaction
/// <summary>
/// Effects to be triggered when the reaction occurs.
/// </summary>
[DataField("effects")] public List<EntityEffect> Effects = new();
[DataField("effects")] public EntityEffect[] Effects = [];
/// <summary>
/// How dangerous is this effect? Stuff like bicaridine should be low, while things like methamphetamine

View File

@@ -34,7 +34,7 @@ public sealed partial class ReactiveReagentEffectEntry
public HashSet<string>? Reagents = null;
[DataField("effects", required: true)]
public List<EntityEffect> Effects = default!;
public EntityEffect[] Effects = default!;
[DataField("groups", readOnly: true, serverOnly: true,
customTypeSerializer:typeof(PrototypeIdDictionarySerializer<HashSet<ReactionMethod>, ReactiveGroupPrototype>))]

View File

@@ -1,108 +1,35 @@
using Content.Shared.Administration.Logs;
using Content.Shared.Chemistry.Components;
using Content.Shared.Chemistry.Reaction;
using Content.Shared.Chemistry.Reagent;
using Content.Shared.Database;
using Content.Shared.EntityEffects;
using Content.Shared.FixedPoint;
using JetBrains.Annotations;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
namespace Content.Shared.Chemistry;
[UsedImplicitly]
public sealed class ReactiveSystem : EntitySystem
{
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IRobustRandom _robustRandom = default!;
[Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
[Dependency] private readonly IPrototypeManager _proto = default!;
public void DoEntityReaction(EntityUid uid, Solution solution, ReactionMethod method)
{
foreach (var reagent in solution.Contents.ToArray())
{
ReactionEntity(uid, method, reagent, solution);
ReactionEntity(uid, method, reagent);
}
}
public void ReactionEntity(EntityUid uid, ReactionMethod method, ReagentQuantity reagentQuantity, Solution? source)
public void ReactionEntity(EntityUid uid, ReactionMethod method, ReagentQuantity reagentQuantity)
{
// We throw if the reagent specified doesn't exist.
var proto = _prototypeManager.Index<ReagentPrototype>(reagentQuantity.Reagent.Prototype);
ReactionEntity(uid, method, proto, reagentQuantity, source);
}
public void ReactionEntity(EntityUid uid, ReactionMethod method, ReagentPrototype proto,
ReagentQuantity reagentQuantity, Solution? source)
{
if (!TryComp(uid, out ReactiveComponent? reactive))
if (reagentQuantity.Quantity == FixedPoint2.Zero)
return;
// custom event for bypassing reactivecomponent stuff
var ev = new ReactionEntityEvent(method, proto, reagentQuantity, source);
// We throw if the reagent specified doesn't exist.
if (!_proto.Resolve<ReagentPrototype>(reagentQuantity.Reagent.Prototype, out var proto))
return;
var ev = new ReactionEntityEvent(method, reagentQuantity, proto);
RaiseLocalEvent(uid, ref ev);
// If we have a source solution, use the reagent quantity we have left. Otherwise, use the reaction volume specified.
var args = new EntityEffectReagentArgs(uid, EntityManager, null, source, source?.GetReagentQuantity(reagentQuantity.Reagent) ?? reagentQuantity.Quantity, proto, method, 1f);
// First, check if the reagent wants to apply any effects.
if (proto.ReactiveEffects != null && reactive.ReactiveGroups != null)
{
foreach (var (key, val) in proto.ReactiveEffects)
{
if (!val.Methods.Contains(method))
continue;
if (!reactive.ReactiveGroups.ContainsKey(key))
continue;
if (!reactive.ReactiveGroups[key].Contains(method))
continue;
foreach (var effect in val.Effects)
{
if (!effect.ShouldApply(args, _robustRandom))
continue;
if (effect.ShouldLog)
{
var entity = args.TargetEntity;
_adminLogger.Add(LogType.ReagentEffect, effect.LogImpact,
$"Reactive effect {effect.GetType().Name:effect} of reagent {proto.ID:reagent} with method {method} applied on entity {ToPrettyString(entity):entity} at {Transform(entity).Coordinates:coordinates}");
}
effect.Effect(args);
}
}
}
// Then, check if the prototype has any effects it can apply as well.
if (reactive.Reactions != null)
{
foreach (var entry in reactive.Reactions)
{
if (!entry.Methods.Contains(method))
continue;
if (entry.Reagents != null && !entry.Reagents.Contains(proto.ID))
continue;
foreach (var effect in entry.Effects)
{
if (!effect.ShouldApply(args, _robustRandom))
continue;
if (effect.ShouldLog)
{
var entity = args.TargetEntity;
_adminLogger.Add(LogType.ReagentEffect, effect.LogImpact,
$"Reactive effect {effect.GetType().Name:effect} of {ToPrettyString(entity):entity} using reagent {proto.ID:reagent} with method {method} at {Transform(entity).Coordinates:coordinates}");
}
effect.Effect(args);
}
}
}
}
}
public enum ReactionMethod
@@ -113,9 +40,4 @@ Ingestion,
}
[ByRefEvent]
public readonly record struct ReactionEntityEvent(
ReactionMethod Method,
ReagentPrototype Reagent,
ReagentQuantity ReagentQuantity,
Solution? Source
);
public readonly record struct ReactionEntityEvent(ReactionMethod Method, ReagentQuantity ReagentQuantity, ReagentPrototype Reagent);

View File

@@ -2,21 +2,17 @@ using System.Collections.Frozen;
using System.Linq;
using Content.Shared.FixedPoint;
using System.Text.Json.Serialization;
using Content.Shared.Administration.Logs;
using Content.Shared.Body.Prototypes;
using Content.Shared.Chemistry.Components;
using Content.Shared.Chemistry.Reaction;
using Content.Shared.Contraband;
using Content.Shared.EntityEffects;
using Content.Shared.Database;
using Content.Shared.Localizations;
using Content.Shared.Nutrition;
using Content.Shared.Prototypes;
using Content.Shared.Roles;
using Content.Shared.Slippery;
using Robust.Shared.Audio;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Serialization;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Array;
using Robust.Shared.Utility;
@@ -190,6 +186,7 @@ namespace Content.Shared.Chemistry.Reagent
[DataField]
public SoundSpecifier FootstepSound = new SoundCollectionSpecifier("FootstepPuddle");
// TODO: Reaction tile doesn't work properly and destroys reagents way too quickly
public FixedPoint2 ReactionTile(TileRef tile, FixedPoint2 reactVolume, IEntityManager entityManager, List<ReagentData>? data)
{
var removed = FixedPoint2.Zero;
@@ -211,33 +208,32 @@ namespace Content.Shared.Chemistry.Reagent
return removed;
}
public void ReactionPlant(EntityUid? plantHolder,
ReagentQuantity amount,
Solution solution,
EntityManager entityManager,
IRobustRandom random,
ISharedAdminLogManager logger)
public IEnumerable<string> GuidebookReagentEffectsDescription(IPrototypeManager prototype, IEntitySystemManager entSys, IEnumerable<EntityEffect> effects, FixedPoint2? metabolism = null)
{
if (plantHolder == null)
return;
return effects.Select(x => GuidebookReagentEffectDescription(prototype, entSys, x, metabolism))
.Where(x => x is not null)
.Select(x => x!)
.ToArray();
}
var args = new EntityEffectReagentArgs(plantHolder.Value, entityManager, null, solution, amount.Quantity, this, null, 1f);
foreach (var plantMetabolizable in PlantMetabolisms)
{
if (!plantMetabolizable.ShouldApply(args, random))
continue;
public string? GuidebookReagentEffectDescription(IPrototypeManager prototype, IEntitySystemManager entSys, EntityEffect effect, FixedPoint2? metabolism)
{
if (effect.EntityEffectGuidebookText(prototype, entSys) is not { } description)
return null;
if (plantMetabolizable.ShouldLog)
{
var entity = args.TargetEntity;
logger.Add(
LogType.ReagentEffect,
plantMetabolizable.LogImpact,
$"Plant metabolism effect {plantMetabolizable.GetType().Name:effect} of reagent {ID} applied on entity {entity}");
}
var quantity = metabolism == null ? 0f : (double) (effect.MinScale * metabolism);
plantMetabolizable.Effect(args);
}
return Loc.GetString(
"guidebook-reagent-effect-description",
("reagent", LocalizedName),
("quantity", quantity),
("effect", description),
("chance", effect.Probability),
("conditionCount", effect.Conditions?.Length ?? 0),
("conditions",
ContentLocalizationManager.FormatList(
effect.Conditions?.Select(x => x.EntityConditionGuidebookText(prototype)).ToList() ?? new List<string>()
)));
}
}
@@ -246,6 +242,7 @@ namespace Content.Shared.Chemistry.Reagent
{
public string ReagentPrototype;
// TODO: Kill Metabolism groups!
public Dictionary<ProtoId<MetabolismGroupPrototype>, ReagentEffectsGuideEntry>? GuideEntries;
public List<string>? PlantMetabolisms = null;
@@ -254,15 +251,12 @@ namespace Content.Shared.Chemistry.Reagent
{
ReagentPrototype = proto.ID;
GuideEntries = proto.Metabolisms?
.Select(x => (x.Key, x.Value.MakeGuideEntry(prototype, entSys)))
.Select(x => (x.Key, x.Value.MakeGuideEntry(prototype, entSys, proto)))
.ToDictionary(x => x.Key, x => x.Item2);
if (proto.PlantMetabolisms.Count > 0)
{
PlantMetabolisms = new List<string>(proto.PlantMetabolisms
.Select(x => x.GuidebookEffectDescription(prototype, entSys))
.Where(x => x is not null)
.Select(x => x!)
.ToArray());
PlantMetabolisms =
new List<string>(proto.GuidebookReagentEffectsDescription(prototype, entSys, proto.PlantMetabolisms));
}
}
}
@@ -291,15 +285,11 @@ namespace Content.Shared.Chemistry.Reagent
[DataField("effects", required: true)]
public EntityEffect[] Effects = default!;
public ReagentEffectsGuideEntry MakeGuideEntry(IPrototypeManager prototype, IEntitySystemManager entSys)
public string EntityEffectFormat => "guidebook-reagent-effect-description";
public ReagentEffectsGuideEntry MakeGuideEntry(IPrototypeManager prototype, IEntitySystemManager entSys, ReagentPrototype proto)
{
return new ReagentEffectsGuideEntry(MetabolismRate,
Effects
.Select(x => x.GuidebookEffectDescription(prototype, entSys)) // hate.
.Concat(StatusEffects.Select(x => x.Describe(prototype, entSys))) // Offbrand
.Where(x => x is not null)
.Select(x => x!)
.ToArray());
return new ReagentEffectsGuideEntry(MetabolismRate, proto.GuidebookReagentEffectsDescription(prototype, entSys, Effects, MetabolismRate).ToArray());
}
}

View File

@@ -2,7 +2,7 @@ using Content.Shared.ActionBlocker;
using Content.Shared.Buckle.Components;
using Content.Shared.Climbing.Components;
using Content.Shared.Climbing.Events;
using Content.Shared.Damage;
using Content.Shared.Damage.Systems;
using Content.Shared.DoAfter;
using Content.Shared.DragDrop;
using Content.Shared.Hands.Components;

View File

@@ -1,3 +1,5 @@
using Robust.Shared.Prototypes;
namespace Content.Shared.Cloning;
public abstract partial class SharedCloningSystem : EntitySystem
@@ -11,4 +13,14 @@ public abstract partial class SharedCloningSystem : EntitySystem
public virtual void CloneComponents(EntityUid original, EntityUid clone, CloningSettingsPrototype settings)
{
}
/// <summary>
/// Copy components from one entity to another based on a CloningSettingsPrototype.
/// </summary>
/// <param name="original">The orignal Entity to clone components from.</param>
/// <param name="clone">The target Entity to clone components to.</param>
/// <param name="settings">The clone settings prototype id containing the list of components to clone.</param>
public virtual void CloneComponents(EntityUid original, EntityUid clone, ProtoId<CloningSettingsPrototype> settings)
{
}
}

View File

@@ -62,27 +62,7 @@ public sealed class MaskSystem : EntitySystem
private void OnGotUnequipped(EntityUid uid, MaskComponent mask, GotUnequippedEvent args)
{
if (!mask.IsToggled || !mask.IsToggleable)
return;
mask.IsToggled = false;
ToggleMaskComponents(uid, mask, args.Equipee, mask.EquippedPrefix, true);
}
/// <summary>
/// Called after setting IsToggled, raises events and dirties.
/// </summary>
private void ToggleMaskComponents(EntityUid uid, MaskComponent mask, EntityUid wearer, string? equippedPrefix = null, bool isEquip = false)
{
Dirty(uid, mask);
if (mask.ToggleActionEntity is { } action)
_actionSystem.SetToggled(action, mask.IsToggled);
var maskEv = new ItemMaskToggledEvent((uid, mask), wearer);
RaiseLocalEvent(uid, ref maskEv);
var wearerEv = new WearerMaskToggledEvent((uid, mask));
RaiseLocalEvent(wearer, ref wearerEv);
SetToggled(uid, false);
}
private void OnFolded(Entity<MaskComponent> ent, ref FoldedEvent args)

View File

@@ -1,5 +1,6 @@
using Content.Shared.Clothing.Components;
using Content.Shared.Damage;
using Content.Shared.Damage.Systems;
using Content.Shared.Examine;
using Content.Shared.Inventory;
using Content.Shared.Movement.Systems;

View File

@@ -2,7 +2,7 @@ using Content.Shared.CCVar;
using Content.Shared.Chemistry.Hypospray.Events;
using Content.Shared.Climbing.Components;
using Content.Shared.Climbing.Events;
using Content.Shared.Damage;
using Content.Shared.Damage.Systems;
using Content.Shared.IdentityManagement;
using Content.Shared.Medical;
using Content.Shared.Popups;
@@ -49,7 +49,7 @@ public sealed class ClumsySystem : EntitySystem
return;
// TODO: Replace with RandomPredicted once the engine PR is merged
var seed = SharedRandomExtensions.HashCodeCombine(new() { (int)_timing.CurTick.Value, GetNetEntity(ent).Id });
var seed = SharedRandomExtensions.HashCodeCombine((int)_timing.CurTick.Value, GetNetEntity(ent).Id);
var rand = new System.Random(seed);
if (!rand.Prob(ent.Comp.ClumsyDefaultCheck))
return;
@@ -68,7 +68,7 @@ public sealed class ClumsySystem : EntitySystem
return;
// TODO: Replace with RandomPredicted once the engine PR is merged
var seed = SharedRandomExtensions.HashCodeCombine(new() { (int)_timing.CurTick.Value, GetNetEntity(ent).Id });
var seed = SharedRandomExtensions.HashCodeCombine((int)_timing.CurTick.Value, GetNetEntity(ent).Id);
var rand = new System.Random(seed);
if (!rand.Prob(ent.Comp.ClumsyDefaultCheck))
return;
@@ -87,7 +87,7 @@ public sealed class ClumsySystem : EntitySystem
return;
// TODO: Replace with RandomPredicted once the engine PR is merged
var seed = SharedRandomExtensions.HashCodeCombine(new() { (int)_timing.CurTick.Value, GetNetEntity(args.Item).Id });
var seed = SharedRandomExtensions.HashCodeCombine((int)_timing.CurTick.Value, GetNetEntity(args.Item).Id);
var rand = new System.Random(seed);
if (!rand.Prob(ent.Comp.ClumsyDefaultCheck))
return;
@@ -95,7 +95,7 @@ public sealed class ClumsySystem : EntitySystem
args.Cancelled = true; // fail to catch
if (ent.Comp.CatchingFailDamage != null)
_damageable.TryChangeDamage(ent, ent.Comp.CatchingFailDamage, origin: args.Item);
_damageable.ChangeDamage(ent.Owner, ent.Comp.CatchingFailDamage, origin: args.Item);
// Collisions don't work properly with PopupPredicted or PlayPredicted.
// So we make this server only.
@@ -121,13 +121,13 @@ public sealed class ClumsySystem : EntitySystem
return;
// TODO: Replace with RandomPredicted once the engine PR is merged
var seed = SharedRandomExtensions.HashCodeCombine(new() { (int)_timing.CurTick.Value, GetNetEntity(args.Gun).Id });
var seed = SharedRandomExtensions.HashCodeCombine((int)_timing.CurTick.Value, GetNetEntity(args.Gun).Id);
var rand = new System.Random(seed);
if (!rand.Prob(ent.Comp.ClumsyDefaultCheck))
return;
if (ent.Comp.GunShootFailDamage != null)
_damageable.TryChangeDamage(ent, ent.Comp.GunShootFailDamage, origin: ent);
_damageable.ChangeDamage(ent.Owner, ent.Comp.GunShootFailDamage, origin: ent);
_stun.TryUpdateParalyzeDuration(ent, ent.Comp.GunShootFailStunTime);
@@ -146,7 +146,7 @@ public sealed class ClumsySystem : EntitySystem
return;
// TODO: Replace with RandomPredicted once the engine PR is merged
var seed = SharedRandomExtensions.HashCodeCombine(new() { (int)_timing.CurTick.Value, GetNetEntity(ent).Id });
var seed = SharedRandomExtensions.HashCodeCombine((int)_timing.CurTick.Value, GetNetEntity(ent).Id);
var rand = new System.Random(seed);
if (!_cfg.GetCVar(CCVars.GameTableBonk) && !rand.Prob(ent.Comp.ClumsyDefaultCheck))
return;
@@ -199,7 +199,7 @@ public sealed class ClumsySystem : EntitySystem
{
stunTime = bonkComp.BonkTime;
if (bonkComp.BonkDamage != null)
_damageable.TryChangeDamage(target, bonkComp.BonkDamage, true);
_damageable.ChangeDamage(target.Owner, bonkComp.BonkDamage, true);
}
_stun.TryUpdateParalyzeDuration(target, stunTime);

View File

@@ -6,6 +6,7 @@ using Content.Shared.IdentityManagement;
using Content.Shared.Interaction.Events;
using Content.Shared.Popups;
using Content.Shared.Throwing;
using Content.Shared.Weapons.Ranged.Components;
using Content.Shared.Weapons.Ranged.Events;
using Robust.Shared.Timing;
@@ -65,6 +66,10 @@ public sealed class PacificationSystem : EntitySystem
if (HasComp<PacifismAllowedGunComponent>(args.Used))
return;
if (TryComp<BatteryWeaponFireModesComponent>(args.Used, out var component))
if (component.FireModes[component.CurrentFireMode].PacifismAllowedMode)
return;
// Disallow firing guns in all cases.
ShowPopup(ent, args.Used, "pacified-cannot-fire-gun");
args.Cancel();

View File

@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Content.Shared.Administration.Logs;
using Content.Shared.Examine;
using Content.Shared.Construction.Components;

View File

@@ -573,7 +573,7 @@ namespace Content.Shared.Containers.ItemSlots
item = slot.Item;
// This handles user logic
if (user != null && item != null && !_actionBlockerSystem.CanPickup(user.Value, item.Value))
if (user != null && item != null && !_actionBlockerSystem.CanPickup(user.Value, item.Value, showPopup: true))
return false;
Eject(uid, slot, item!.Value, user, excludeUserAudio);

View File

@@ -45,6 +45,8 @@ public abstract class SharedCriminalRecordsSystem : EntitySystem
SecurityStatus.Detained => "SecurityIconIncarcerated",
SecurityStatus.Discharged => "SecurityIconDischarged",
SecurityStatus.Suspected => "SecurityIconSuspected",
SecurityStatus.Hostile => "SecurityIconHostile",
SecurityStatus.Eliminated => "SecurityIconEliminated",
_ => record.StatusIcon
};

View File

@@ -24,12 +24,6 @@ public sealed partial class CuffableComponent : Component
[ViewVariables]
public int CuffedHandCount => Container.ContainedEntities.Count * 2;
/// <summary>
/// The last pair of cuffs that was added to this entity.
/// </summary>
[ViewVariables]
public EntityUid LastAddedCuffs => Container.ContainedEntities[^1];
/// <summary>
/// Container of various handcuffs currently applied to the entity.
/// </summary>

View File

@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Shared.ActionBlocker;
using Content.Shared.Administration.Components;
@@ -260,7 +261,7 @@ namespace Content.Shared.Cuffs
{
if (args.Handled)
return;
TryUncuff(ent, ent, cuffable: ent.Comp);
TryUncuff((ent, ent.Comp), ent);
args.Handled = true;
}
@@ -278,7 +279,7 @@ namespace Content.Shared.Cuffs
Verb verb = new()
{
Act = () => TryUncuff(uid, args.User, cuffable: component),
Act = () => TryUncuff((uid, component), args.User),
DoContactInteraction = true,
Text = Loc.GetString("uncuff-verb-get-data-text")
};
@@ -585,41 +586,31 @@ namespace Content.Shared.Cuffs
return true;
}
/// <inheritdoc cref="TryUncuff(Entity{CuffableComponent?},EntityUid,Entity{HandcuffComponent?})"/>
public void TryUncuff(Entity<CuffableComponent?> target, EntityUid user)
{
if (!TryGetLastCuff(target, out var cuff))
return;
TryUncuff(target, user, cuff.Value);
}
/// <summary>
/// Attempt to uncuff a cuffed entity. Can be called by the cuffed entity, or another entity trying to help uncuff them.
/// If the uncuffing succeeds, the cuffs will drop on the floor.
/// </summary>
/// <param name="target"></param>
/// <param name="user">The cuffed entity</param>
/// <param name="cuffsToRemove">Optional param for the handcuff entity to remove from the cuffed entity. If null, uses the most recently added handcuff entity.</param>
/// <param name="cuffable"></param>
/// <param name="cuff"></param>
public void TryUncuff(EntityUid target, EntityUid user, EntityUid? cuffsToRemove = null, CuffableComponent? cuffable = null, HandcuffComponent? cuff = null)
/// <param name="target">The entity we're trying to remove cuffs from.</param>
/// <param name="user">The entity doing the cuffing.</param>
/// <param name="cuff">The handcuff entity we're attempting to remove.</param>
public void TryUncuff(Entity<CuffableComponent?> target, EntityUid user, Entity<HandcuffComponent?> cuff)
{
if (!Resolve(target, ref cuffable))
if (!Resolve(target, ref target.Comp) || !Resolve(cuff, ref cuff.Comp))
return;
var isOwner = user == target;
var isOwner = user == target.Owner;
if (cuffsToRemove == null)
{
if (cuffable.Container.ContainedEntities.Count == 0)
{
return;
}
cuffsToRemove = cuffable.LastAddedCuffs;
}
else
{
if (!cuffable.Container.ContainedEntities.Contains(cuffsToRemove.Value))
{
Log.Warning("A user is trying to remove handcuffs that aren't in the owner's container. This should never happen!");
}
}
if (!Resolve(cuffsToRemove.Value, ref cuff))
return;
if (!target.Comp.Container.ContainedEntities.Contains(cuff))
Log.Warning("A user is trying to remove handcuffs that aren't in the owner's container. This should never happen!");
var attempt = new UncuffAttemptEvent(user, target);
RaiseLocalEvent(user, ref attempt, true);
@@ -629,29 +620,28 @@ namespace Content.Shared.Cuffs
return;
}
if (!isOwner && !_interaction.InRangeUnobstructed(user, target))
if (!isOwner && !_interaction.InRangeUnobstructed(user, target.Owner))
{
_popup.PopupClient(Loc.GetString("cuffable-component-cannot-remove-cuffs-too-far-message"), user, user);
return;
}
var ev = new ModifyUncuffDurationEvent(user, target, isOwner ? cuff.BreakoutTime : cuff.UncuffTime);
var ev = new ModifyUncuffDurationEvent(user, target, isOwner ? cuff.Comp.BreakoutTime : cuff.Comp.UncuffTime);
RaiseLocalEvent(user, ref ev);
var uncuffTime = ev.Duration;
if (isOwner)
{
if (!TryComp(cuffsToRemove.Value, out UseDelayComponent? useDelay))
if (!TryComp(cuff, out UseDelayComponent? useDelay))
return;
if (!_delay.TryResetDelay((cuffsToRemove.Value, useDelay), true))
if (!_delay.TryResetDelay((cuff, useDelay), true))
{
return;
}
}
var doAfterEventArgs = new DoAfterArgs(EntityManager, user, uncuffTime, new UnCuffDoAfterEvent(), target, target, cuffsToRemove)
var doAfterEventArgs = new DoAfterArgs(EntityManager, user, uncuffTime, new UnCuffDoAfterEvent(), target, target, cuff)
{
BreakOnMove = true,
BreakOnWeightlessMove = false,
@@ -666,7 +656,7 @@ namespace Content.Shared.Cuffs
_adminLog.Add(LogType.Action, LogImpact.High, $"{ToPrettyString(user):player} is trying to uncuff {ToPrettyString(target):subject}");
var popupText = user == target
var popupText = user == target.Owner
? "cuffable-component-start-uncuffing-self-observer"
: "cuffable-component-start-uncuffing-observer";
_popup.PopupEntity(
@@ -678,7 +668,7 @@ namespace Content.Shared.Cuffs
.RemoveWhere(e => e.AttachedEntity == target || e.AttachedEntity == user),
true);
if (target == user)
if (isOwner)
{
_popup.PopupClient(Loc.GetString("cuffable-component-start-uncuffing-self"), user, user);
}
@@ -694,7 +684,7 @@ namespace Content.Shared.Cuffs
target);
}
_audio.PlayPredicted(isOwner ? cuff.StartBreakoutSound : cuff.StartUncuffSound, target, user);
_audio.PlayPredicted(isOwner ? cuff.Comp.StartBreakoutSound : cuff.Comp.StartUncuffSound, target, user);
}
public void Uncuff(EntityUid target, EntityUid? user, EntityUid cuffsToRemove, CuffableComponent? cuffable = null, HandcuffComponent? cuff = null)
@@ -818,9 +808,56 @@ namespace Content.Shared.Cuffs
#endregion
public IReadOnlyList<EntityUid> GetAllCuffs(CuffableComponent component)
/// <summary>
/// Tries to get a list of all the handcuffs stored in an entity's <see cref="CuffableComponent"/>.
/// </summary>
/// <param name="entity">The cuffable entity in question.</param>
/// <param name="cuffs">A list of cuffs if it exists.</param>
/// <returns>True if a list of cuffs with cuffs exists. False if no list exists or if it is empty.</returns>
public bool TryGetAllCuffs(Entity<CuffableComponent?> entity, out IReadOnlyList<EntityUid> cuffs)
{
return component.Container.ContainedEntities;
cuffs = GetAllCuffs(entity);
return cuffs.Count > 0;
}
/// <summary>
/// Tries to get a list of all the handcuffs stored in a entity's <see cref="CuffableComponent"/>.
/// </summary>
/// <param name="entity">The cuffable entity in question.</param>
/// <returns>A list of cuffs if it exists, or null if there are no cuffs.</returns>
public IReadOnlyList<EntityUid> GetAllCuffs(Entity<CuffableComponent?> entity)
{
if (!Resolve(entity, ref entity.Comp))
return [];
return entity.Comp.Container.ContainedEntities;
}
/// <summary>
/// Tries to get the most recently added pair of handcuffs added to an entity with <see cref="CuffableComponent"/>.
/// </summary>
/// <param name="entity">The cuffable entity in question.</param>
/// <param name="cuff">The most recently added cuff.</param>
/// <returns>Returns true if a cuff exists and false if one doesn't.</returns>
public bool TryGetLastCuff(Entity<CuffableComponent?> entity, [NotNullWhen(true)] out EntityUid? cuff)
{
cuff = GetLastCuffOrNull(entity);
return cuff != null;
}
/// <summary>
/// Tries to get the most recently added pair of handcuffs added to an entity with <see cref="CuffableComponent"/>.
/// </summary>
/// <param name="entity">The cuffable entity in question.</param>
/// <returns>The most recently added cuff or null if none exists.</returns>
public EntityUid? GetLastCuffOrNull(Entity<CuffableComponent?> entity)
{
if (!Resolve(entity, ref entity.Comp))
return null;
return entity.Comp.Container.ContainedEntities.Count == 0 ? null : entity.Comp.Container.ContainedEntities.Last();
}
}

View File

@@ -1,3 +1,4 @@
using Content.Shared.Damage.Systems;
using Robust.Shared.GameStates;
namespace Content.Shared.Damage.Components;

View File

@@ -1,4 +1,5 @@
using Content.Shared.Damage.Prototypes;
using Content.Shared.Damage.Systems;
using Content.Shared.FixedPoint;
using Content.Shared.Mobs;
using Content.Shared.StatusIcon;
@@ -6,105 +7,100 @@ using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
namespace Content.Shared.Damage
namespace Content.Shared.Damage.Components;
/// <summary>
/// Component that allows entities to take damage.
/// </summary>
/// <remarks>
/// The supported damage types are specified using a <see cref="DamageContainerPrototype"/>s. DamageContainers
/// may also have resistances to certain damage types, defined via a <see cref="DamageModifierSetPrototype"/>.
/// </remarks>
[RegisterComponent]
[NetworkedComponent]
[Access(typeof(DamageableSystem), Other = AccessPermissions.ReadExecute)]
public sealed partial class DamageableComponent : Component
{
/// <summary>
/// Component that allows entities to take damage.
/// This <see cref="DamageContainerPrototype"/> specifies what damage types are supported by this component.
/// If null, all damage types will be supported.
/// </summary>
[DataField("damageContainer")]
// ReSharper disable once InconsistentNaming - This is wrong but fixing it is potentially annoying for downstreams.
public ProtoId<DamageContainerPrototype>? DamageContainerID;
/// <summary>
/// This <see cref="DamageModifierSetPrototype"/> will be applied to any damage that is dealt to this container,
/// unless the damage explicitly ignores resistances.
/// </summary>
/// <remarks>
/// The supported damage types are specified using a <see cref="DamageContainerPrototype"/>s. DamageContainers
/// may also have resistances to certain damage types, defined via a <see cref="DamageModifierSetPrototype"/>.
/// Though DamageModifierSets can be deserialized directly, we only want to use the prototype version here
/// to reduce duplication.
/// </remarks>
[RegisterComponent]
[NetworkedComponent]
[Access(typeof(DamageableSystem), Other = AccessPermissions.ReadExecute)]
public sealed partial class DamageableComponent : Component
[DataField("damageModifierSet")]
public ProtoId<DamageModifierSetPrototype>? DamageModifierSetId;
/// <summary>
/// All the damage information is stored in this <see cref="DamageSpecifier"/>.
/// </summary>
/// <remarks>
/// If this data-field is specified, this allows damageable components to be initialized with non-zero damage.
/// </remarks>
[DataField(readOnly: true)] //TODO FULL GAME SAVE
public DamageSpecifier Damage = new();
/// <summary>
/// Damage, indexed by <see cref="DamageGroupPrototype"/> ID keys.
/// </summary>
/// <remarks>
/// Groups which have no members that are supported by this component will not be present in this
/// dictionary.
/// </remarks>
[ViewVariables] public Dictionary<string, FixedPoint2> DamagePerGroup = new();
/// <summary>
/// The sum of all damages in the DamageableComponent.
/// </summary>
[ViewVariables]
public FixedPoint2 TotalDamage;
[DataField("radiationDamageTypes")]
// ReSharper disable once UseCollectionExpression - Cannot refactor this as it's a potential sandbox violation.
public List<ProtoId<DamageTypePrototype>> RadiationDamageTypeIDs = new() { "Radiation" };
/// <summary>
/// Group types that affect the pain overlay.
/// </summary>
/// TODO: Add support for adding damage types specifically rather than whole damage groups
[DataField]
// ReSharper disable once UseCollectionExpression - Cannot refactor this as it's a potential sandbox volation.
public List<ProtoId<DamageGroupPrototype>> PainDamageGroups = new() { "Brute", "Burn" };
[DataField]
public Dictionary<MobState, ProtoId<HealthIconPrototype>> HealthIcons = new()
{
/// <summary>
/// This <see cref="DamageContainerPrototype"/> specifies what damage types are supported by this component.
/// If null, all damage types will be supported.
/// </summary>
[DataField("damageContainer")]
public ProtoId<DamageContainerPrototype>? DamageContainerID;
{ MobState.Alive, "HealthIconFine" },
{ MobState.Critical, "HealthIconCritical" },
{ MobState.Dead, "HealthIconDead" },
};
/// <summary>
/// This <see cref="DamageModifierSetPrototype"/> will be applied to any damage that is dealt to this container,
/// unless the damage explicitly ignores resistances.
/// </summary>
/// <remarks>
/// Though DamageModifierSets can be deserialized directly, we only want to use the prototype version here
/// to reduce duplication.
/// </remarks>
[DataField("damageModifierSet")]
public ProtoId<DamageModifierSetPrototype>? DamageModifierSetId;
[DataField]
public ProtoId<HealthIconPrototype> RottingIcon = "HealthIconRotting";
/// <summary>
/// All the damage information is stored in this <see cref="DamageSpecifier"/>.
/// </summary>
/// <remarks>
/// If this data-field is specified, this allows damageable components to be initialized with non-zero damage.
/// </remarks>
[DataField(readOnly: true)] // TODO FULL GAME SAVE
public DamageSpecifier Damage = new();
/// <summary>
/// Damage, indexed by <see cref="DamageGroupPrototype"/> ID keys.
/// </summary>
/// <remarks>
/// Groups which have no members that are supported by this component will not be present in this
/// dictionary.
/// </remarks>
[ViewVariables] public Dictionary<string, FixedPoint2> DamagePerGroup = new();
/// <summary>
/// The sum of all damages in the DamageableComponent.
/// </summary>
[ViewVariables]
public FixedPoint2 TotalDamage;
[DataField("radiationDamageTypes")]
public List<ProtoId<DamageTypePrototype>> RadiationDamageTypeIDs = new() { "Radiation" };
/// <summary>
/// Group types that affect the pain overlay.
/// </summary>
/// TODO: Add support for adding damage types specifically rather than whole damage groups
[DataField]
public List<ProtoId<DamageGroupPrototype>> PainDamageGroups = new() { "Brute", "Burn" };
[DataField]
public Dictionary<MobState, ProtoId<HealthIconPrototype>> HealthIcons = new()
{
{ MobState.Alive, "HealthIconFine" },
{ MobState.Critical, "HealthIconCritical" },
{ MobState.Dead, "HealthIconDead" },
};
[DataField]
public ProtoId<HealthIconPrototype> RottingIcon = "HealthIconRotting";
[DataField]
public FixedPoint2? HealthBarThreshold;
}
[Serializable, NetSerializable]
public sealed class DamageableComponentState : ComponentState
{
public readonly Dictionary<string, FixedPoint2> DamageDict;
public readonly string? DamageContainerId;
public readonly string? ModifierSetId;
public readonly FixedPoint2? HealthBarThreshold;
public DamageableComponentState(
Dictionary<string, FixedPoint2> damageDict,
string? damageContainerId,
string? modifierSetId,
FixedPoint2? healthBarThreshold)
{
DamageDict = damageDict;
DamageContainerId = damageContainerId;
ModifierSetId = modifierSetId;
HealthBarThreshold = healthBarThreshold;
}
}
[DataField]
public FixedPoint2? HealthBarThreshold;
}
[Serializable, NetSerializable]
public sealed class DamageableComponentState(
Dictionary<string, FixedPoint2> damageDict,
ProtoId<DamageContainerPrototype>? damageContainerId,
ProtoId<DamageModifierSetPrototype>? modifierSetId,
FixedPoint2? healthBarThreshold)
: ComponentState
{
public readonly Dictionary<string, FixedPoint2> DamageDict = damageDict;
public readonly ProtoId<DamageContainerPrototype>? DamageContainerId = damageContainerId;
public readonly ProtoId<DamageModifierSetPrototype>? ModifierSetId = modifierSetId;
public readonly FixedPoint2? HealthBarThreshold = healthBarThreshold;
}

View File

@@ -1,3 +1,4 @@
using Content.Shared.Damage.Systems;
using Robust.Shared.GameStates;
namespace Content.Shared.Damage.Components;

View File

@@ -1,3 +1,4 @@
using Content.Shared.Damage.Systems;
using Robust.Shared.GameStates;
namespace Content.Shared.Damage.Components;

View File

@@ -1,3 +1,4 @@
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Prototypes;
using Robust.Shared.Serialization;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Dictionary;

View File

@@ -1,5 +1,6 @@
using System.Linq;
using System.Text.Json.Serialization;
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Prototypes;
using Content.Shared.FixedPoint;
using JetBrains.Annotations;

View File

@@ -1,5 +1,5 @@
using Content.Shared.Damage.Components;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
namespace Content.Shared.Damage.Prototypes
{

View File

@@ -1,5 +1,5 @@
using Content.Shared.Damage.Components;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
namespace Content.Shared.Damage.Prototypes
{

View File

@@ -73,9 +73,9 @@ public sealed class DamageOnAttackedSystem : EntitySystem
}
}
totalDamage = _damageableSystem.TryChangeDamage(args.User, totalDamage, entity.Comp.IgnoreResistances, origin: entity);
totalDamage = _damageableSystem.ChangeDamage(args.User, totalDamage, entity.Comp.IgnoreResistances, origin: entity);
if (totalDamage != null && totalDamage.AnyPositive())
if (totalDamage.AnyPositive())
{
_adminLogger.Add(LogType.Damaged, $"{ToPrettyString(args.User):user} injured themselves by attacking {ToPrettyString(entity):target} and received {totalDamage.GetTotal():damage} damage");
_audioSystem.PlayPredicted(entity.Comp.InteractSound, entity, args.User);

View File

@@ -65,7 +65,7 @@ public sealed class DamageOnInteractSystem : EntitySystem
// or checking the entity for the comp itself if the inventory didn't work
if (protectiveEntity.Comp == null && TryComp<DamageOnInteractProtectionComponent>(args.User, out var protectiveComp))
protectiveEntity = (args.User, protectiveComp);
// if protectiveComp isn't null after all that, it means the user has protection,
// so let's calculate how much they resist
@@ -75,9 +75,9 @@ public sealed class DamageOnInteractSystem : EntitySystem
}
}
totalDamage = _damageableSystem.TryChangeDamage(args.User, totalDamage, origin: args.Target);
totalDamage = _damageableSystem.ChangeDamage(args.User, totalDamage, origin: args.Target);
if (totalDamage != null && totalDamage.AnyPositive())
if (totalDamage.AnyPositive())
{
// Record this interaction and determine when a user is allowed to interact with this entity again
entity.Comp.LastInteraction = _gameTiming.CurTime;

View File

@@ -0,0 +1,235 @@
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Prototypes;
using Content.Shared.FixedPoint;
using Robust.Shared.Prototypes;
namespace Content.Shared.Damage.Systems;
public sealed partial class DamageableSystem
{
/// <summary>
/// Directly sets the damage specifier of a damageable component.
/// </summary>
/// <remarks>
/// Useful for some unfriendly folk. Also ensures that cached values are updated and that a damage changed
/// event is raised.
/// </remarks>
public void SetDamage(Entity<DamageableComponent?> ent, DamageSpecifier damage)
{
if (!_damageableQuery.Resolve(ent, ref ent.Comp, false))
return;
ent.Comp.Damage = damage;
OnEntityDamageChanged((ent, ent.Comp));
}
/// <summary>
/// Applies damage specified via a <see cref="DamageSpecifier"/>.
/// </summary>
/// <remarks>
/// <see cref="DamageSpecifier"/> is effectively just a dictionary of damage types and damage values. This
/// function just applies the container's resistances (unless otherwise specified) and then changes the
/// stored damage data. Division of group damage into types is managed by <see cref="DamageSpecifier"/>.
/// </remarks>
/// <returns>
/// If the attempt was successful or not.
/// </returns>
public bool TryChangeDamage(
Entity<DamageableComponent?> ent,
DamageSpecifier damage,
bool ignoreResistances = false,
bool interruptsDoAfters = true,
EntityUid? origin = null,
bool ignoreGlobalModifiers = false
)
{
//! Empty just checks if the DamageSpecifier is _literally_ empty, as in, is internal dictionary of damage types is empty.
// If you deal 0.0 of some damage type, Empty will be false!
return !TryChangeDamage(ent, damage, out _, ignoreResistances, interruptsDoAfters, origin, ignoreGlobalModifiers);
}
/// <summary>
/// Applies damage specified via a <see cref="DamageSpecifier"/>.
/// </summary>
/// <remarks>
/// <see cref="DamageSpecifier"/> is effectively just a dictionary of damage types and damage values. This
/// function just applies the container's resistances (unless otherwise specified) and then changes the
/// stored damage data. Division of group damage into types is managed by <see cref="DamageSpecifier"/>.
/// </remarks>
/// <returns>
/// If the attempt was successful or not.
/// </returns>
public bool TryChangeDamage(
Entity<DamageableComponent?> ent,
DamageSpecifier damage,
out DamageSpecifier newDamage,
bool ignoreResistances = false,
bool interruptsDoAfters = true,
EntityUid? origin = null,
bool ignoreGlobalModifiers = false
)
{
//! Empty just checks if the DamageSpecifier is _literally_ empty, as in, is internal dictionary of damage types is empty.
// If you deal 0.0 of some damage type, Empty will be false!
newDamage = ChangeDamage(ent, damage, ignoreResistances, interruptsDoAfters, origin, ignoreGlobalModifiers);
return !damage.Empty;
}
/// <summary>
/// Applies damage specified via a <see cref="DamageSpecifier"/>.
/// </summary>
/// <remarks>
/// <see cref="DamageSpecifier"/> is effectively just a dictionary of damage types and damage values. This
/// function just applies the container's resistances (unless otherwise specified) and then changes the
/// stored damage data. Division of group damage into types is managed by <see cref="DamageSpecifier"/>.
/// </remarks>
/// <returns>
/// The actual amount of damage taken, as a DamageSpecifier.
/// </returns>
public DamageSpecifier ChangeDamage(
Entity<DamageableComponent?> ent,
DamageSpecifier damage,
bool ignoreResistances = false,
bool interruptsDoAfters = true,
EntityUid? origin = null,
bool ignoreGlobalModifiers = false
)
{
var damageDone = new DamageSpecifier();
if (!_damageableQuery.Resolve(ent, ref ent.Comp, false))
return damageDone;
if (damage.Empty)
return damageDone;
var before = new BeforeDamageChangedEvent(damage, origin);
RaiseLocalEvent(ent, ref before);
if (before.Cancelled)
return damageDone;
// Apply resistances
if (!ignoreResistances)
{
if (
ent.Comp.DamageModifierSetId != null &&
_prototypeManager.Resolve(ent.Comp.DamageModifierSetId, out var modifierSet)
)
damage = DamageSpecifier.ApplyModifierSet(damage, modifierSet);
// TODO DAMAGE
// byref struct event.
var ev = new DamageModifyEvent(damage, origin);
RaiseLocalEvent(ent, ev);
damage = ev.Damage;
if (damage.Empty)
return damageDone;
}
if (!ignoreGlobalModifiers)
damage = ApplyUniversalAllModifiers(damage);
damageDone.DamageDict.EnsureCapacity(damage.DamageDict.Count);
var dict = ent.Comp.Damage.DamageDict;
foreach (var (type, value) in damage.DamageDict)
{
// CollectionsMarshal my beloved.
if (!dict.TryGetValue(type, out var oldValue))
continue;
var newValue = FixedPoint2.Max(FixedPoint2.Zero, oldValue + value);
if (newValue == oldValue)
continue;
dict[type] = newValue;
damageDone.DamageDict[type] = newValue - oldValue;
}
if (!damageDone.Empty)
OnEntityDamageChanged((ent, ent.Comp), damageDone, interruptsDoAfters, origin);
return damageDone;
}
/// <summary>
/// Applies the two universal "All" modifiers, if set.
/// Individual damage source modifiers are set in their respective code.
/// </summary>
/// <param name="damage">The damage to be changed.</param>
public DamageSpecifier ApplyUniversalAllModifiers(DamageSpecifier damage)
{
// Checks for changes first since they're unlikely in normal play.
if (
MathHelper.CloseToPercent(UniversalAllDamageModifier, 1f) &&
MathHelper.CloseToPercent(UniversalAllHealModifier, 1f)
)
return damage;
foreach (var (key, value) in damage.DamageDict)
{
if (value == 0)
continue;
if (value > 0)
{
damage.DamageDict[key] *= UniversalAllDamageModifier;
continue;
}
if (value < 0)
damage.DamageDict[key] *= UniversalAllHealModifier;
}
return damage;
}
public void ClearAllDamage(Entity<DamageableComponent?> ent)
{
SetAllDamage(ent, FixedPoint2.Zero);
}
/// <summary>
/// Sets all damage types supported by a <see cref="Components.DamageableComponent"/> to the specified value.
/// </summary>
/// <remarks>
/// Does nothing If the given damage value is negative.
/// </remarks>
public void SetAllDamage(Entity<DamageableComponent?> ent, FixedPoint2 newValue)
{
if (!_damageableQuery.Resolve(ent, ref ent.Comp, false))
return;
if (newValue < 0)
return;
foreach (var type in ent.Comp.Damage.DamageDict.Keys)
{
ent.Comp.Damage.DamageDict[type] = newValue;
}
// Setting damage does not count as 'dealing' damage, even if it is set to a larger value, so we pass an
// empty damage delta.
OnEntityDamageChanged((ent, ent.Comp), new DamageSpecifier());
}
/// <summary>
/// Set's the damage modifier set prototype for this entity.
/// </summary>
/// <param name="ent">The entity we're setting the modifier set of.</param>
/// <param name="damageModifierSetId">The prototype we're setting.</param>
public void SetDamageModifierSetId(Entity<DamageableComponent?> ent, ProtoId<DamageModifierSetPrototype>? damageModifierSetId)
{
if (!_damageableQuery.Resolve(ent, ref ent.Comp, false))
return;
ent.Comp.DamageModifierSetId = damageModifierSetId;
Dirty(ent);
}
}

View File

@@ -0,0 +1,17 @@
using Content.Shared.Damage.Components;
namespace Content.Shared.Damage.Systems;
public sealed partial class DamageableSystem
{
/// <summary>
/// Applies damage to all entities to see how expensive it is to deal damage.
/// </summary>
public void ApplyDamageToAllEntities(List<Entity<DamageableComponent>> damageables, DamageSpecifier damage)
{
foreach (var (uid, damageable) in damageables)
{
TryChangeDamage((uid, damageable), damage);
}
}
}

View File

@@ -0,0 +1,290 @@
using Content.Shared.CCVar;
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Prototypes;
using Content.Shared.FixedPoint;
using Content.Shared.Inventory;
using Content.Shared.Radiation.Events;
using Content.Shared.Rejuvenate;
using Robust.Shared.GameStates;
namespace Content.Shared.Damage.Systems;
public sealed partial class DamageableSystem
{
public override void Initialize()
{
SubscribeLocalEvent<DamageableComponent, ComponentInit>(DamageableInit);
SubscribeLocalEvent<DamageableComponent, ComponentHandleState>(DamageableHandleState);
SubscribeLocalEvent<DamageableComponent, ComponentGetState>(DamageableGetState);
SubscribeLocalEvent<DamageableComponent, OnIrradiatedEvent>(OnIrradiated);
SubscribeLocalEvent<DamageableComponent, RejuvenateEvent>(OnRejuvenate);
_appearanceQuery = GetEntityQuery<AppearanceComponent>();
_damageableQuery = GetEntityQuery<DamageableComponent>();
// Damage modifier CVars are updated and stored here to be queried in other systems.
// Note that certain modifiers requires reloading the guidebook.
Subs.CVar(
_config,
CCVars.PlaytestAllDamageModifier,
value =>
{
UniversalAllDamageModifier = value;
_chemistryGuideData.ReloadAllReagentPrototypes();
_explosion.ReloadMap();
},
true
);
Subs.CVar(
_config,
CCVars.PlaytestAllHealModifier,
value =>
{
UniversalAllHealModifier = value;
_chemistryGuideData.ReloadAllReagentPrototypes();
},
true
);
Subs.CVar(
_config,
CCVars.PlaytestProjectileDamageModifier,
value => UniversalProjectileDamageModifier = value,
true
);
Subs.CVar(
_config,
CCVars.PlaytestMeleeDamageModifier,
value => UniversalMeleeDamageModifier = value,
true
);
Subs.CVar(
_config,
CCVars.PlaytestProjectileDamageModifier,
value => UniversalProjectileDamageModifier = value,
true
);
Subs.CVar(
_config,
CCVars.PlaytestHitscanDamageModifier,
value => UniversalHitscanDamageModifier = value,
true
);
Subs.CVar(
_config,
CCVars.PlaytestReagentDamageModifier,
value =>
{
UniversalReagentDamageModifier = value;
_chemistryGuideData.ReloadAllReagentPrototypes();
},
true
);
Subs.CVar(
_config,
CCVars.PlaytestReagentHealModifier,
value =>
{
UniversalReagentHealModifier = value;
_chemistryGuideData.ReloadAllReagentPrototypes();
},
true
);
Subs.CVar(
_config,
CCVars.PlaytestExplosionDamageModifier,
value =>
{
UniversalExplosionDamageModifier = value;
_explosion.ReloadMap();
},
true
);
Subs.CVar(
_config,
CCVars.PlaytestThrownDamageModifier,
value => UniversalThrownDamageModifier = value,
true
);
Subs.CVar(
_config,
CCVars.PlaytestTopicalsHealModifier,
value => UniversalTopicalsHealModifier = value,
true
);
Subs.CVar(
_config,
CCVars.PlaytestMobDamageModifier,
value => UniversalMobDamageModifier = value,
true
);
}
/// <summary>
/// Initialize a damageable component
/// </summary>
private void DamageableInit(Entity<DamageableComponent> ent, ref ComponentInit _)
{
if (
ent.Comp.DamageContainerID is null ||
!_prototypeManager.Resolve(ent.Comp.DamageContainerID, out var damageContainerPrototype)
)
{
// No DamageContainerPrototype was given. So we will allow the container to support all damage types
foreach (var type in _prototypeManager.EnumeratePrototypes<DamageTypePrototype>())
{
ent.Comp.Damage.DamageDict.TryAdd(type.ID, FixedPoint2.Zero);
}
}
else
{
// Initialize damage dictionary, using the types and groups from the damage
// container prototype
foreach (var type in damageContainerPrototype.SupportedTypes)
{
ent.Comp.Damage.DamageDict.TryAdd(type, FixedPoint2.Zero);
}
foreach (var groupId in damageContainerPrototype.SupportedGroups)
{
var group = _prototypeManager.Index(groupId);
foreach (var type in group.DamageTypes)
{
ent.Comp.Damage.DamageDict.TryAdd(type, FixedPoint2.Zero);
}
}
}
ent.Comp.Damage.GetDamagePerGroup(_prototypeManager, ent.Comp.DamagePerGroup);
ent.Comp.TotalDamage = ent.Comp.Damage.GetTotal();
}
private void OnIrradiated(Entity<DamageableComponent> ent, ref OnIrradiatedEvent args)
{
var damageValue = FixedPoint2.New(args.TotalRads);
// Radiation should really just be a damage group instead of a list of types.
DamageSpecifier damage = new();
foreach (var typeId in ent.Comp.RadiationDamageTypeIDs)
{
damage.DamageDict.Add(typeId, damageValue);
}
ChangeDamage(ent.Owner, damage, interruptsDoAfters: false, origin: args.Origin);
}
private void OnRejuvenate(Entity<DamageableComponent> ent, ref RejuvenateEvent args)
{
// Do this so that the state changes when we set the damage
_mobThreshold.SetAllowRevives(ent, true);
ClearAllDamage(ent.AsNullable());
_mobThreshold.SetAllowRevives(ent, false);
}
private void DamageableHandleState(Entity<DamageableComponent> ent, ref ComponentHandleState args)
{
if (args.Current is not DamageableComponentState state)
return;
ent.Comp.DamageContainerID = state.DamageContainerId;
ent.Comp.DamageModifierSetId = state.ModifierSetId;
ent.Comp.HealthBarThreshold = state.HealthBarThreshold;
// Has the damage actually changed?
DamageSpecifier newDamage = new() { DamageDict = new Dictionary<string, FixedPoint2>(state.DamageDict) };
var delta = newDamage - ent.Comp.Damage;
delta.TrimZeros();
if (delta.Empty)
return;
ent.Comp.Damage = newDamage;
OnEntityDamageChanged(ent, delta);
}
}
/// <summary>
/// Raised before damage is done, so stuff can cancel it if necessary.
/// </summary>
[ByRefEvent]
public record struct BeforeDamageChangedEvent(DamageSpecifier Damage, EntityUid? Origin = null, bool Cancelled = false);
/// <summary>
/// Raised on an entity when damage is about to be dealt,
/// in case anything else needs to modify it other than the base
/// damageable component.
///
/// For example, armor.
/// </summary>
public sealed class DamageModifyEvent(DamageSpecifier damage, EntityUid? origin = null)
: EntityEventArgs, IInventoryRelayEvent
{
// Whenever locational damage is a thing, this should just check only that bit of armour.
public SlotFlags TargetSlots => ~SlotFlags.POCKET;
public readonly DamageSpecifier OriginalDamage = damage;
public DamageSpecifier Damage = damage;
}
public sealed class DamageChangedEvent : EntityEventArgs
{
/// <summary>
/// This is the component whose damage was changed.
/// </summary>
/// <remarks>
/// Given that nearly every component that cares about a change in the damage, needs to know the
/// current damage values, directly passing this information prevents a lot of duplicate
/// Owner.TryGetComponent() calls.
/// </remarks>
public readonly DamageableComponent Damageable;
/// <summary>
/// The amount by which the damage has changed. If the damage was set directly to some number, this will be
/// null.
/// </summary>
public readonly DamageSpecifier? DamageDelta;
/// <summary>
/// Was any of the damage change dealing damage, or was it all healing?
/// </summary>
public readonly bool DamageIncreased;
/// <summary>
/// Does this event interrupt DoAfters?
/// Note: As provided in the constructor, this *does not* account for DamageIncreased.
/// As written into the event, this *does* account for DamageIncreased.
/// </summary>
public readonly bool InterruptsDoAfters;
/// <summary>
/// Contains the entity which caused the change in damage, if any was responsible.
/// </summary>
public readonly EntityUid? Origin;
public DamageChangedEvent(
DamageableComponent damageable,
DamageSpecifier? damageDelta,
bool interruptsDoAfters,
EntityUid? origin
)
{
Damageable = damageable;
DamageDelta = damageDelta;
Origin = origin;
if (DamageDelta is null)
return;
foreach (var damageChange in DamageDelta.DamageDict.Values)
{
if (damageChange <= 0)
continue;
DamageIncreased = true;
break;
}
InterruptsDoAfters = interruptsDoAfters && DamageIncreased;
}
}

View File

@@ -1,484 +1,97 @@
using System.Linq;
using Content.Shared.CCVar;
using Content.Shared.Chemistry;
using Content.Shared.Damage.Prototypes;
using Content.Shared.Damage.Components;
using Content.Shared.Explosion.EntitySystems;
using Content.Shared.FixedPoint;
using Content.Shared.Inventory;
using Content.Shared.Mobs.Components;
using Content.Shared.Mobs.Systems;
using Content.Shared.Radiation.Events;
using Content.Shared.Rejuvenate;
using Robust.Shared.Configuration;
using Robust.Shared.GameStates;
using Robust.Shared.Network;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
namespace Content.Shared.Damage
namespace Content.Shared.Damage.Systems;
public sealed partial class DamageableSystem : EntitySystem
{
public sealed class DamageableSystem : EntitySystem
{
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly INetManager _netMan = default!;
[Dependency] private readonly MobThresholdSystem _mobThreshold = default!;
[Dependency] private readonly IConfigurationManager _config = default!;
[Dependency] private readonly SharedChemistryGuideDataSystem _chemistryGuideData = default!;
[Dependency] private readonly SharedExplosionSystem _explosion = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly INetManager _netMan = default!;
[Dependency] private readonly MobThresholdSystem _mobThreshold = default!;
[Dependency] private readonly IConfigurationManager _config = default!;
[Dependency] private readonly SharedChemistryGuideDataSystem _chemistryGuideData = default!;
[Dependency] private readonly SharedExplosionSystem _explosion = default!;
private EntityQuery<AppearanceComponent> _appearanceQuery;
private EntityQuery<DamageableComponent> _damageableQuery;
private EntityQuery<AppearanceComponent> _appearanceQuery;
private EntityQuery<DamageableComponent> _damageableQuery;
public float UniversalAllDamageModifier { get; private set; } = 1f;
public float UniversalAllHealModifier { get; private set; } = 1f;
public float UniversalMeleeDamageModifier { get; private set; } = 1f;
public float UniversalProjectileDamageModifier { get; private set; } = 1f;
public float UniversalHitscanDamageModifier { get; private set; } = 1f;
public float UniversalReagentDamageModifier { get; private set; } = 1f;
public float UniversalReagentHealModifier { get; private set; } = 1f;
public float UniversalExplosionDamageModifier { get; private set; } = 1f;
public float UniversalThrownDamageModifier { get; private set; } = 1f;
public float UniversalTopicalsHealModifier { get; private set; } = 1f;
public float UniversalMobDamageModifier { get; private set; } = 1f;
public override void Initialize()
{
SubscribeLocalEvent<DamageableComponent, ComponentInit>(DamageableInit);
SubscribeLocalEvent<DamageableComponent, ComponentHandleState>(DamageableHandleState);
SubscribeLocalEvent<DamageableComponent, ComponentGetState>(DamageableGetState);
SubscribeLocalEvent<DamageableComponent, OnIrradiatedEvent>(OnIrradiated);
SubscribeLocalEvent<DamageableComponent, RejuvenateEvent>(OnRejuvenate, after: [typeof(Content.Shared.StatusEffectNew.StatusEffectsSystem)]); // Offbrand
_appearanceQuery = GetEntityQuery<AppearanceComponent>();
_damageableQuery = GetEntityQuery<DamageableComponent>();
// Damage modifier CVars are updated and stored here to be queried in other systems.
// Note that certain modifiers requires reloading the guidebook.
Subs.CVar(_config, CCVars.PlaytestAllDamageModifier, value =>
{
UniversalAllDamageModifier = value;
_chemistryGuideData.ReloadAllReagentPrototypes();
_explosion.ReloadMap();
}, true);
Subs.CVar(_config, CCVars.PlaytestAllHealModifier, value =>
{
UniversalAllHealModifier = value;
_chemistryGuideData.ReloadAllReagentPrototypes();
}, true);
Subs.CVar(_config, CCVars.PlaytestProjectileDamageModifier, value => UniversalProjectileDamageModifier = value, true);
Subs.CVar(_config, CCVars.PlaytestMeleeDamageModifier, value => UniversalMeleeDamageModifier = value, true);
Subs.CVar(_config, CCVars.PlaytestProjectileDamageModifier, value => UniversalProjectileDamageModifier = value, true);
Subs.CVar(_config, CCVars.PlaytestHitscanDamageModifier, value => UniversalHitscanDamageModifier = value, true);
Subs.CVar(_config, CCVars.PlaytestReagentDamageModifier, value =>
{
UniversalReagentDamageModifier = value;
_chemistryGuideData.ReloadAllReagentPrototypes();
}, true);
Subs.CVar(_config, CCVars.PlaytestReagentHealModifier, value =>
{
UniversalReagentHealModifier = value;
_chemistryGuideData.ReloadAllReagentPrototypes();
}, true);
Subs.CVar(_config, CCVars.PlaytestExplosionDamageModifier, value =>
{
UniversalExplosionDamageModifier = value;
_explosion.ReloadMap();
}, true);
Subs.CVar(_config, CCVars.PlaytestThrownDamageModifier, value => UniversalThrownDamageModifier = value, true);
Subs.CVar(_config, CCVars.PlaytestTopicalsHealModifier, value => UniversalTopicalsHealModifier = value, true);
Subs.CVar(_config, CCVars.PlaytestMobDamageModifier, value => UniversalMobDamageModifier = value, true);
}
/// <summary>
/// Initialize a damageable component
/// </summary>
private void DamageableInit(EntityUid uid, DamageableComponent component, ComponentInit _)
{
if (component.DamageContainerID != null &&
_prototypeManager.Resolve<DamageContainerPrototype>(component.DamageContainerID,
out var damageContainerPrototype))
{
// Initialize damage dictionary, using the types and groups from the damage
// container prototype
foreach (var type in damageContainerPrototype.SupportedTypes)
{
component.Damage.DamageDict.TryAdd(type, FixedPoint2.Zero);
}
foreach (var groupId in damageContainerPrototype.SupportedGroups)
{
var group = _prototypeManager.Index<DamageGroupPrototype>(groupId);
foreach (var type in group.DamageTypes)
{
component.Damage.DamageDict.TryAdd(type, FixedPoint2.Zero);
}
}
}
else
{
// No DamageContainerPrototype was given. So we will allow the container to support all damage types
foreach (var type in _prototypeManager.EnumeratePrototypes<DamageTypePrototype>())
{
component.Damage.DamageDict.TryAdd(type.ID, FixedPoint2.Zero);
}
}
component.Damage.GetDamagePerGroup(_prototypeManager, component.DamagePerGroup);
component.TotalDamage = component.Damage.GetTotal();
}
/// <summary>
/// Directly sets the damage specifier of a damageable component.
/// </summary>
/// <remarks>
/// Useful for some unfriendly folk. Also ensures that cached values are updated and that a damage changed
/// event is raised.
/// </remarks>
public void SetDamage(EntityUid uid, DamageableComponent damageable, DamageSpecifier damage)
{
damageable.Damage = damage;
DamageChanged(uid, damageable);
}
/// <summary>
/// If the damage in a DamageableComponent was changed, this function should be called.
/// </summary>
/// <remarks>
/// This updates cached damage information, flags the component as dirty, and raises a damage changed event.
/// The damage changed event is used by other systems, such as damage thresholds.
/// </remarks>
public void DamageChanged(EntityUid uid, DamageableComponent component, DamageSpecifier? damageDelta = null,
bool interruptsDoAfters = true, EntityUid? origin = null, bool forcedRefresh = false) // Offbrand
{
component.Damage.GetDamagePerGroup(_prototypeManager, component.DamagePerGroup);
component.TotalDamage = component.Damage.GetTotal();
Dirty(uid, component);
if (_appearanceQuery.TryGetComponent(uid, out var appearance) && damageDelta != null)
{
var data = new DamageVisualizerGroupData(component.DamagePerGroup.Keys.ToList());
_appearance.SetData(uid, DamageVisualizerKeys.DamageUpdateGroups, data, appearance);
}
// TODO DAMAGE
// byref struct event.
RaiseLocalEvent(uid, new DamageChangedEvent(component, damageDelta, interruptsDoAfters, origin, forcedRefresh)); // Offbrand
}
/// <summary>
/// Applies damage specified via a <see cref="DamageSpecifier"/>.
/// </summary>
/// <remarks>
/// <see cref="DamageSpecifier"/> is effectively just a dictionary of damage types and damage values. This
/// function just applies the container's resistances (unless otherwise specified) and then changes the
/// stored damage data. Division of group damage into types is managed by <see cref="DamageSpecifier"/>.
/// </remarks>
/// <returns>
/// Returns a <see cref="DamageSpecifier"/> with information about the actual damage changes. This will be
/// null if the user had no applicable components that can take damage.
/// </returns>
/// <param name="ignoreResistances">If true, this will ignore the entity's damage modifier (<see cref="DamageableComponent.DamageModifierSetId"/> and skip raising a <see cref="DamageModifyEvent"/>.</param>
/// <param name="interruptsDoAfters">Whether the damage should cancel any damage sensitive do-afters</param>
/// <param name="origin">The entity that is causing this damage</param>
/// <param name="ignoreGlobalModifiers">If true, this will skip over applying the universal damage modifiers (see <see cref="ApplyUniversalAllModifiers"/>).</param>
/// <returns></returns>
public DamageSpecifier? TryChangeDamage(
EntityUid? uid,
DamageSpecifier damage,
bool ignoreResistances = false,
bool interruptsDoAfters = true,
DamageableComponent? damageable = null,
EntityUid? origin = null,
bool ignoreGlobalModifiers = false,
bool forceRefresh = false) // Offbrand
{
if (!uid.HasValue || !_damageableQuery.Resolve(uid.Value, ref damageable, false))
{
// TODO BODY SYSTEM pass damage onto body system
// BOBBY WHEN?
return null;
}
if (damage.Empty && !forceRefresh) // Offbrand
{
return damage;
}
var before = new BeforeDamageChangedEvent(damage, origin);
RaiseLocalEvent(uid.Value, ref before);
if (before.Cancelled)
return null;
// Apply resistances
if (!ignoreResistances)
{
if (damageable.DamageModifierSetId != null &&
_prototypeManager.Resolve(damageable.DamageModifierSetId, out var modifierSet))
{
damage = DamageSpecifier.ApplyModifierSet(damage, modifierSet);
}
// TODO DAMAGE
// byref struct event.
var ev = new DamageModifyEvent(damage, origin);
RaiseLocalEvent(uid.Value, ev);
damage = ev.Damage;
if (damage.Empty)
{
return damage;
}
}
if (!ignoreGlobalModifiers)
damage = ApplyUniversalAllModifiers(damage);
// Begin Offbrand
var beforeCommit = new Content.Shared._Offbrand.Wounds.BeforeDamageCommitEvent(damage, forceRefresh);
RaiseLocalEvent(uid.Value, ref beforeCommit);
damage = beforeCommit.Damage;
// End Offbrand
var delta = new DamageSpecifier();
delta.DamageDict.EnsureCapacity(damage.DamageDict.Count);
var dict = damageable.Damage.DamageDict;
foreach (var (type, value) in damage.DamageDict)
{
// CollectionsMarshal my beloved.
if (!dict.TryGetValue(type, out var oldValue))
continue;
var newValue = FixedPoint2.Max(FixedPoint2.Zero, oldValue + value);
if (newValue == oldValue)
continue;
dict[type] = newValue;
delta.DamageDict[type] = newValue - oldValue;
}
if (delta.DamageDict.Count > 0)
DamageChanged(uid.Value, damageable, delta, interruptsDoAfters, origin, forceRefresh); // Offbrand
return delta;
}
/// <summary>
/// Applies the two univeral "All" modifiers, if set.
/// Individual damage source modifiers are set in their respective code.
/// </summary>
/// <param name="damage">The damage to be changed.</param>
public DamageSpecifier ApplyUniversalAllModifiers(DamageSpecifier damage)
{
// Checks for changes first since they're unlikely in normal play.
if (UniversalAllDamageModifier == 1f && UniversalAllHealModifier == 1f)
return damage;
foreach (var (key, value) in damage.DamageDict)
{
if (value == 0)
continue;
if (value > 0)
{
damage.DamageDict[key] *= UniversalAllDamageModifier;
continue;
}
if (value < 0)
{
damage.DamageDict[key] *= UniversalAllHealModifier;
}
}
return damage;
}
/// <summary>
/// Sets all damage types supported by a <see cref="DamageableComponent"/> to the specified value.
/// </summary>
/// <remakrs>
/// Does nothing If the given damage value is negative.
/// </remakrs>
public void SetAllDamage(EntityUid uid, DamageableComponent component, FixedPoint2 newValue)
{
if (newValue < 0)
{
// invalid value
return;
}
foreach (var type in component.Damage.DamageDict.Keys)
{
component.Damage.DamageDict[type] = newValue;
}
// Setting damage does not count as 'dealing' damage, even if it is set to a larger value, so we pass an
// empty damage delta.
DamageChanged(uid, component, new DamageSpecifier());
}
public void SetDamageModifierSetId(EntityUid uid, string? damageModifierSetId, DamageableComponent? comp = null)
{
if (!_damageableQuery.Resolve(uid, ref comp))
return;
comp.DamageModifierSetId = damageModifierSetId;
Dirty(uid, comp);
}
private void DamageableGetState(EntityUid uid, DamageableComponent component, ref ComponentGetState args)
{
if (_netMan.IsServer)
{
args.State = new DamageableComponentState(component.Damage.DamageDict, component.DamageContainerID, component.DamageModifierSetId, component.HealthBarThreshold);
}
else
{
// avoid mispredicting damage on newly spawned entities.
args.State = new DamageableComponentState(component.Damage.DamageDict.ShallowClone(), component.DamageContainerID, component.DamageModifierSetId, component.HealthBarThreshold);
}
}
private void OnIrradiated(EntityUid uid, DamageableComponent component, OnIrradiatedEvent args)
{
var damageValue = FixedPoint2.New(args.TotalRads);
// Radiation should really just be a damage group instead of a list of types.
DamageSpecifier damage = new();
foreach (var typeId in component.RadiationDamageTypeIDs)
{
damage.DamageDict.Add(typeId, damageValue);
}
TryChangeDamage(uid, damage, interruptsDoAfters: false, origin: args.Origin);
}
private void OnRejuvenate(EntityUid uid, DamageableComponent component, RejuvenateEvent args)
{
Log.Debug("rejuvenate damage");
TryComp<MobThresholdsComponent>(uid, out var thresholds);
_mobThreshold.SetAllowRevives(uid, true, thresholds); // do this so that the state changes when we set the damage
SetAllDamage(uid, component, 0);
_mobThreshold.SetAllowRevives(uid, false, thresholds);
}
private void DamageableHandleState(EntityUid uid, DamageableComponent component, ref ComponentHandleState args)
{
if (args.Current is not DamageableComponentState state)
{
return;
}
component.DamageContainerID = state.DamageContainerId;
component.DamageModifierSetId = state.ModifierSetId;
component.HealthBarThreshold = state.HealthBarThreshold;
// Has the damage actually changed?
DamageSpecifier newDamage = new() { DamageDict = new(state.DamageDict) };
var delta = newDamage - component.Damage;
delta.TrimZeros();
if (!delta.Empty)
{
component.Damage = newDamage;
DamageChanged(uid, component, delta);
}
}
}
public float UniversalAllDamageModifier { get; private set; } = 1f;
public float UniversalAllHealModifier { get; private set; } = 1f;
public float UniversalMeleeDamageModifier { get; private set; } = 1f;
public float UniversalProjectileDamageModifier { get; private set; } = 1f;
public float UniversalHitscanDamageModifier { get; private set; } = 1f;
public float UniversalReagentDamageModifier { get; private set; } = 1f;
public float UniversalReagentHealModifier { get; private set; } = 1f;
public float UniversalExplosionDamageModifier { get; private set; } = 1f;
public float UniversalThrownDamageModifier { get; private set; } = 1f;
public float UniversalTopicalsHealModifier { get; private set; } = 1f;
public float UniversalMobDamageModifier { get; private set; } = 1f;
/// <summary>
/// Raised before damage is done, so stuff can cancel it if necessary.
/// If the damage in a DamageableComponent was changed this function should be called.
/// </summary>
[ByRefEvent]
public record struct BeforeDamageChangedEvent(DamageSpecifier Damage, EntityUid? Origin = null, bool Cancelled = false);
/// <summary>
/// Raised on an entity when damage is about to be dealt,
/// in case anything else needs to modify it other than the base
/// damageable component.
///
/// For example, armor.
/// </summary>
public sealed class DamageModifyEvent : EntityEventArgs, IInventoryRelayEvent
/// <remarks>
/// This updates cached damage information, flags the component as dirty, and raises a damage changed event.
/// The damage changed event is used by other systems, such as damage thresholds.
/// </remarks>
private void OnEntityDamageChanged(
Entity<DamageableComponent> ent,
DamageSpecifier? damageDelta = null,
bool interruptsDoAfters = true,
EntityUid? origin = null
)
{
// Whenever locational damage is a thing, this should just check only that bit of armour.
public SlotFlags TargetSlots { get; } = ~SlotFlags.POCKET;
ent.Comp.Damage.GetDamagePerGroup(_prototypeManager, ent.Comp.DamagePerGroup);
ent.Comp.TotalDamage = ent.Comp.Damage.GetTotal();
Dirty(ent);
public readonly DamageSpecifier OriginalDamage;
public DamageSpecifier Damage;
public EntityUid? Origin;
public DamageModifyEvent(DamageSpecifier damage, EntityUid? origin = null)
if (damageDelta != null && _appearanceQuery.TryGetComponent(ent, out var appearance))
{
OriginalDamage = damage;
Damage = damage;
Origin = origin;
_appearance.SetData(
ent,
DamageVisualizerKeys.DamageUpdateGroups,
new DamageVisualizerGroupData(ent.Comp.DamagePerGroup.Keys.ToList()),
appearance
);
}
// TODO DAMAGE
// byref struct event.
RaiseLocalEvent(ent, new DamageChangedEvent(ent.Comp, damageDelta, interruptsDoAfters, origin));
}
public sealed class DamageChangedEvent : EntityEventArgs
private void DamageableGetState(Entity<DamageableComponent> ent, ref ComponentGetState args)
{
/// <summary>
/// This is the component whose damage was changed.
/// </summary>
/// <remarks>
/// Given that nearly every component that cares about a change in the damage, needs to know the
/// current damage values, directly passing this information prevents a lot of duplicate
/// Owner.TryGetComponent() calls.
/// </remarks>
public readonly DamageableComponent Damageable;
/// <summary>
/// The amount by which the damage has changed. If the damage was set directly to some number, this will be
/// null.
/// </summary>
public readonly DamageSpecifier? DamageDelta;
/// <summary>
/// Was any of the damage change dealing damage, or was it all healing?
/// </summary>
public readonly bool DamageIncreased;
/// <summary>
/// Does this event interrupt DoAfters?
/// Note: As provided in the constructor, this *does not* account for DamageIncreased.
/// As written into the event, this *does* account for DamageIncreased.
/// </summary>
public readonly bool InterruptsDoAfters;
/// <summary>
/// Contains the entity which caused the change in damage, if any was responsible.
/// </summary>
public readonly EntityUid? Origin;
// Offbrand
/// <summary>
/// If this damage changed happened as part of a forced refresh
/// </summary>
public readonly bool ForcedRefresh;
public DamageChangedEvent(DamageableComponent damageable, DamageSpecifier? damageDelta, bool interruptsDoAfters, EntityUid? origin, bool forcedRefresh) // Offbrand
if (_netMan.IsServer)
{
Damageable = damageable;
DamageDelta = damageDelta;
Origin = origin;
ForcedRefresh = forcedRefresh; // Offbrand
args.State = new DamageableComponentState(
ent.Comp.Damage.DamageDict,
ent.Comp.DamageContainerID,
ent.Comp.DamageModifierSetId,
ent.Comp.HealthBarThreshold
);
// TODO BODY SYSTEM pass damage onto body system
// BOBBY WHEN? 😭
// BOBBY SOON 🫡
if (DamageDelta == null)
return;
foreach (var damageChange in DamageDelta.DamageDict.Values)
{
if (damageChange > 0)
{
DamageIncreased = true;
break;
}
}
InterruptsDoAfters = interruptsDoAfters && DamageIncreased;
return;
}
// avoid mispredicting damage on newly spawned entities.
args.State = new DamageableComponentState(
ent.Comp.Damage.DamageDict.ShallowClone(),
ent.Comp.DamageContainerID,
ent.Comp.DamageModifierSetId,
ent.Comp.HealthBarThreshold
);
}
}

View File

@@ -1,10 +1,8 @@
using Content.Shared.Damage.Components;
using Content.Shared.Mobs.Systems;
using Content.Shared.Mobs.Components;
using Content.Shared.FixedPoint;
using Robust.Shared.Timing;
namespace Content.Shared.Damage;
namespace Content.Shared.Damage.Systems;
public sealed class PassiveDamageSystem : EntitySystem
{
@@ -47,7 +45,7 @@ public sealed class PassiveDamageSystem : EntitySystem
foreach (var allowedState in comp.AllowedStates)
{
if(allowedState == mobState.CurrentState)
_damageable.TryChangeDamage(uid, comp.Damage, true, false, damage);
_damageable.ChangeDamage((uid, damage), comp.Damage, true, false);
}
}
}

View File

@@ -1,10 +1,11 @@
using Content.Shared.Damage.Components;
using Content.Shared.Projectiles;
using Content.Shared.Weapons.Ranged.Components;
using Content.Shared.Standing;
using Robust.Shared.Physics.Events;
using Content.Shared.Weapons.Ranged.Components;
using Robust.Shared.Containers;
using Robust.Shared.Physics.Events;
namespace Content.Shared.Damage.Components;
namespace Content.Shared.Damage.Systems;
public sealed class RequireProjectileTargetSystem : EntitySystem
{

View File

@@ -85,9 +85,9 @@ public abstract class SharedGodmodeSystem : EntitySystem
if (!Resolve(uid, ref godmode, false))
return;
if (TryComp<DamageableComponent>(uid, out var damageable) && godmode.OldDamage != null)
if (godmode.OldDamage != null)
{
_damageable.SetDamage(uid, damageable, godmode.OldDamage);
_damageable.SetDamage(uid, godmode.OldDamage);
}
RemComp<GodmodeComponent>(uid);

View File

@@ -5,109 +5,108 @@ using Content.Shared.FixedPoint;
using Content.Shared.Inventory;
using Content.Shared.Movement.Systems;
namespace Content.Shared.Damage
namespace Content.Shared.Damage.Systems;
public sealed class SlowOnDamageSystem : EntitySystem
{
public sealed class SlowOnDamageSystem : EntitySystem
[Dependency] private readonly MovementSpeedModifierSystem _movementSpeedModifierSystem = default!;
public override void Initialize()
{
[Dependency] private readonly MovementSpeedModifierSystem _movementSpeedModifierSystem = default!;
base.Initialize();
public override void Initialize()
SubscribeLocalEvent<SlowOnDamageComponent, DamageChangedEvent>(OnDamageChanged);
SubscribeLocalEvent<SlowOnDamageComponent, RefreshMovementSpeedModifiersEvent>(OnRefreshMovespeed);
SubscribeLocalEvent<ClothingSlowOnDamageModifierComponent, InventoryRelayedEvent<ModifySlowOnDamageSpeedEvent>>(OnModifySpeed);
SubscribeLocalEvent<ClothingSlowOnDamageModifierComponent, ExaminedEvent>(OnExamined);
SubscribeLocalEvent<ClothingSlowOnDamageModifierComponent, ClothingGotEquippedEvent>(OnGotEquipped);
SubscribeLocalEvent<ClothingSlowOnDamageModifierComponent, ClothingGotUnequippedEvent>(OnGotUnequipped);
SubscribeLocalEvent<IgnoreSlowOnDamageComponent, ComponentStartup>(OnIgnoreStartup);
SubscribeLocalEvent<IgnoreSlowOnDamageComponent, ComponentShutdown>(OnIgnoreShutdown);
SubscribeLocalEvent<IgnoreSlowOnDamageComponent, ModifySlowOnDamageSpeedEvent>(OnIgnoreModifySpeed);
}
private void OnRefreshMovespeed(EntityUid uid, SlowOnDamageComponent component, RefreshMovementSpeedModifiersEvent args)
{
if (!TryComp<DamageableComponent>(uid, out var damage))
return;
if (damage.TotalDamage == FixedPoint2.Zero)
return;
// Get closest threshold
FixedPoint2 closest = FixedPoint2.Zero;
var total = damage.TotalDamage;
foreach (var thres in component.SpeedModifierThresholds)
{
base.Initialize();
SubscribeLocalEvent<SlowOnDamageComponent, DamageChangedEvent>(OnDamageChanged);
SubscribeLocalEvent<SlowOnDamageComponent, RefreshMovementSpeedModifiersEvent>(OnRefreshMovespeed);
SubscribeLocalEvent<ClothingSlowOnDamageModifierComponent, InventoryRelayedEvent<ModifySlowOnDamageSpeedEvent>>(OnModifySpeed);
SubscribeLocalEvent<ClothingSlowOnDamageModifierComponent, ExaminedEvent>(OnExamined);
SubscribeLocalEvent<ClothingSlowOnDamageModifierComponent, ClothingGotEquippedEvent>(OnGotEquipped);
SubscribeLocalEvent<ClothingSlowOnDamageModifierComponent, ClothingGotUnequippedEvent>(OnGotUnequipped);
SubscribeLocalEvent<IgnoreSlowOnDamageComponent, ComponentStartup>(OnIgnoreStartup);
SubscribeLocalEvent<IgnoreSlowOnDamageComponent, ComponentShutdown>(OnIgnoreShutdown);
SubscribeLocalEvent<IgnoreSlowOnDamageComponent, ModifySlowOnDamageSpeedEvent>(OnIgnoreModifySpeed);
if (total >= thres.Key && thres.Key > closest)
closest = thres.Key;
}
private void OnRefreshMovespeed(EntityUid uid, SlowOnDamageComponent component, RefreshMovementSpeedModifiersEvent args)
if (closest != FixedPoint2.Zero)
{
if (!TryComp<DamageableComponent>(uid, out var damage))
return;
var speed = component.SpeedModifierThresholds[closest];
if (damage.TotalDamage == FixedPoint2.Zero)
return;
// Get closest threshold
FixedPoint2 closest = FixedPoint2.Zero;
var total = damage.TotalDamage;
foreach (var thres in component.SpeedModifierThresholds)
{
if (total >= thres.Key && thres.Key > closest)
closest = thres.Key;
}
if (closest != FixedPoint2.Zero)
{
var speed = component.SpeedModifierThresholds[closest];
var ev = new ModifySlowOnDamageSpeedEvent(speed);
RaiseLocalEvent(uid, ref ev);
args.ModifySpeed(ev.Speed, ev.Speed);
}
}
private void OnDamageChanged(EntityUid uid, SlowOnDamageComponent component, DamageChangedEvent args)
{
// We -could- only refresh if it crossed a threshold but that would kind of be a lot of duplicated
// code and this isn't a super hot path anyway since basically only humans have this
_movementSpeedModifierSystem.RefreshMovementSpeedModifiers(uid);
}
private void OnModifySpeed(Entity<ClothingSlowOnDamageModifierComponent> ent, ref InventoryRelayedEvent<ModifySlowOnDamageSpeedEvent> args)
{
var dif = 1 - args.Args.Speed;
if (dif <= 0)
return;
// reduces the slowness modifier by the given coefficient
args.Args.Speed += dif * ent.Comp.Modifier;
}
private void OnExamined(Entity<ClothingSlowOnDamageModifierComponent> ent, ref ExaminedEvent args)
{
var msg = Loc.GetString("slow-on-damage-modifier-examine", ("mod", (1 - ent.Comp.Modifier) * 100));
args.PushMarkup(msg);
}
private void OnGotEquipped(Entity<ClothingSlowOnDamageModifierComponent> ent, ref ClothingGotEquippedEvent args)
{
_movementSpeedModifierSystem.RefreshMovementSpeedModifiers(args.Wearer);
}
private void OnGotUnequipped(Entity<ClothingSlowOnDamageModifierComponent> ent, ref ClothingGotUnequippedEvent args)
{
_movementSpeedModifierSystem.RefreshMovementSpeedModifiers(args.Wearer);
}
private void OnIgnoreStartup(Entity<IgnoreSlowOnDamageComponent> ent, ref ComponentStartup args)
{
_movementSpeedModifierSystem.RefreshMovementSpeedModifiers(ent);
}
private void OnIgnoreShutdown(Entity<IgnoreSlowOnDamageComponent> ent, ref ComponentShutdown args)
{
_movementSpeedModifierSystem.RefreshMovementSpeedModifiers(ent);
}
private void OnIgnoreModifySpeed(Entity<IgnoreSlowOnDamageComponent> ent, ref ModifySlowOnDamageSpeedEvent args)
{
args.Speed = 1f;
var ev = new ModifySlowOnDamageSpeedEvent(speed);
RaiseLocalEvent(uid, ref ev);
args.ModifySpeed(ev.Speed, ev.Speed);
}
}
[ByRefEvent]
public record struct ModifySlowOnDamageSpeedEvent(float Speed) : IInventoryRelayEvent
private void OnDamageChanged(EntityUid uid, SlowOnDamageComponent component, DamageChangedEvent args)
{
public SlotFlags TargetSlots => SlotFlags.WITHOUT_POCKET;
// We -could- only refresh if it crossed a threshold but that would kind of be a lot of duplicated
// code and this isn't a super hot path anyway since basically only humans have this
_movementSpeedModifierSystem.RefreshMovementSpeedModifiers(uid);
}
private void OnModifySpeed(Entity<ClothingSlowOnDamageModifierComponent> ent, ref InventoryRelayedEvent<ModifySlowOnDamageSpeedEvent> args)
{
var dif = 1 - args.Args.Speed;
if (dif <= 0)
return;
// reduces the slowness modifier by the given coefficient
args.Args.Speed += dif * ent.Comp.Modifier;
}
private void OnExamined(Entity<ClothingSlowOnDamageModifierComponent> ent, ref ExaminedEvent args)
{
var msg = Loc.GetString("slow-on-damage-modifier-examine", ("mod", (1 - ent.Comp.Modifier) * 100));
args.PushMarkup(msg);
}
private void OnGotEquipped(Entity<ClothingSlowOnDamageModifierComponent> ent, ref ClothingGotEquippedEvent args)
{
_movementSpeedModifierSystem.RefreshMovementSpeedModifiers(args.Wearer);
}
private void OnGotUnequipped(Entity<ClothingSlowOnDamageModifierComponent> ent, ref ClothingGotUnequippedEvent args)
{
_movementSpeedModifierSystem.RefreshMovementSpeedModifiers(args.Wearer);
}
private void OnIgnoreStartup(Entity<IgnoreSlowOnDamageComponent> ent, ref ComponentStartup args)
{
_movementSpeedModifierSystem.RefreshMovementSpeedModifiers(ent);
}
private void OnIgnoreShutdown(Entity<IgnoreSlowOnDamageComponent> ent, ref ComponentShutdown args)
{
_movementSpeedModifierSystem.RefreshMovementSpeedModifiers(ent);
}
private void OnIgnoreModifySpeed(Entity<IgnoreSlowOnDamageComponent> ent, ref ModifySlowOnDamageSpeedEvent args)
{
args.Speed = 1f;
}
}
[ByRefEvent]
public record struct ModifySlowOnDamageSpeedEvent(float Speed) : IInventoryRelayEvent
{
public SlotFlags TargetSlots => SlotFlags.WITHOUT_POCKET;
}

View File

@@ -162,7 +162,7 @@ public abstract class SharedDeliverySystem : EntitySystem
private bool TryUnlockDelivery(Entity<DeliveryComponent> ent, EntityUid user, bool rewardMoney = true, bool force = false)
{
// Check fingerprint access if there is a reader on the mail
if (!force && TryComp<FingerprintReaderComponent>(ent, out var reader) && !_fingerprintReader.IsAllowed((ent, reader), user))
if (!force && !_fingerprintReader.IsAllowed(ent.Owner, user, out _))
return false;
var deliveryName = _nameModifier.GetBaseName(ent.Owner);

View File

@@ -1,11 +1,11 @@
namespace Content.Shared.Destructible;
namespace Content.Shared.Destructible;
public abstract class SharedDestructibleSystem : EntitySystem
{
/// <summary>
/// Force entity to be destroyed and deleted.
/// Force entity to be destroyed and deleted.
/// </summary>
public bool DestroyEntity(EntityUid owner)
public bool DestroyEntity(Entity<MetaDataComponent?> owner)
{
var ev = new DestructionAttemptEvent();
RaiseLocalEvent(owner, ev);
@@ -15,12 +15,12 @@ public abstract class SharedDestructibleSystem : EntitySystem
var eventArgs = new DestructionEventArgs();
RaiseLocalEvent(owner, eventArgs);
QueueDel(owner);
PredictedQueueDel(owner);
return true;
}
/// <summary>
/// Force entity to break.
/// Force entity to break.
/// </summary>
public void BreakEntity(EntityUid owner)
{
@@ -30,7 +30,7 @@ public abstract class SharedDestructibleSystem : EntitySystem
}
/// <summary>
/// Raised before an entity is about to be destroyed and deleted
/// Raised before an entity is about to be destroyed and deleted
/// </summary>
public sealed class DestructionAttemptEvent : CancellableEntityEventArgs
{
@@ -38,7 +38,7 @@ public sealed class DestructionAttemptEvent : CancellableEntityEventArgs
}
/// <summary>
/// Raised when entity is destroyed and about to be deleted.
/// Raised when entity is destroyed and about to be deleted.
/// </summary>
public sealed class DestructionEventArgs : EntityEventArgs
{
@@ -46,7 +46,7 @@ public sealed class DestructionEventArgs : EntityEventArgs
}
/// <summary>
/// Raised when entity was heavy damage and about to break.
/// Raised when entity was heavy damage and about to break.
/// </summary>
public sealed class BreakageEventArgs : EntityEventArgs
{

View File

@@ -0,0 +1,12 @@
using Robust.Shared.Serialization;
namespace Content.Shared.Destructible;
[Flags]
[Serializable, NetSerializable]
public enum ThresholdActs : byte
{
None = 0,
Breakage = 1 << 0,
Destruction = 1 << 1,
}

View File

@@ -0,0 +1,28 @@
using Content.Shared.Damage.Components;
using Robust.Shared.Serialization;
namespace Content.Shared.Destructible.Thresholds.Triggers;
/// <summary>
/// A trigger that will activate when all of its triggers have activated.
/// </summary>
[Serializable, NetSerializable]
[DataDefinition]
public sealed partial class AndTrigger : IThresholdTrigger
{
[DataField]
public List<IThresholdTrigger> Triggers = new();
public bool Reached(Entity<DamageableComponent> damageable, SharedDestructibleSystem system)
{
foreach (var trigger in Triggers)
{
if (!trigger.Reached(damageable, system))
{
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,33 @@
using Content.Shared.Damage.Components;
using Content.Shared.FixedPoint;
using Content.Shared.Damage.Prototypes;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
namespace Content.Shared.Destructible.Thresholds.Triggers;
/// <summary>
/// A trigger that will activate when the amount of damage received
/// of the specified class is above the specified threshold.
/// </summary>
[Serializable, NetSerializable]
[DataDefinition]
public sealed partial class DamageGroupTrigger : IThresholdTrigger
{
/// <summary>
/// The damage group to check for.
/// </summary>
[DataField(required: true)]
public ProtoId<DamageGroupPrototype> DamageGroup = default!;
/// <summary>
/// The amount of damage at which this threshold will trigger.
/// </summary>
[DataField(required: true)]
public FixedPoint2 Damage = default!;
public bool Reached(Entity<DamageableComponent> damageable, SharedDestructibleSystem system)
{
return damageable.Comp.DamagePerGroup[DamageGroup] >= Damage;
}
}

View File

@@ -0,0 +1,25 @@
using Content.Shared.Damage.Components;
using Content.Shared.FixedPoint;
using Robust.Shared.Serialization;
namespace Content.Shared.Destructible.Thresholds.Triggers;
/// <summary>
/// A trigger that will activate when the total amount of damage received
/// is above the specified threshold.
/// </summary>
[Serializable, NetSerializable]
[DataDefinition]
public sealed partial class DamageTrigger : IThresholdTrigger
{
/// <summary>
/// The amount of damage at which this threshold will trigger.
/// </summary>
[DataField(required: true)]
public FixedPoint2 Damage = default!;
public bool Reached(Entity<DamageableComponent> damageable, SharedDestructibleSystem system)
{
return damageable.Comp.TotalDamage >= Damage;
}
}

View File

@@ -0,0 +1,34 @@
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Prototypes;
using Content.Shared.FixedPoint;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
namespace Content.Shared.Destructible.Thresholds.Triggers;
/// <summary>
/// A trigger that will activate when the amount of damage received
/// of the specified type is above the specified threshold.
/// </summary>
[Serializable, NetSerializable]
[DataDefinition]
public sealed partial class DamageTypeTrigger : IThresholdTrigger
{
/// <summary>
/// The damage type to check for.
/// </summary>
[DataField(required: true)]
public ProtoId<DamageTypePrototype> DamageType = default!;
/// <summary>
/// The amount of damage at which this threshold will trigger.
/// </summary>
[DataField(required: true)]
public FixedPoint2 Damage = default!;
public bool Reached(Entity<DamageableComponent> damageable, SharedDestructibleSystem system)
{
return damageable.Comp.Damage.DamageDict.TryGetValue(DamageType, out var damageReceived) &&
damageReceived >= Damage;
}
}

View File

@@ -0,0 +1,27 @@
using Content.Shared.Damage.Components;
namespace Content.Shared.Destructible.Thresholds.Triggers;
/// <summary>
/// A condition for triggering a <see cref="DamageThreshold">.
/// </summary>
/// <remarks>
/// I decided against converting these into EntityEffectConditions for performance reasons
/// (although I did not do any benchmarks, so it might be fine).
/// Entity effects will raise a separate event for each entity and each condition, which can become a huge number
/// for cases like nuke explosions or shuttle collisions where there are lots of DamageChangedEvents at once.
/// IThresholdTriggers on the other hand are directly checked in a foreach loop without raising events.
/// And there are only few of these conditions, so there is only a minor amount of code duplication.
/// </remarks>
public interface IThresholdTrigger
{
/// <summary>
/// Checks if this trigger has been reached.
/// </summary>
/// <param name="damageable">The damageable component to check with.</param>
/// <param name="system">
/// An instance of <see cref="SharedDestructibleSystem"/> to pull dependencies from, if any.
/// </param>
/// <returns>true if this trigger has been reached, false otherwise.</returns>
bool Reached(Entity<DamageableComponent> damageable, SharedDestructibleSystem system);
}

View File

@@ -0,0 +1,28 @@
using Content.Shared.Damage.Components;
using Robust.Shared.Serialization;
namespace Content.Shared.Destructible.Thresholds.Triggers;
/// <summary>
/// A trigger that will activate when any of its triggers have activated.
/// </summary>
[Serializable, NetSerializable]
[DataDefinition]
public sealed partial class OrTrigger : IThresholdTrigger
{
[DataField]
public List<IThresholdTrigger> Triggers = new();
public bool Reached(Entity<DamageableComponent> damageable, SharedDestructibleSystem system)
{
foreach (var trigger in Triggers)
{
if (trigger.Reached(damageable, system))
{
return true;
}
}
return false;
}
}

View File

@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Content.Shared.ActionBlocker;
using Content.Shared.Damage;
using Content.Shared.Damage.Systems;
using Content.Shared.Hands.Components;
using Content.Shared.Tag;
using Robust.Shared.GameStates;

View File

@@ -325,4 +325,5 @@ public enum DoorVisualLayers : byte
BaseUnlit,
BaseBolted,
BaseEmergencyAccess,
BaseEmagging,
}

View File

@@ -2,7 +2,7 @@ using System.Linq;
using Content.Shared.Access.Components;
using Content.Shared.Access.Systems;
using Content.Shared.Administration.Logs;
using Content.Shared.Damage;
using Content.Shared.Damage.Systems;
using Content.Shared.Database;
using Content.Shared.Doors.Components;
using Content.Shared.Emag.Systems;

View File

@@ -1,4 +1,3 @@
using Content.Shared.Speech.EntitySystems;
using Content.Shared.StatusEffectNew;
using Content.Shared.Traits.Assorted;
using Robust.Shared.Prototypes;
@@ -8,12 +7,6 @@ namespace Content.Shared.Drunk;
public abstract class SharedDrunkSystem : EntitySystem
{
public static EntProtoId Drunk = "StatusEffectDrunk";
public static EntProtoId Woozy = "StatusEffectWoozy";
/* I have no clue why this magic number was chosen, I copied it from slur system and needed it for the overlay
If you have a more intelligent magic number be my guest to completely explode this value.
There were no comments as to why this value was chosen three years ago. */
public static float MagicNumber = 1100f;
[Dependency] protected readonly StatusEffectsSystem Status = default!;

View File

@@ -1,27 +1,6 @@
using Content.Shared.Chat.Prototypes;
using Content.Shared.Inventory;
namespace Content.Shared.Emoting;
namespace Content.Shared.Emoting;
public sealed class EmoteAttemptEvent(EntityUid uid) : CancellableEntityEventArgs
{
public EntityUid Uid { get; } = uid;
}
/// <summary>
/// An event raised just before an emote is performed, providing systems with an opportunity to cancel the emote's performance.
/// </summary>
[ByRefEvent]
public sealed class BeforeEmoteEvent(EntityUid source, EmotePrototype emote)
: CancellableEntityEventArgs, IInventoryRelayEvent
{
public readonly EntityUid Source = source;
public readonly EmotePrototype Emote = emote;
/// <summary>
/// The equipment that is blocking emoting. Should only be non-null if the event was canceled.
/// </summary>
public EntityUid? Blocker = null;
public SlotFlags TargetSlots => SlotFlags.WITHOUT_POCKET;
}

View File

@@ -0,0 +1,10 @@
using Robust.Shared.Prototypes;
namespace Content.Shared.EntityConditions.Conditions.Body;
/// <inheritdoc cref="EntityCondition"/>
public sealed partial class BreathingCondition : EntityConditionBase<BreathingCondition>
{
public override string EntityConditionGuidebookText(IPrototypeManager prototype) =>
Loc.GetString("entity-condition-guidebook-breathing", ("isBreathing", !Inverted));
}

View File

@@ -0,0 +1,33 @@
using Content.Shared.Nutrition.Components;
using Content.Shared.Nutrition.EntitySystems;
using Robust.Shared.Prototypes;
namespace Content.Shared.EntityConditions.Conditions.Body;
/// <summary>
/// Returns true if this entity's hunger is within a specified minimum and maximum.
/// </summary>
/// <inheritdoc cref="EntityConditionSystem{T, TCondition}"/>
public sealed partial class TotalHungerEntityConditionSystem : EntityConditionSystem<HungerComponent, HungerCondition>
{
[Dependency] private readonly HungerSystem _hunger = default!;
protected override void Condition(Entity<HungerComponent> entity, ref EntityConditionEvent<HungerCondition> args)
{
var total = _hunger.GetHunger(entity.Comp);
args.Result = total >= args.Condition.Min && total <= args.Condition.Max;
}
}
/// <inheritdoc cref="EntityCondition"/>
public sealed partial class HungerCondition : EntityConditionBase<HungerCondition>
{
[DataField]
public float Min;
[DataField]
public float Max = float.PositiveInfinity;
public override string EntityConditionGuidebookText(IPrototypeManager prototype) =>
Loc.GetString("entity-condition-guidebook-total-hunger", ("max", float.IsPositiveInfinity(Max) ? int.MaxValue : Max), ("min", Min));
}

View File

@@ -0,0 +1,23 @@
using Content.Shared.Body.Components;
using Robust.Shared.Prototypes;
namespace Content.Shared.EntityConditions.Conditions.Body;
/// <summary>
/// Returns true if this entity is using internals. False if they are not or cannot use internals.
/// </summary>
/// <inheritdoc cref="EntityConditionSystem{T, TCondition}"/>
public sealed partial class InternalsOnEntityConditionSystem : EntityConditionSystem<InternalsComponent, InternalsCondition>
{
protected override void Condition(Entity<InternalsComponent> entity, ref EntityConditionEvent<InternalsCondition> args)
{
args.Result = entity.Comp.GasTankEntity != null;
}
}
/// <inheritdoc cref="EntityCondition"/>
public sealed partial class InternalsCondition : EntityConditionBase<InternalsCondition>
{
public override string EntityConditionGuidebookText(IPrototypeManager prototype) =>
Loc.GetString("entity-condition-guidebook-internals", ("usingInternals", !Inverted));
}

View File

@@ -0,0 +1,31 @@
using Content.Shared.Body.Prototypes;
using Content.Shared.Localizations;
using Robust.Shared.Prototypes;
namespace Content.Shared.EntityConditions.Conditions.Body;
/// <inheritdoc cref="EntityCondition"/>
public sealed partial class MetabolizerTypeCondition : EntityConditionBase<MetabolizerTypeCondition>
{
[DataField(required: true)]
public ProtoId<MetabolizerTypePrototype>[] Type = default!;
public override string EntityConditionGuidebookText(IPrototypeManager prototype)
{
var typeList = new List<string>();
foreach (var type in Type)
{
if (!prototype.Resolve(type, out var proto))
continue;
typeList.Add(proto.LocalizedName);
}
var names = ContentLocalizationManager.FormatListToOr(typeList);
return Loc.GetString("entity-condition-guidebook-organ-type",
("name", names),
("shouldhave", !Inverted));
}
}

View File

@@ -0,0 +1,28 @@
using Content.Shared.Mobs;
using Content.Shared.Mobs.Components;
using Robust.Shared.Prototypes;
namespace Content.Shared.EntityConditions.Conditions.Body;
/// <summary>
/// Returns true if this entity's current mob state matches the condition's specified mob state.
/// </summary>
/// <inheritdoc cref="EntityConditionSystem{T, TCondition}"/>
public sealed partial class MobStateEntityConditionSystem : EntityConditionSystem<MobStateComponent, MobStateCondition>
{
protected override void Condition(Entity<MobStateComponent> entity, ref EntityConditionEvent<MobStateCondition> args)
{
if (entity.Comp.CurrentState == args.Condition.Mobstate)
args.Result = true;
}
}
/// <inheritdoc cref="EntityCondition"/>
public sealed partial class MobStateCondition : EntityConditionBase<MobStateCondition>
{
[DataField]
public MobState Mobstate = MobState.Alive;
public override string EntityConditionGuidebookText(IPrototypeManager prototype) =>
Loc.GetString("entity-condition-guidebook-mob-state-condition", ("state", Mobstate));
}

View File

@@ -0,0 +1,38 @@
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Prototypes;
using Content.Shared.FixedPoint;
using Robust.Shared.Prototypes;
namespace Content.Shared.EntityConditions.Conditions;
/// <summary>
/// Returns true if this entity can take damage and if its damage of a given damage group is within a specified minimum and maximum.
/// </summary>
/// <inheritdoc cref="EntityConditionSystem{T, TCondition}"/>
public sealed partial class DamageGroupEntityConditionSystem : EntityConditionSystem<DamageableComponent, DamageGroupCondition>
{
protected override void Condition(Entity<DamageableComponent> entity, ref EntityConditionEvent<DamageGroupCondition> args)
{
var value = entity.Comp.DamagePerGroup[args.Condition.DamageGroup];
args.Result = value >= args.Condition.Min && value <= args.Condition.Max;
}
}
/// <inheritdoc cref="EntityCondition"/>
public sealed partial class DamageGroupCondition : EntityConditionBase<DamageGroupCondition>
{
[DataField]
public FixedPoint2 Max = FixedPoint2.MaxValue;
[DataField]
public FixedPoint2 Min = FixedPoint2.Zero;
[DataField(required: true)]
public ProtoId<DamageGroupPrototype> DamageGroup;
public override string EntityConditionGuidebookText(IPrototypeManager prototype) =>
Loc.GetString("entity-condition-guidebook-group-damage",
("max", Max == FixedPoint2.MaxValue ? int.MaxValue : Max.Float()),
("min", Min.Float()),
("type", prototype.Index(DamageGroup).LocalizedName));
}

View File

@@ -0,0 +1,39 @@
using Content.Shared.Damage;
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Prototypes;
using Content.Shared.FixedPoint;
using Robust.Shared.Prototypes;
namespace Content.Shared.EntityConditions.Conditions;
/// <summary>
/// Returns true if this entity can take damage and if its damage of a given damage type is within a specified minimum and maximum.
/// </summary>
/// <inheritdoc cref="EntityConditionSystem{T, TCondition}"/>
public sealed partial class DamageTypeEntityConditionSystem : EntityConditionSystem<DamageableComponent, DamageTypeCondition>
{
protected override void Condition(Entity<DamageableComponent> entity, ref EntityConditionEvent<DamageTypeCondition> args)
{
var value = entity.Comp.Damage.DamageDict.GetValueOrDefault(args.Condition.DamageType);
args.Result = value >= args.Condition.Min && value <= args.Condition.Max;
}
}
/// <inheritdoc cref="EntityCondition"/>
public sealed partial class DamageTypeCondition : EntityConditionBase<DamageTypeCondition>
{
[DataField]
public FixedPoint2 Max = FixedPoint2.MaxValue;
[DataField]
public FixedPoint2 Min = FixedPoint2.Zero;
[DataField(required: true)]
public ProtoId<DamageTypePrototype> DamageType;
public override string EntityConditionGuidebookText(IPrototypeManager prototype) =>
Loc.GetString("entity-condition-guidebook-type-damage",
("max", Max == FixedPoint2.MaxValue ? int.MaxValue : Max.Float()),
("min", Min.Float()),
("type", prototype.Index(DamageType).LocalizedName));
}

View File

@@ -0,0 +1,59 @@
using System.Linq;
using Content.Shared.Localizations;
using Content.Shared.Mind;
using Content.Shared.Mind.Components;
using Content.Shared.Roles;
using Content.Shared.Roles.Components;
using Robust.Shared.Prototypes;
namespace Content.Shared.EntityConditions.Conditions;
/// <summary>
/// Returns true if this entity has any of the specified jobs. False if the entity has no mind, none of the specified jobs, or is jobless.
/// </summary>
/// <inheritdoc cref="EntityConditionSystem{T, TCondition}"/>
public sealed partial class HasJobEntityConditionSystem : EntityConditionSystem<MindContainerComponent, JobCondition>
{
protected override void Condition(Entity<MindContainerComponent> entity, ref EntityConditionEvent<JobCondition> args)
{
// We need a mind in our mind container...
if (!TryComp<MindComponent>(entity.Comp.Mind, out var mind))
return;
foreach (var roleId in mind.MindRoleContainer.ContainedEntities)
{
if (!HasComp<JobRoleComponent>(roleId))
continue;
if (!TryComp<MindRoleComponent>(roleId, out var mindRole))
{
Log.Error($"Encountered job mind role entity {roleId} without a {nameof(MindRoleComponent)}");
continue;
}
if (mindRole.JobPrototype == null)
{
Log.Error($"Encountered job mind role entity {roleId} without a {nameof(JobPrototype)}");
continue;
}
if (!args.Condition.Jobs.Contains(mindRole.JobPrototype.Value))
continue;
args.Result = true;
return;
}
}
}
/// <inheritdoc cref="EntityCondition"/>
public sealed partial class JobCondition : EntityConditionBase<JobCondition>
{
[DataField(required: true)] public List<ProtoId<JobPrototype>> Jobs = [];
public override string EntityConditionGuidebookText(IPrototypeManager prototype)
{
var localizedNames = Jobs.Select(jobId => prototype.Index(jobId).LocalizedName).ToList();
return Loc.GetString("entity-condition-guidebook-job-condition", ("job", ContentLocalizationManager.FormatListToOr(localizedNames)));
}
}

View File

@@ -0,0 +1,44 @@
using Content.Shared.Chemistry.Components;
using Content.Shared.Chemistry.Reagent;
using Content.Shared.FixedPoint;
using Robust.Shared.Prototypes;
namespace Content.Shared.EntityConditions.Conditions;
/// <summary>
/// Returns true if this solution entity has an amount of reagent in it within a specified minimum and maximum.
/// </summary>
/// <inheritdoc cref="EntityConditionSystem{T, TCondition}"/>
public sealed partial class ReagentThresholdEntityConditionSystem : EntityConditionSystem<SolutionComponent, ReagentCondition>
{
protected override void Condition(Entity<SolutionComponent> entity, ref EntityConditionEvent<ReagentCondition> args)
{
var quant = entity.Comp.Solution.GetTotalPrototypeQuantity(args.Condition.Reagent);
args.Result = quant >= args.Condition.Min && quant <= args.Condition.Max;
}
}
/// <inheritdoc cref="EntityCondition"/>
public sealed partial class ReagentCondition : EntityConditionBase<ReagentCondition>
{
[DataField]
public FixedPoint2 Min = FixedPoint2.Zero;
[DataField]
public FixedPoint2 Max = FixedPoint2.MaxValue;
[DataField(required: true)]
public ProtoId<ReagentPrototype> Reagent;
public override string EntityConditionGuidebookText(IPrototypeManager prototype)
{
if (!prototype.Resolve(Reagent, out var reagentProto))
return String.Empty;
return Loc.GetString("entity-condition-guidebook-reagent-threshold",
("reagent", reagentProto.LocalizedName),
("max", Max == FixedPoint2.MaxValue ? int.MaxValue : Max.Float()),
("min", Min.Float()));
}
}

View File

@@ -0,0 +1,43 @@
using Content.Shared.Localizations;
using Content.Shared.Tag;
using Robust.Shared.Prototypes;
namespace Content.Shared.EntityConditions.Conditions.Tags;
/// <summary>
/// Returns true if this entity has all the listed tags.
/// </summary>
/// <inheritdoc cref="EntityConditionSystem{T, TCondition}"/>
public sealed partial class HasAllTagsEntityConditionSystem : EntityConditionSystem<TagComponent, AllTagsCondition>
{
[Dependency] private readonly TagSystem _tag = default!;
protected override void Condition(Entity<TagComponent> entity, ref EntityConditionEvent<AllTagsCondition> args)
{
args.Result = _tag.HasAllTags(entity.Comp, args.Condition.Tags);
}
}
/// <inheritdoc cref="EntityCondition"/>
public sealed partial class AllTagsCondition : EntityConditionBase<AllTagsCondition>
{
[DataField(required: true)]
public ProtoId<TagPrototype>[] Tags = [];
public override string EntityConditionGuidebookText(IPrototypeManager prototype)
{
var tagList = new List<string>();
foreach (var type in Tags)
{
if (!prototype.Resolve(type, out var proto))
continue;
tagList.Add(proto.ID);
}
var names = ContentLocalizationManager.FormatList(tagList);
return Loc.GetString("entity-condition-guidebook-has-tag", ("tag", names), ("invert", Inverted));
}
}

View File

@@ -0,0 +1,43 @@
using Content.Shared.Localizations;
using Content.Shared.Tag;
using Robust.Shared.Prototypes;
namespace Content.Shared.EntityConditions.Conditions.Tags;
/// <summary>
/// Returns true if this entity have any of the listed tags.
/// </summary>
/// <inheritdoc cref="EntityConditionSystem{T, TCondition}"/>
public sealed partial class HasAnyTagEntityConditionSystem : EntityConditionSystem<TagComponent, AnyTagCondition>
{
[Dependency] private readonly TagSystem _tag = default!;
protected override void Condition(Entity<TagComponent> entity, ref EntityConditionEvent<AnyTagCondition> args)
{
args.Result = _tag.HasAnyTag(entity.Comp, args.Condition.Tags);
}
}
/// <inheritdoc cref="EntityCondition"/>
public sealed partial class AnyTagCondition : EntityConditionBase<AnyTagCondition>
{
[DataField(required: true)]
public ProtoId<TagPrototype>[] Tags = [];
public override string EntityConditionGuidebookText(IPrototypeManager prototype)
{
var tagList = new List<string>();
foreach (var type in Tags)
{
if (!prototype.Resolve(type, out var proto))
continue;
tagList.Add(proto.ID);
}
var names = ContentLocalizationManager.FormatListToOr(tagList);
return Loc.GetString("entity-condition-guidebook-has-tag", ("tag", names), ("invert", Inverted));
}
}

View File

@@ -0,0 +1,28 @@
using Content.Shared.Tag;
using Robust.Shared.Prototypes;
namespace Content.Shared.EntityConditions.Conditions.Tags;
/// <summary>
/// Returns true if this entity has the listed tag.
/// </summary>
/// <inheritdoc cref="EntityConditionSystem{T, TCondition}"/>
public sealed partial class HasTagEntityConditionSystem : EntityConditionSystem<TagComponent, TagCondition>
{
[Dependency] private readonly TagSystem _tag = default!;
protected override void Condition(Entity<TagComponent> entity, ref EntityConditionEvent<TagCondition> args)
{
args.Result = _tag.HasTag(entity.Comp, args.Condition.Tag);
}
}
/// <inheritdoc cref="EntityCondition"/>
public sealed partial class TagCondition : EntityConditionBase<TagCondition>
{
[DataField(required: true)]
public ProtoId<TagPrototype> Tag;
public override string EntityConditionGuidebookText(IPrototypeManager prototype) =>
Loc.GetString("entity-condition-guidebook-has-tag", ("tag", Tag), ("invert", Inverted));
}

Some files were not shown because too many files have changed in this diff Show More