diff --git a/Content.Server/AI/Considerations/Bots/CanInjectCon.cs b/Content.Server/AI/Considerations/Bots/CanInjectCon.cs new file mode 100644 index 0000000000..02169eac3a --- /dev/null +++ b/Content.Server/AI/Considerations/Bots/CanInjectCon.cs @@ -0,0 +1,38 @@ +using Content.Server.AI.WorldState; +using Content.Server.AI.WorldState.States; +using Content.Server.AI.Tracking; +using Content.Shared.Damage; +using Content.Shared.MobState.Components; +using Content.Server.Silicons.Bots; + +namespace Content.Server.AI.Utility.Considerations.Bot +{ + public sealed class CanInjectCon : Consideration + { + protected override float GetScore(Blackboard context) + { + var entMan = IoCManager.Resolve(); + var target = context.GetState().GetValue(); + + if (target == null || !entMan.TryGetComponent(target, out DamageableComponent? damageableComponent)) + return 0; + + if (entMan.TryGetComponent(target, out RecentlyInjectedComponent? recently)) + return 0f; + + if (!entMan.TryGetComponent(target, out MobStateComponent? mobState) || mobState.IsDead()) + return 0f; + + if (damageableComponent.TotalDamage == 0) + return 0f; + + if (damageableComponent.TotalDamage <= MedibotComponent.StandardMedDamageThreshold) + return 1f; + + if (damageableComponent.TotalDamage >= MedibotComponent.EmergencyMedDamageThreshold) + return 1f; + + return 0f; + } + } +} diff --git a/Content.Server/AI/EntitySystems/InjectNearbySystem.cs b/Content.Server/AI/EntitySystems/InjectNearbySystem.cs new file mode 100644 index 0000000000..f702f5db48 --- /dev/null +++ b/Content.Server/AI/EntitySystems/InjectNearbySystem.cs @@ -0,0 +1,74 @@ +using Content.Server.Chemistry.Components.SolutionManager; +using Content.Server.Chemistry.EntitySystems; +using Content.Server.AI.Tracking; +using Content.Server.Popups; +using Content.Server.Chat.Systems; +using Content.Server.Silicons.Bots; +using Content.Shared.MobState.Components; +using Content.Shared.Damage; +using Content.Shared.Interaction; +using Robust.Shared.Player; +using Robust.Shared.Audio; + +namespace Content.Server.AI.EntitySystems +{ + public sealed class InjectNearbySystem : EntitySystem + { + [Dependency] private readonly EntityLookupSystem _lookup = default!; + [Dependency] private readonly SolutionContainerSystem _solutionSystem = default!; + [Dependency] private readonly PopupSystem _popupSystem = default!; + [Dependency] private readonly ChatSystem _chat = default!; + [Dependency] private readonly SharedInteractionSystem _interactionSystem = default!; + + public EntityUid GetNearbyInjectable(EntityUid medibot, float range = 4) + { + foreach (var entity in _lookup.GetEntitiesInRange(medibot, range)) + { + if (HasComp(entity) && HasComp(entity)) + return entity; + } + + return default; + } + + public bool Inject(EntityUid medibot, EntityUid target) + { + if (!TryComp(medibot, out var botComp)) + return false; + + if (!TryComp(target, out var damage)) + return false; + + if (!_solutionSystem.TryGetInjectableSolution(target, out var injectable)) + return false; + + if (!_interactionSystem.InRangeUnobstructed(medibot, target)) + return true; // return true lets the bot reattempt the action on the same target + + if (damage.TotalDamage == 0) + return false; + + if (damage.TotalDamage <= MedibotComponent.StandardMedDamageThreshold) + { + _solutionSystem.TryAddReagent(target, injectable, botComp.StandardMed, botComp.StandardMedInjectAmount, out var accepted); + EnsureComp(target); + _popupSystem.PopupEntity(Loc.GetString("hypospray-component-feel-prick-message"), target, Filter.Entities(target)); + SoundSystem.Play("/Audio/Items/hypospray.ogg", Filter.Pvs(target), target); + _chat.TrySendInGameICMessage(medibot, Loc.GetString("medibot-finish-inject"), InGameICChatType.Speak, false); + return true; + } + + if (damage.TotalDamage >= MedibotComponent.EmergencyMedDamageThreshold) + { + _solutionSystem.TryAddReagent(target, injectable, botComp.EmergencyMed, botComp.EmergencyMedInjectAmount, out var accepted); + EnsureComp(target); + _popupSystem.PopupEntity(Loc.GetString("hypospray-component-feel-prick-message"), target, Filter.Entities(target)); + SoundSystem.Play("/Audio/Items/hypospray.ogg", Filter.Pvs(target), target); + _chat.TrySendInGameICMessage(medibot, Loc.GetString("medibot-finish-inject"), InGameICChatType.Speak, false); + return true; + } + + return false; + } + } +} diff --git a/Content.Server/AI/Operators/Bots/InjectOperator.cs b/Content.Server/AI/Operators/Bots/InjectOperator.cs new file mode 100644 index 0000000000..d8061763b9 --- /dev/null +++ b/Content.Server/AI/Operators/Bots/InjectOperator.cs @@ -0,0 +1,24 @@ +using Content.Server.AI.EntitySystems; + +namespace Content.Server.AI.Operators.Bots +{ + public sealed class InjectOperator : AiOperator + { + private EntityUid _medibot; + private EntityUid _target; + public InjectOperator(EntityUid medibot, EntityUid target) + { + _medibot = medibot; + _target = target; + } + + public override Outcome Execute(float frameTime) + { + var injectSystem = IoCManager.Resolve().GetEntitySystem(); + if (injectSystem.Inject(_medibot, _target)) + return Outcome.Success; + + return Outcome.Failed; + } + } +} diff --git a/Content.Server/AI/Operators/Speech/SpeechOperator.cs b/Content.Server/AI/Operators/Speech/SpeechOperator.cs new file mode 100644 index 0000000000..19fa4698ae --- /dev/null +++ b/Content.Server/AI/Operators/Speech/SpeechOperator.cs @@ -0,0 +1,22 @@ +using Content.Server.Chat.Systems; + +namespace Content.Server.AI.Operators.Speech +{ + public sealed class SpeakOperator : AiOperator + { + private EntityUid _speaker; + private string _speechString; + public SpeakOperator(EntityUid speaker, string speechString) + { + _speaker = speaker; + _speechString = speechString; + } + + public override Outcome Execute(float frameTime) + { + var chatSystem = IoCManager.Resolve().GetEntitySystem(); + chatSystem.TrySendInGameICMessage(_speaker, _speechString, InGameICChatType.Speak, false); + return Outcome.Success; + } + } +} diff --git a/Content.Server/AI/Tracking/RecentlyInjectedComponent.cs b/Content.Server/AI/Tracking/RecentlyInjectedComponent.cs new file mode 100644 index 0000000000..ffcc025baa --- /dev/null +++ b/Content.Server/AI/Tracking/RecentlyInjectedComponent.cs @@ -0,0 +1,12 @@ +namespace Content.Server.AI.Tracking +{ + /// Added when a medibot injects someone + /// So they don't get injected again for at least a minute. + [RegisterComponent] + public sealed class RecentlyInjectedComponent : Component + { + public float Accumulator = 0f; + + public TimeSpan RemoveTime = TimeSpan.FromMinutes(1); + } +} diff --git a/Content.Server/AI/Tracking/RecentlyInjectedSystem.cs b/Content.Server/AI/Tracking/RecentlyInjectedSystem.cs new file mode 100644 index 0000000000..0a9ad16a4d --- /dev/null +++ b/Content.Server/AI/Tracking/RecentlyInjectedSystem.cs @@ -0,0 +1,25 @@ +namespace Content.Server.AI.Tracking +{ + public sealed class RecentlyInjectedSystem : EntitySystem + { + + Queue RemQueue = new(); + public override void Update(float frameTime) + { + base.Update(frameTime); + foreach (var toRemove in RemQueue) + { + RemComp(toRemove); + } + RemQueue.Clear(); + foreach (var entity in EntityQuery()) + { + entity.Accumulator += frameTime; + if (entity.Accumulator < entity.RemoveTime.TotalSeconds) + continue; + entity.Accumulator = 0; + RemQueue.Enqueue(entity.Owner); + } + } + } +} diff --git a/Content.Server/AI/Utility/Actions/Bots/InjectNearby.cs b/Content.Server/AI/Utility/Actions/Bots/InjectNearby.cs new file mode 100644 index 0000000000..13ec3ab469 --- /dev/null +++ b/Content.Server/AI/Utility/Actions/Bots/InjectNearby.cs @@ -0,0 +1,57 @@ +using Content.Server.AI.Operators; +using Content.Server.AI.Operators.Generic; +using Content.Server.AI.Operators.Movement; +using Content.Server.AI.Operators.Bots; +using Content.Server.AI.Operators.Speech; +using Content.Server.AI.WorldState; +using Content.Server.AI.Utility.Considerations.Containers; +using Content.Server.AI.Utility.Considerations; +using Content.Server.AI.Utility.Considerations.ActionBlocker; +using Content.Server.AI.WorldState.States.Movement; +using Content.Server.AI.WorldState.States; +using Content.Server.AI.Utility.Considerations.Bot; + + +namespace Content.Server.AI.Utility.Actions.Bots +{ + public sealed class InjectNearby : UtilityAction + { + public EntityUid Target { get; set; } = default!; + + public override void SetupOperators(Blackboard context) + { + MoveToEntityOperator moveOperator = new MoveToEntityOperator(Owner, Target); + float waitTime = 3f; + + ActionOperators = new Queue(new AiOperator[] + { + moveOperator, + new SpeakOperator(Owner, Loc.GetString("medibot-start-inject")), + new WaitOperator(waitTime), + new InjectOperator(Owner, Target), + }); + } + + protected override void UpdateBlackboard(Blackboard context) + { + base.UpdateBlackboard(context); + context.GetState().SetValue(Target); + context.GetState().SetValue(Target); + } + + protected override IReadOnlyCollection> GetConsiderations(Blackboard context) + { + var considerationsManager = IoCManager.Resolve(); + + return new[] + { + considerationsManager.Get() + .BoolCurve(context), + considerationsManager.Get() + .BoolCurve(context), + considerationsManager.Get() + .BoolCurve(context), + }; + } + } +} diff --git a/Content.Server/AI/Utility/ExpendableActions/Bots/InjectNearbyExp.cs b/Content.Server/AI/Utility/ExpendableActions/Bots/InjectNearbyExp.cs new file mode 100644 index 0000000000..0e74889841 --- /dev/null +++ b/Content.Server/AI/Utility/ExpendableActions/Bots/InjectNearbyExp.cs @@ -0,0 +1,40 @@ +using Content.Server.AI.Components; +using Content.Server.AI.EntitySystems; +using Content.Server.AI.Utility.Actions; +using Content.Server.AI.Utility.Actions.Bots; +using Content.Server.AI.Utility.Considerations; +using Content.Server.AI.WorldState; +using Content.Server.AI.WorldState.States; +using Content.Server.AI.Utility.Considerations.ActionBlocker; +using Content.Server.Silicons.Bots; + +namespace Content.Server.AI.Utility.ExpandableActions.Bots +{ + public sealed class InjectNearbyExp : ExpandableUtilityAction + { + public override float Bonus => 30; + IEntityManager _entMan = IoCManager.Resolve(); + + protected override IReadOnlyCollection> GetCommonConsiderations(Blackboard context) + { + var considerationsManager = IoCManager.Resolve(); + + return new[] + { + considerationsManager.Get() + .BoolCurve(context), + }; + } + public override IEnumerable GetActions(Blackboard context) + { + var owner = context.GetState().GetValue(); + if (!_entMan.TryGetComponent(owner, out NPCComponent? controller) + || !_entMan.TryGetComponent(owner, out MedibotComponent? bot)) + { + throw new InvalidOperationException(); + } + + yield return new InjectNearby() {Owner = owner, Target = EntitySystem.Get().GetNearbyInjectable(Owner), Bonus = Bonus}; + } + } +} diff --git a/Content.Server/Silicons/Bots/MedibotComponent.cs b/Content.Server/Silicons/Bots/MedibotComponent.cs new file mode 100644 index 0000000000..8d6083a24e --- /dev/null +++ b/Content.Server/Silicons/Bots/MedibotComponent.cs @@ -0,0 +1,31 @@ +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; +using Content.Shared.Chemistry.Reagent; + +namespace Content.Server.Silicons.Bots +{ + [RegisterComponent] + public sealed class MedibotComponent : Component + { + /// + /// Med the bot will inject when UNDER the standard med damage threshold. + /// + [DataField("standardMed", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string StandardMed = "Tricordrazine"; + + [DataField("standardMedInjectAmount")] + public float StandardMedInjectAmount = 15f; + public const float StandardMedDamageThreshold = 50f; + + /// + /// Med the bot will inject when OVER the emergency med damage threshold. + /// + [DataField("emergencyMed", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string EmergencyMed = "Inaprovaline"; + + [DataField("emergencyMedInjectAmount")] + public float EmergencyMedInjectAmount = 15f; + + public const float EmergencyMedDamageThreshold = 100f; + + } +} diff --git a/Resources/Locale/en-US/ai/medibot.ftl b/Resources/Locale/en-US/ai/medibot.ftl new file mode 100644 index 0000000000..b645220119 --- /dev/null +++ b/Resources/Locale/en-US/ai/medibot.ftl @@ -0,0 +1,2 @@ +medibot-start-inject = Hold still, please. +medibot-finish-inject = All done. diff --git a/Resources/Prototypes/Entities/Markers/Spawners/bots.yml b/Resources/Prototypes/Entities/Markers/Spawners/bots.yml new file mode 100644 index 0000000000..8524721c2b --- /dev/null +++ b/Resources/Prototypes/Entities/Markers/Spawners/bots.yml @@ -0,0 +1,26 @@ +- type: entity + name: medibot spawner + id: SpawnMobMedibot + parent: MarkerBase + components: + - type: Sprite + layers: + - state: green + - texture: Mobs/Silicon/Bots/medibot.rsi/medibot.png + - type: ConditionalSpawner + prototypes: + - MobMedibot + +- type: entity + name: cleanbot spawner + id: SpawnMobCleanBot + parent: MarkerBase + components: + - type: Sprite + layers: + - state: green + - texture: Mobs/Silicon/Bots/cleanbot.rsi/cleanbot.png + - type: ConditionalSpawner + prototypes: + - MobCleanBot + diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml b/Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml index 579025fce7..14078730f5 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml @@ -156,3 +156,22 @@ maxVol: 30 - type: DrainableSolution solution: drainBuffer + +- type: entity + parent: MobSiliconBase + id: MobMedibot + name: medibot + description: No substitute for a doctor, but better than nothing. + components: + - type: UtilityNPC + behaviorSets: + - MediBot + - type: Medibot + - type: Sprite + drawdepth: Mobs + sprite: Mobs/Silicon/Bots/medibot.rsi + state: medibot + - type: Speech + - type: Construction + graph: MediBot + node: bot diff --git a/Resources/Prototypes/Entities/Objects/Specific/Medical/healthanalyzer.yml b/Resources/Prototypes/Entities/Objects/Specific/Medical/healthanalyzer.yml index a200ebb516..f74f9a6e7c 100644 --- a/Resources/Prototypes/Entities/Objects/Specific/Medical/healthanalyzer.yml +++ b/Resources/Prototypes/Entities/Objects/Specific/Medical/healthanalyzer.yml @@ -15,6 +15,9 @@ - key: enum.HealthAnalyzerUiKey.Key type: HealthAnalyzerBoundUserInterface - type: HealthAnalyzer + - type: Tag + tags: + - DiscreteHealthAnalyzer - type: entity parent: HandheldHealthAnalyzer diff --git a/Resources/Prototypes/Entities/Objects/Specific/Medical/medkits.yml b/Resources/Prototypes/Entities/Objects/Specific/Medical/medkits.yml index 4ae1d15fa0..60f825e536 100644 --- a/Resources/Prototypes/Entities/Objects/Specific/Medical/medkits.yml +++ b/Resources/Prototypes/Entities/Objects/Specific/Medical/medkits.yml @@ -14,6 +14,9 @@ size: 30 sprite: Objects/Specific/Medical/firstaidkits.rsi HeldPrefix: firstaid + - type: Tag + tags: + - Medkit - type: entity name: burn treatment kit diff --git a/Resources/Prototypes/NPCs/behavior_sets.yml b/Resources/Prototypes/NPCs/behavior_sets.yml index a930c4b2fb..801cb4d7bf 100644 --- a/Resources/Prototypes/NPCs/behavior_sets.yml +++ b/Resources/Prototypes/NPCs/behavior_sets.yml @@ -33,6 +33,11 @@ - BufferNearbyPuddlesExp - WanderAndWait +- type: behaviorSet + id: MediBot + actions: + - InjectNearbyExp + - type: behaviorSet id: Spirate actions: diff --git a/Resources/Prototypes/Recipes/Crafting/Graphs/bots/medibot.yml b/Resources/Prototypes/Recipes/Crafting/Graphs/bots/medibot.yml new file mode 100644 index 0000000000..db3626c6ad --- /dev/null +++ b/Resources/Prototypes/Recipes/Crafting/Graphs/bots/medibot.yml @@ -0,0 +1,33 @@ +- type: constructionGraph + id: MediBot + start: start + graph: + - node: start + edges: + - to: bot + steps: + - tag: Medkit + icon: + sprite: Objects/Specific/Medical/firstaidkits.rsi + state: firstaid + name: medkit + - tag: DiscreteHealthAnalyzer + icon: + sprite: Objects/Specific/Medical/healthanalyzer.rsi + state: analyzer + name: health analyzer + doAfter: 2 + - prototype: ProximitySensor + icon: + sprite: Objects/Misc/proximity_sensor.rsi + state: icon + name: promixmity sensor + doAfter: 2 + - tag: BorgArm + icon: + sprite: Mobs/Silicon/drone.rsi + state: l_hand + name: borg arm + doAfter: 2 + - node: bot + entity: MobMedibot diff --git a/Resources/Prototypes/Recipes/Crafting/bots.yml b/Resources/Prototypes/Recipes/Crafting/bots.yml index e6abeaa012..d9449b3890 100644 --- a/Resources/Prototypes/Recipes/Crafting/bots.yml +++ b/Resources/Prototypes/Recipes/Crafting/bots.yml @@ -23,3 +23,16 @@ icon: sprite: Mobs/Silicon/Bots/honkbot.rsi state: honkbot + +- type: construction + name: medibot + id: medibot + graph: MediBot + startNode: start + targetNode: bot + category: Utilities + objectType: Item + description: This bot can help supply basic healing. + icon: + sprite: Mobs/Silicon/Bots/medibot.rsi + state: medibot diff --git a/Resources/Prototypes/tags.yml b/Resources/Prototypes/tags.yml index 2c0db23646..68780b7d09 100644 --- a/Resources/Prototypes/tags.yml +++ b/Resources/Prototypes/tags.yml @@ -144,6 +144,9 @@ - type: Tag id: Dice +- type: Tag + id: DiscreteHealthAnalyzer #So construction recipes don't eat medical PDAs + - type: Tag id: Document @@ -299,6 +302,9 @@ - type: Tag id: Matchstick +- type: Tag + id: Medkit + - type: Tag id: Metal diff --git a/Resources/Textures/Mobs/Silicon/Bots/medibot.rsi/medibot.png b/Resources/Textures/Mobs/Silicon/Bots/medibot.rsi/medibot.png new file mode 100644 index 0000000000..cae7efa381 Binary files /dev/null and b/Resources/Textures/Mobs/Silicon/Bots/medibot.rsi/medibot.png differ diff --git a/Resources/Textures/Mobs/Silicon/Bots/medibot.rsi/meta.json b/Resources/Textures/Mobs/Silicon/Bots/medibot.rsi/meta.json new file mode 100644 index 0000000000..a156bca4fe --- /dev/null +++ b/Resources/Textures/Mobs/Silicon/Bots/medibot.rsi/meta.json @@ -0,0 +1,15 @@ +{ + "copyright" : "Taken from https://github.com/tgstation/tgstation", + "license" : "CC-BY-SA-3.0", + "size" : { + "x" : 32, + "y" : 32 + }, + "states" : [ + { + "directions" : 1, + "name" : "medibot" + } + ], + "version" : 1 +}