#nullable enable using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using Content.Shared.Actions.Prototypes; using Content.Shared.NetIDs; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Map; using Robust.Shared.Players; using Robust.Shared.Serialization; using Robust.Shared.Serialization.Manager.Attributes; using Robust.Shared.Timing; using Robust.Shared.ViewVariables; namespace Content.Shared.Actions.Components { /// /// Manages the actions available to an entity. /// Should only be used for player-controlled entities. /// /// Actions are granted directly to the owner entity. Item actions are granted via a particular item which /// must be in the owner's inventory (the action is revoked when item leaves the owner's inventory). This /// should almost always be done via ItemActionsComponent on the item entity (which also tracks the /// cooldowns associated with the actions on that item). /// /// Actions can still have an associated state even when revoked. For example, a flashlight toggle action /// may be unusable while the player is stunned, but this component will still have an entry for the action /// so the user can see whether it's currently toggled on or off. /// public abstract class SharedActionsComponent : Component { private static readonly TimeSpan CooldownExpiryThreshold = TimeSpan.FromSeconds(10); [Dependency] protected readonly ActionManager ActionManager = default!; [Dependency] protected readonly IGameTiming GameTiming = default!; [Dependency] protected readonly IEntityManager EntityManager = default!; public override string Name => "Actions"; public override uint? NetID => ContentNetIDs.ACTIONS; /// /// Actions granted to this entity as soon as they spawn, regardless /// of the status of the entity. /// public IEnumerable InnateActions => _innateActions ?? Enumerable.Empty(); [DataField("innateActions")] private List? _innateActions = null; // entries are removed from this if they are at the initial state (not enabled, no cooldown, toggled off). // a system runs which periodically removes cooldowns from entries when they are revoked and their // cooldowns have expired for a long enough time, also removing the entry if it is then at initial state. // This helps to keep our component state smaller. [ViewVariables] private Dictionary _actions = new(); // Holds item action states. Item actions are only added to this when granted, and are removed // when revoked or when they leave inventory. This is almost entirely handled by ItemActionsComponent on // item entities. [ViewVariables] private Dictionary> _itemActions = new(); protected override void Startup() { foreach (var actionType in InnateActions) { Grant(actionType); } } public override ComponentState GetComponentState(ICommonSession player) { return new ActionComponentState(_actions, _itemActions); } public override void HandleComponentState(ComponentState? curState, ComponentState? nextState) { base.HandleComponentState(curState, nextState); if (curState is not ActionComponentState state) { return; } _actions = state.Actions; _itemActions = state.ItemActions; } /// /// Gets the action state associated with the specified action type, if it has been /// granted, has a cooldown, or has been toggled on /// /// false if not found for this action type public bool TryGetActionState(ActionType actionType, out ActionState actionState) { return _actions.TryGetValue(actionType, out actionState); } /// /// Gets the item action states associated with the specified item if any have been granted /// and not yet revoked. /// /// false if no states found for this item action type. public bool TryGetItemActionStates(EntityUid item, [NotNullWhen((true))] out IReadOnlyDictionary? itemActionStates) { if (_itemActions.TryGetValue(item, out var actualItemActionStates)) { itemActionStates = actualItemActionStates; return true; } itemActionStates = null; return false; } /// public bool TryGetItemActionStates(IEntity item, [NotNullWhen((true))] out IReadOnlyDictionary? itemActionStates) { return TryGetItemActionStates(item.Uid, out itemActionStates); } /// /// Gets the item action state associated with the specified item action type for the specified item, if it has any. /// /// false if no state found for this item action type for this item public bool TryGetItemActionState(ItemActionType actionType, EntityUid item, out ActionState actionState) { if (_itemActions.TryGetValue(item, out var actualItemActionStates)) { return actualItemActionStates.TryGetValue(actionType, out actionState); } actionState = default; return false; } /// true if the action is granted and enabled (if item action, if granted and enabled for any item) public bool IsGranted(BaseActionPrototype actionType) { return actionType switch { ActionPrototype actionPrototype => IsGranted(actionPrototype.ActionType), ItemActionPrototype itemActionPrototype => IsGranted(itemActionPrototype.ActionType), _ => false }; } public bool IsGranted(ActionType actionType) { if (TryGetActionState(actionType, out var actionState)) { return actionState.Enabled; } return false; } /// true if the action is granted and enabled for any item. This /// has to traverse the entire item state dictionary so please avoid frequent calls. public bool IsGranted(ItemActionType actionType) { return _itemActions.Values.SelectMany(vals => vals) .Any(state => state.Key == actionType && state.Value.Enabled); } /// public bool TryGetItemActionState(ItemActionType actionType, IEntity item, out ActionState actionState) { return TryGetItemActionState(actionType, item.Uid, out actionState); } /// /// Gets all action types that have non-initial state (granted, have a cooldown, or toggled on). /// public IReadOnlyDictionary ActionStates() { return _actions; } /// /// Gets all items that have actions currently granted (that are not revoked /// and still in inventory). /// Map from item uid -> (action type -> associated action state) /// PLEASE DO NOT MODIFY THE INNER DICTIONARY! I CANNOT CAST IT TO IReadOnlyDictionary! /// public IReadOnlyDictionary> ItemActionStates() { return _itemActions; } /// /// Creates or updates the action state with the supplied non-null values /// private void GrantOrUpdate(ActionType actionType, bool? enabled = null, bool? toggleOn = null, (TimeSpan start, TimeSpan end)? cooldown = null, bool clearCooldown = false) { var dirty = false; if (!_actions.TryGetValue(actionType, out var actionState)) { // no state at all for this action, create it anew dirty = true; actionState = new ActionState(enabled ?? false, toggleOn ?? false); } if (enabled.HasValue && actionState.Enabled != enabled.Value) { dirty = true; actionState.Enabled = enabled.Value; } if ((cooldown.HasValue || clearCooldown) && actionState.Cooldown != cooldown) { dirty = true; actionState.Cooldown = cooldown; } if (toggleOn.HasValue && actionState.ToggledOn != toggleOn.Value) { dirty = true; actionState.ToggledOn = toggleOn.Value; } if (!dirty) return; _actions[actionType] = actionState; AfterActionChanged(); Dirty(); } /// /// Intended to only be used by ItemActionsComponent. /// Updates the state of the item action provided by the item, granting the action /// if it is not yet granted to the player. Should be called whenever the /// status changes. The existing state will be completely overwritten by the new state. /// public void GrantOrUpdateItemAction(ItemActionType actionType, EntityUid item, ActionState state) { if (!_itemActions.TryGetValue(item, out var itemStates)) { itemStates = new Dictionary(); _itemActions[item] = itemStates; } itemStates[actionType] = state; AfterActionChanged(); Dirty(); } /// /// Intended to only be used by ItemActionsComponent. Revokes the item action so the player no longer /// sees it and can no longer use it. /// public void RevokeItemAction(ItemActionType actionType, EntityUid item) { if (!_itemActions.TryGetValue(item, out var itemStates)) return; itemStates.Remove(actionType); if (itemStates.Count == 0) { _itemActions.Remove(item); } AfterActionChanged(); Dirty(); } /// /// Grants the entity the ability to perform the action, optionally overriding its /// current state with specified values. /// /// Even if the action was already granted, if the action had any state (cooldown, toggle) prior to this method /// being called, it will be preserved, with specific fields optionally overridden by any of the provided /// non-null arguments. /// /// When null, preserves the current toggle status of the action, defaulting /// to false if action has no current state. /// When non-null, action will be shown toggled to this value /// When null, preserves the current cooldown status of the action, defaulting /// to no cooldown if action has no current state. /// When non-null, action cooldown will be set to this value. public void Grant(ActionType actionType, bool? toggleOn = null, (TimeSpan start, TimeSpan end)? cooldown = null) { GrantOrUpdate(actionType, true, toggleOn, cooldown); } /// /// Grants the entity the ability to perform the action, resetting its state /// to its initial state and settings its state based on supplied parameters. /// /// Even if the action was already granted, if the action had any state (cooldown, toggle) prior to this method /// being called, it will be reset to initial (no cooldown, toggled off). /// /// action will be shown toggled to this value /// action cooldown will be set to this value (by default the cooldown is cleared). public void GrantFromInitialState(ActionType actionType, bool toggleOn = false, (TimeSpan start, TimeSpan end)? cooldown = null) { _actions.Remove(actionType); Grant(actionType, toggleOn, cooldown); } /// /// Sets the cooldown for the action. Actions on cooldown cannot be used. /// /// This will work even if the action is revoked - /// for example if there's an ability with a cooldown which is temporarily unusable due /// to the player being stunned, the cooldown will still tick down even while the player /// is stunned. /// /// Setting cooldown to null clears it. /// public void Cooldown(ActionType actionType, (TimeSpan start, TimeSpan end)? cooldown) { GrantOrUpdate(actionType, cooldown: cooldown, clearCooldown: true); } /// /// Revokes the ability to perform the action for this entity. Current state /// of the action (toggle / cooldown) is preserved. /// public void Revoke(ActionType actionType) { if (!_actions.TryGetValue(actionType, out var actionState)) return; if (!actionState.Enabled) return; actionState.Enabled = false; // don't store it anymore if its at its initial state. if (actionState.IsAtInitialState) { _actions.Remove(actionType); } else { _actions[actionType] = actionState; } AfterActionChanged(); Dirty(); } /// /// Toggles the action to the specified value. Works even if the action is on cooldown /// or revoked. /// public void ToggleAction(ActionType actionType, bool toggleOn) { Grant(actionType, toggleOn); } /// /// Clears any cooldowns which have expired beyond the predefined threshold. /// this should be run periodically to ensure we don't have unbounded growth of /// our saved action data, and keep our component state sent to the client as minimal as possible. /// public void ExpireCooldowns() { // actions - only clear cooldowns and remove associated action state // if the action is at initial state var actionTypesToRemove = new List(); foreach (var (actionType, actionState) in _actions) { // ignore it unless we may be able to delete it due to // clearing the cooldown if (!actionState.IsAtInitialStateExceptCooldown) continue; if (!actionState.Cooldown.HasValue) { actionTypesToRemove.Add(actionType); continue; } var expiryTime = GameTiming.CurTime - actionState.Cooldown.Value.Item2; if (expiryTime > CooldownExpiryThreshold) { actionTypesToRemove.Add(actionType); } } foreach (var remove in actionTypesToRemove) { _actions.Remove(remove); } } /// /// Invoked after a change has been made to an action state in this component. /// protected virtual void AfterActionChanged() { } } [Serializable, NetSerializable] public class ActionComponentState : ComponentState { public Dictionary Actions; public Dictionary> ItemActions; public ActionComponentState(Dictionary actions, Dictionary> itemActions) : base(ContentNetIDs.ACTIONS) { Actions = actions; ItemActions = itemActions; } } [Serializable, NetSerializable] public struct ActionState { /// /// False if this action is not currently allowed to be performed. /// public bool Enabled; /// /// Only used for toggle actions, indicates whether it's currently toggled on or off /// TODO: Eventually this should probably be a byte so we it can toggle through multiple states. /// public bool ToggledOn; public (TimeSpan start, TimeSpan end)? Cooldown; public bool IsAtInitialState => IsAtInitialStateExceptCooldown && !Cooldown.HasValue; public bool IsAtInitialStateExceptCooldown => !Enabled && !ToggledOn; /// /// Creates an action state for the indicated type, defaulting to the /// initial state. /// public ActionState(bool enabled = false, bool toggledOn = false, (TimeSpan start, TimeSpan end)? cooldown = null) { Enabled = enabled; ToggledOn = toggledOn; Cooldown = cooldown; } public bool IsOnCooldown(TimeSpan curTime) { if (Cooldown == null) return false; return curTime < Cooldown.Value.Item2; } public bool IsOnCooldown(IGameTiming gameTiming) { return IsOnCooldown(gameTiming.CurTime); } public bool Equals(ActionState other) { return Enabled == other.Enabled && ToggledOn == other.ToggledOn && Nullable.Equals(Cooldown, other.Cooldown); } public override bool Equals(object? obj) { return obj is ActionState other && Equals(other); } public override int GetHashCode() { return HashCode.Combine(Enabled, ToggledOn, Cooldown); } } [Serializable, NetSerializable] public abstract class BasePerformActionMessage : ComponentMessage { public abstract BehaviorType BehaviorType { get; } } [Serializable, NetSerializable] public abstract class PerformActionMessage : BasePerformActionMessage { public readonly ActionType ActionType; protected PerformActionMessage(ActionType actionType) { Directed = true; ActionType = actionType; } } [Serializable, NetSerializable] public abstract class PerformItemActionMessage : BasePerformActionMessage { public readonly ItemActionType ActionType; public readonly EntityUid Item; protected PerformItemActionMessage(ItemActionType actionType, EntityUid item) { Directed = true; ActionType = actionType; Item = item; } } /// /// A message that tells server we want to run the instant action logic. /// [Serializable, NetSerializable] public class PerformInstantActionMessage : PerformActionMessage { public override BehaviorType BehaviorType => BehaviorType.Instant; public PerformInstantActionMessage(ActionType actionType) : base(actionType) { } } /// /// A message that tells server we want to run the instant action logic. /// [Serializable, NetSerializable] public class PerformInstantItemActionMessage : PerformItemActionMessage { public override BehaviorType BehaviorType => BehaviorType.Instant; public PerformInstantItemActionMessage(ItemActionType actionType, EntityUid item) : base(actionType, item) { } } public interface IToggleActionMessage { bool ToggleOn { get; } } public interface ITargetPointActionMessage { /// /// Targeted local coordinates /// EntityCoordinates Target { get; } } public interface ITargetEntityActionMessage { /// /// Targeted entity /// EntityUid Target { get; } } /// /// A message that tells server we want to toggle on the indicated action. /// [Serializable, NetSerializable] public class PerformToggleOnActionMessage : PerformActionMessage, IToggleActionMessage { public override BehaviorType BehaviorType => BehaviorType.Toggle; public bool ToggleOn => true; public PerformToggleOnActionMessage(ActionType actionType) : base(actionType) { } } /// /// A message that tells server we want to toggle off the indicated action. /// [Serializable, NetSerializable] public class PerformToggleOffActionMessage : PerformActionMessage, IToggleActionMessage { public override BehaviorType BehaviorType => BehaviorType.Toggle; public bool ToggleOn => false; public PerformToggleOffActionMessage(ActionType actionType) : base(actionType) { } } /// /// A message that tells server we want to toggle on the indicated action. /// [Serializable, NetSerializable] public class PerformToggleOnItemActionMessage : PerformItemActionMessage, IToggleActionMessage { public override BehaviorType BehaviorType => BehaviorType.Toggle; public bool ToggleOn => true; public PerformToggleOnItemActionMessage(ItemActionType actionType, EntityUid item) : base(actionType, item) { } } /// /// A message that tells server we want to toggle off the indicated action. /// [Serializable, NetSerializable] public class PerformToggleOffItemActionMessage : PerformItemActionMessage, IToggleActionMessage { public override BehaviorType BehaviorType => BehaviorType.Toggle; public bool ToggleOn => false; public PerformToggleOffItemActionMessage(ItemActionType actionType, EntityUid item) : base(actionType, item) { } } /// /// A message that tells server we want to target the provided point with a particular action. /// [Serializable, NetSerializable] public class PerformTargetPointActionMessage : PerformActionMessage, ITargetPointActionMessage { public override BehaviorType BehaviorType => BehaviorType.TargetPoint; private readonly EntityCoordinates _target; public EntityCoordinates Target => _target; public PerformTargetPointActionMessage(ActionType actionType, EntityCoordinates target) : base(actionType) { _target = target; } } /// /// A message that tells server we want to target the provided point with a particular action. /// [Serializable, NetSerializable] public class PerformTargetPointItemActionMessage : PerformItemActionMessage, ITargetPointActionMessage { private readonly EntityCoordinates _target; public EntityCoordinates Target => _target; public override BehaviorType BehaviorType => BehaviorType.TargetPoint; public PerformTargetPointItemActionMessage(ItemActionType actionType, EntityUid item, EntityCoordinates target) : base(actionType, item) { _target = target; } } /// /// A message that tells server we want to target the provided entity with a particular action. /// [Serializable, NetSerializable] public class PerformTargetEntityActionMessage : PerformActionMessage, ITargetEntityActionMessage { public override BehaviorType BehaviorType => BehaviorType.TargetEntity; private readonly EntityUid _target; public EntityUid Target => _target; public PerformTargetEntityActionMessage(ActionType actionType, EntityUid target) : base(actionType) { _target = target; } } /// /// A message that tells server we want to target the provided entity with a particular action. /// [Serializable, NetSerializable] public class PerformTargetEntityItemActionMessage : PerformItemActionMessage, ITargetEntityActionMessage { public override BehaviorType BehaviorType => BehaviorType.TargetEntity; private readonly EntityUid _target; public EntityUid Target => _target; public PerformTargetEntityItemActionMessage(ItemActionType actionType, EntityUid item, EntityUid target) : base(actionType, item) { _target = target; } } }