diff --git a/Content.Client/Silicons/Bots/HugBotSystem.cs b/Content.Client/Silicons/Bots/HugBotSystem.cs
new file mode 100644
index 0000000000..b40fe51974
--- /dev/null
+++ b/Content.Client/Silicons/Bots/HugBotSystem.cs
@@ -0,0 +1,5 @@
+using Content.Shared.Silicons.Bots;
+
+namespace Content.Client.Silicons.Bots;
+
+public sealed partial class HugBotSystem : SharedHugBotSystem;
diff --git a/Content.Server/NPC/HTN/Preconditions/IsEmaggedPrecondition.cs b/Content.Server/NPC/HTN/Preconditions/IsEmaggedPrecondition.cs
new file mode 100644
index 0000000000..fe76968f34
--- /dev/null
+++ b/Content.Server/NPC/HTN/Preconditions/IsEmaggedPrecondition.cs
@@ -0,0 +1,31 @@
+using Content.Shared.Emag.Systems;
+
+namespace Content.Server.NPC.HTN.Preconditions;
+
+///
+/// A precondition which is met if the NPC is emagged with , as computed by
+/// . This is useful for changing NPC behavior in the case that the NPC is emagged,
+/// eg. like a helper NPC bot turning evil.
+///
+public sealed partial class IsEmaggedPrecondition : HTNPrecondition
+{
+ private EmagSystem _emag;
+
+ ///
+ /// The type of emagging to check for.
+ ///
+ [DataField]
+ public EmagType EmagType = EmagType.Interaction;
+
+ public override void Initialize(IEntitySystemManager sysManager)
+ {
+ base.Initialize(sysManager);
+ _emag = sysManager.GetEntitySystem();
+ }
+
+ public override bool IsMet(NPCBlackboard blackboard)
+ {
+ var owner = blackboard.GetValue(NPCBlackboard.Owner);
+ return _emag.CheckFlag(owner, EmagType);
+ }
+}
diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Combat/Melee/MeleeAttackOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Combat/Melee/MeleeAttackOperator.cs
new file mode 100644
index 0000000000..a1440acb2e
--- /dev/null
+++ b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Combat/Melee/MeleeAttackOperator.cs
@@ -0,0 +1,70 @@
+using Content.Server.NPC.HTN.PrimitiveTasks.Operators.Interactions;
+using Content.Shared.CombatMode;
+using Content.Shared.Weapons.Melee;
+
+namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators.Combat.Melee;
+
+///
+/// Something between and , this operator causes the NPC
+/// to attempt a SINGLE melee attack on the specified
+/// target.
+///
+public sealed partial class MeleeAttackOperator : HTNOperator
+{
+ [Dependency] private readonly IEntityManager _entManager = default!;
+ private SharedMeleeWeaponSystem _melee;
+
+ ///
+ /// Key that contains the target entity.
+ ///
+ [DataField(required: true)]
+ public string TargetKey = default!;
+
+ public override void Initialize(IEntitySystemManager sysManager)
+ {
+ base.Initialize(sysManager);
+ _melee = sysManager.GetEntitySystem();
+ }
+
+ public override void TaskShutdown(NPCBlackboard blackboard, HTNOperatorStatus status)
+ {
+ base.TaskShutdown(blackboard, status);
+
+ ExitCombatMode(blackboard);
+ }
+
+ public override void PlanShutdown(NPCBlackboard blackboard)
+ {
+ base.PlanShutdown(blackboard);
+
+ ExitCombatMode(blackboard);
+ }
+
+ public override HTNOperatorStatus Update(NPCBlackboard blackboard, float frameTime)
+ {
+ var owner = blackboard.GetValue(NPCBlackboard.Owner);
+
+ if (!_entManager.TryGetComponent(owner, out var combatMode) ||
+ !_melee.TryGetWeapon(owner, out var weaponUid, out var weapon))
+ {
+ return HTNOperatorStatus.Failed;
+ }
+
+ _entManager.System().SetInCombatMode(owner, true, combatMode);
+
+
+ if (!blackboard.TryGetValue(TargetKey, out var target, _entManager) ||
+ !_melee.AttemptLightAttack(owner, weaponUid, weapon, target))
+ {
+ return HTNOperatorStatus.Continuing;
+ }
+
+ return HTNOperatorStatus.Finished;
+ }
+
+ private void ExitCombatMode(NPCBlackboard blackboard)
+ {
+ var owner = blackboard.GetValue(NPCBlackboard.Owner);
+ _entManager.System().SetInCombatMode(owner, false);
+ }
+}
diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/SpeakOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/SpeakOperator.cs
index 8a4c655a39..f69a0771f9 100644
--- a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/SpeakOperator.cs
+++ b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/SpeakOperator.cs
@@ -1,13 +1,21 @@
using Content.Server.Chat.Systems;
+using Content.Shared.Dataset;
+using Content.Shared.Random.Helpers;
+using JetBrains.Annotations;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+using static Content.Server.NPC.HTN.PrimitiveTasks.Operators.SpeakOperator.SpeakOperatorSpeech;
namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators;
public sealed partial class SpeakOperator : HTNOperator
{
private ChatSystem _chat = default!;
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
[DataField(required: true)]
- public string Speech = string.Empty;
+ public SpeakOperatorSpeech Speech;
///
/// Whether to hide message from chat window and logs.
@@ -18,15 +26,51 @@ public sealed partial class SpeakOperator : HTNOperator
public override void Initialize(IEntitySystemManager sysManager)
{
base.Initialize(sysManager);
-
_chat = sysManager.GetEntitySystem();
}
public override HTNOperatorStatus Update(NPCBlackboard blackboard, float frameTime)
{
+ LocId speechLocId;
+ switch (Speech)
+ {
+ case LocalizedSetSpeakOperatorSpeech localizedDataSet:
+ if (!_proto.TryIndex(localizedDataSet.LineSet, out var speechSet))
+ return HTNOperatorStatus.Failed;
+ speechLocId = _random.Pick(speechSet);
+ break;
+ case SingleSpeakOperatorSpeech single:
+ speechLocId = single.Line;
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(Speech));
+ }
+
var speaker = blackboard.GetValue(NPCBlackboard.Owner);
- _chat.TrySendInGameICMessage(speaker, Loc.GetString(Speech), InGameICChatType.Speak, hideChat: Hidden, hideLog: Hidden);
+ _chat.TrySendInGameICMessage(
+ speaker,
+ Loc.GetString(speechLocId),
+ InGameICChatType.Speak,
+ hideChat: Hidden,
+ hideLog: Hidden
+ );
return base.Update(blackboard, frameTime);
}
+
+ [ImplicitDataDefinitionForInheritors, MeansImplicitUse]
+ public abstract partial class SpeakOperatorSpeech
+ {
+ public sealed partial class SingleSpeakOperatorSpeech : SpeakOperatorSpeech
+ {
+ [DataField(required: true)]
+ public string Line;
+ }
+
+ public sealed partial class LocalizedSetSpeakOperatorSpeech : SpeakOperatorSpeech
+ {
+ [DataField(required: true)]
+ public ProtoId LineSet;
+ }
+ }
}
diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Specific/RaiseEventForOwnerOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Specific/RaiseEventForOwnerOperator.cs
new file mode 100644
index 0000000000..4bbdbb3473
--- /dev/null
+++ b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Specific/RaiseEventForOwnerOperator.cs
@@ -0,0 +1,47 @@
+namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators.Specific;
+
+///
+/// Raises an on the owner. The event will contain
+/// the specified , and if not null, the value of .
+///
+public sealed partial class RaiseEventForOwnerOperator : HTNOperator
+{
+ [Dependency] private readonly IEntityManager _entMan = default!;
+
+ ///
+ /// The conceptual "target" of this event. Note that this is NOT the entity for which the event is raised. If null,
+ /// will be null.
+ ///
+ [DataField]
+ public string? TargetKey;
+
+ ///
+ /// The data contained in the raised event. Since is itself pretty meaningless, this is
+ /// included to give some context of what the event is actually supposed to mean.
+ ///
+ [DataField(required: true)]
+ public EntityEventArgs Args;
+
+ public override HTNOperatorStatus Update(NPCBlackboard blackboard, float frameTime)
+ {
+ _entMan.EventBus.RaiseLocalEvent(
+ blackboard.GetValue(NPCBlackboard.Owner),
+ new HTNRaisedEvent(
+ blackboard.GetValue(NPCBlackboard.Owner),
+ TargetKey is { } targetKey ? blackboard.GetValue(targetKey) : null,
+ Args
+ )
+ );
+
+ return HTNOperatorStatus.Finished;
+ }
+}
+
+public sealed partial class HTNRaisedEvent(EntityUid owner, EntityUid? target, EntityEventArgs args) : EntityEventArgs
+{
+ // Owner and target are both included here in case we want to add a "RaiseEventForTargetOperator" in the future
+ // while reusing this event.
+ public EntityUid Owner = owner;
+ public EntityUid? Target = target;
+ public EntityEventArgs Args = args;
+}
diff --git a/Content.Server/NPC/Queries/Queries/ComponentFilter.cs b/Content.Server/NPC/Queries/Queries/ComponentFilter.cs
index 0df4bd902f..5ba63bf6f7 100644
--- a/Content.Server/NPC/Queries/Queries/ComponentFilter.cs
+++ b/Content.Server/NPC/Queries/Queries/ComponentFilter.cs
@@ -9,4 +9,11 @@ public sealed partial class ComponentFilter : UtilityQueryFilter
///
[DataField("components", required: true)]
public ComponentRegistry Components = new();
+
+ ///
+ /// If true, this filter retains entities with ALL of the specified components. If false, this filter removes
+ /// entities with ANY of the specified components.
+ ///
+ [DataField]
+ public bool RetainWithComp = true;
}
diff --git a/Content.Server/NPC/Systems/NPCUtilitySystem.cs b/Content.Server/NPC/Systems/NPCUtilitySystem.cs
index 813626a1c4..81f9415121 100644
--- a/Content.Server/NPC/Systems/NPCUtilitySystem.cs
+++ b/Content.Server/NPC/Systems/NPCUtilitySystem.cs
@@ -512,11 +512,12 @@ public sealed class NPCUtilitySystem : EntitySystem
{
foreach (var comp in compFilter.Components)
{
- if (HasComp(ent, comp.Value.Component.GetType()))
- continue;
-
- _entityList.Add(ent);
- break;
+ var hasComp = HasComp(ent, comp.Value.Component.GetType());
+ if (!compFilter.RetainWithComp == hasComp)
+ {
+ _entityList.Add(ent);
+ break;
+ }
}
}
diff --git a/Content.Server/Silicons/Bots/HugBotSystem.cs b/Content.Server/Silicons/Bots/HugBotSystem.cs
new file mode 100644
index 0000000000..4be948f434
--- /dev/null
+++ b/Content.Server/Silicons/Bots/HugBotSystem.cs
@@ -0,0 +1,65 @@
+using Content.Server.NPC.HTN.PrimitiveTasks.Operators.Specific;
+using Content.Shared.Silicons.Bots;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Silicons.Bots;
+
+///
+/// Beyond what does, this system manages the "lifecycle" of
+/// .
+///
+public sealed class HugBotSystem : SharedHugBotSystem
+{
+ [Dependency] private readonly IGameTiming _gameTiming = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnHtnRaisedEvent);
+ }
+
+ private void OnHtnRaisedEvent(Entity entity, ref HTNRaisedEvent args)
+ {
+ if (args.Args is not HugBotDidHugEvent ||
+ args.Target is not {} target)
+ return;
+
+ var ev = new HugBotHugEvent(GetNetEntity(entity));
+ RaiseLocalEvent(target, ev);
+
+ ApplyHugBotCooldown(entity, target);
+ }
+
+ ///
+ /// Applies to based on the configuration of
+ /// .
+ ///
+ public void ApplyHugBotCooldown(Entity hugBot, EntityUid target)
+ {
+ var hugged = EnsureComp(target);
+ hugged.CooldownCompleteAfter = _gameTiming.CurTime + hugBot.Comp.HugCooldown;
+ }
+
+ public override void Update(float frameTime)
+ {
+ // Iterate through all RecentlyHuggedByHugBot entities...
+ var huggedEntities = AllEntityQuery();
+ while (huggedEntities.MoveNext(out var huggedEnt, out var huggedComp))
+ {
+ // ... and if their cooldown is complete...
+ if (huggedComp.CooldownCompleteAfter <= _gameTiming.CurTime)
+ {
+ // ... remove it, allowing them to receive the blessing of hugs once more.
+ RemCompDeferred(huggedEnt);
+ }
+ }
+ }
+}
+
+///
+/// This event is indirectly raised (by being ) on a HugBot when it hugs (or emaggedly
+/// punches) an entity.
+///
+[Serializable, DataDefinition]
+public sealed partial class HugBotDidHugEvent : EntityEventArgs;
diff --git a/Content.Server/Silicons/Bots/RecentlyHuggedByHugBotComponent.cs b/Content.Server/Silicons/Bots/RecentlyHuggedByHugBotComponent.cs
new file mode 100644
index 0000000000..c6c7d47e8d
--- /dev/null
+++ b/Content.Server/Silicons/Bots/RecentlyHuggedByHugBotComponent.cs
@@ -0,0 +1,15 @@
+using Content.Shared.Silicons.Bots;
+
+namespace Content.Server.Silicons.Bots;
+
+///
+/// This marker component indicates that its entity has been recently hugged by a HugBot and should not be hugged again
+/// before a cooldown period in order to prevent hug spam.
+///
+///
+[RegisterComponent, AutoGenerateComponentPause]
+public sealed partial class RecentlyHuggedByHugBotComponent : Component
+{
+ [DataField, AutoPausedField]
+ public TimeSpan CooldownCompleteAfter = TimeSpan.MinValue;
+}
diff --git a/Content.Shared/Silicons/Bots/HugBotComponent.cs b/Content.Shared/Silicons/Bots/HugBotComponent.cs
new file mode 100644
index 0000000000..64281b6316
--- /dev/null
+++ b/Content.Shared/Silicons/Bots/HugBotComponent.cs
@@ -0,0 +1,12 @@
+namespace Content.Shared.Silicons.Bots;
+
+///
+/// This component describes how a HugBot hugs.
+///
+///
+[RegisterComponent, AutoGenerateComponentState]
+public sealed partial class HugBotComponent : Component
+{
+ [DataField, AutoNetworkedField]
+ public TimeSpan HugCooldown = TimeSpan.FromMinutes(2);
+}
diff --git a/Content.Shared/Silicons/Bots/SharedHugBotSystem.cs b/Content.Shared/Silicons/Bots/SharedHugBotSystem.cs
new file mode 100644
index 0000000000..b5dec71f20
--- /dev/null
+++ b/Content.Shared/Silicons/Bots/SharedHugBotSystem.cs
@@ -0,0 +1,38 @@
+using Content.Shared.Emag.Systems;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Silicons.Bots;
+
+///
+/// This system handles HugBots.
+///
+public abstract class SharedHugBotSystem : EntitySystem
+{
+ [Dependency] private readonly EmagSystem _emag = default!;
+
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnEmagged);
+ }
+
+ private void OnEmagged(Entity entity, ref GotEmaggedEvent args)
+ {
+ if (!_emag.CompareFlag(args.Type, EmagType.Interaction) ||
+ _emag.CheckFlag(entity, EmagType.Interaction) ||
+ !TryComp(entity, out var hugBot))
+ return;
+
+ // HugBot HTN checks for emag state within its own logic, so we don't need to change anything here.
+
+ args.Handled = true;
+ }
+}
+
+///
+/// This event is raised on an entity when it is hugged by a HugBot.
+///
+[Serializable, NetSerializable]
+public sealed partial class HugBotHugEvent(NetEntity hugBot) : EntityEventArgs
+{
+ public readonly NetEntity HugBot = hugBot;
+}
diff --git a/Resources/Locale/en-US/_Moffstation/recipes/tags.ftl b/Resources/Locale/en-US/_Moffstation/recipes/tags.ftl
new file mode 100644
index 0000000000..73b392534a
--- /dev/null
+++ b/Resources/Locale/en-US/_Moffstation/recipes/tags.ftl
@@ -0,0 +1 @@
+construction-graph-tag-boxhug = a box of hugs
diff --git a/Resources/Locale/en-US/npc/hugbot.ftl b/Resources/Locale/en-US/npc/hugbot.ftl
new file mode 100644
index 0000000000..6b38eda30e
--- /dev/null
+++ b/Resources/Locale/en-US/npc/hugbot.ftl
@@ -0,0 +1,26 @@
+hugbot-start-hug-1 = LEVEL 5 HUG DEFICIENCY DETECTED!
+hugbot-start-hug-2 = You look like you need a hug!
+hugbot-start-hug-3 = Aww, somebody needs a hug!
+hugbot-start-hug-4 = Target acquired; Initiating hug routine.
+hugbot-start-hug-5 = Hold still, please.
+hugbot-start-hug-6 = Hugs!
+hugbot-start-hug-7 = Deploying HUG.
+hugbot-start-hug-8 = I am designed to hug, and you WILL be hugged.
+
+hugbot-finish-hug-1 = All done.
+hugbot-finish-hug-2 = Hug routine terminated.
+hugbot-finish-hug-3 = Feel better?
+hugbot-finish-hug-4 = Feel better soon!
+hugbot-finish-hug-5 = You are loved.
+hugbot-finish-hug-6 = You matter.
+hugbot-finish-hug-7 = It always gets better!
+hugbot-finish-hug-8 = Hug: COMPLETE.
+
+hugbot-emagged-finish-hug-1 = Actually, fuck you.
+hugbot-emagged-finish-hug-2 = Nobody loves you.
+hugbot-emagged-finish-hug-3 = Ewww... no.
+hugbot-emagged-finish-hug-4 = It can only get worse from here!
+hugbot-emagged-finish-hug-5 = Fucking crybaby.
+hugbot-emagged-finish-hug-6 = Go die.
+hugbot-emagged-finish-hug-7 = Drop dead.
+hugbot-emagged-finish-hug-8 = You are alone in this universe.
diff --git a/Resources/Prototypes/Body/Prototypes/bot.yml b/Resources/Prototypes/Body/Prototypes/bot.yml
index 848db2a4fd..ae9bd4a77a 100644
--- a/Resources/Prototypes/Body/Prototypes/bot.yml
+++ b/Resources/Prototypes/Body/Prototypes/bot.yml
@@ -5,3 +5,19 @@
slots:
hand 1:
part: LeftArmBorg
+
+# It's like a medibot or a cleanbot except it has two arms to hug :)
+- type: body
+ id: HugBot
+ name: "hugBot"
+ root: box
+ slots:
+ box:
+ part: TorsoBorg
+ connections:
+ - right_arm
+ - left_arm
+ right_arm:
+ part: RightArmBorg
+ left_arm:
+ part: LeftArmBorg
diff --git a/Resources/Prototypes/Catalog/hugbot.yml b/Resources/Prototypes/Catalog/hugbot.yml
new file mode 100644
index 0000000000..469bb1196d
--- /dev/null
+++ b/Resources/Prototypes/Catalog/hugbot.yml
@@ -0,0 +1,17 @@
+- type: localizedDataset
+ id: HugBotStarts
+ values:
+ prefix: hugbot-start-hug-
+ count: 8
+
+- type: localizedDataset
+ id: HugBotFinishes
+ values:
+ prefix: hugbot-finish-hug-
+ count: 8
+
+- type: localizedDataset
+ id: EmaggedHugBotFinishes
+ values:
+ prefix: hugbot-emagged-finish-hug-
+ count: 8
diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml b/Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml
index fd700d7a4c..a538243192 100644
--- a/Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml
+++ b/Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml
@@ -460,3 +460,40 @@
- Supply
- type: Puller
needsHands: false
+
+- type: entity
+ parent: [ MobSiliconBase, MobCombat ]
+ id: MobHugBot
+ name: hugbot
+ description: Awww, who needs a hug?
+ components:
+ - type: Sprite
+ sprite: Mobs/Silicon/Bots/hugbot.rsi
+ state: hugbot
+ - type: Construction
+ graph: HugBot
+ node: bot
+ - type: MovementSpeedModifier
+ baseWalkSpeed: 2
+ baseSprintSpeed: 3
+ - type: MeleeWeapon
+ soundHit:
+ path: /Audio/Weapons/boxingpunch1.ogg
+ angle: 30
+ animation: WeaponArcPunch
+ damage:
+ types:
+ Blunt: 2
+ - type: Anchorable
+ - type: Hands # This probably REALLY needs hand whitelisting, but we NEED hands for hugs, so...
+ - type: ComplexInteraction # Hugging is a complex interaction, apparently.
+ - type: HugBot
+ - type: Body
+ prototype: HugBot
+ - type: HTN
+ rootTask:
+ task: HugBotCompound
+ - type: InteractionPopup
+ interactSuccessString: hugging-success-generic
+ interactSuccessSound: /Audio/Effects/thudswoosh.ogg
+ messagePerceivedByOthers: hugging-success-generic-others
diff --git a/Resources/Prototypes/NPCs/debug.yml b/Resources/Prototypes/NPCs/debug.yml
index c7929be103..802e6b4b57 100644
--- a/Resources/Prototypes/NPCs/debug.yml
+++ b/Resources/Prototypes/NPCs/debug.yml
@@ -68,7 +68,7 @@
- !type:KeyFloatLessPrecondition
key: Count
value: 50
-
+
- !type:HTNPrimitiveTask
operator: !type:RandomOperator
targetKey: IdleTime
@@ -85,6 +85,5 @@
- tasks:
- !type:HTNPrimitiveTask
operator: !type:SpeakOperator
- speech: "fuck!"
-
-
\ No newline at end of file
+ speech: !type:SingleSpeakOperatorSpeech
+ line: "fuck!"
diff --git a/Resources/Prototypes/NPCs/firebot.yml b/Resources/Prototypes/NPCs/firebot.yml
index 2da9da50d2..b3a62280cc 100644
--- a/Resources/Prototypes/NPCs/firebot.yml
+++ b/Resources/Prototypes/NPCs/firebot.yml
@@ -6,8 +6,8 @@
task: DouseFireTargetCompound
- tasks:
- !type:HTNCompoundTask
- task: IdleCompound
-
+ task: IdleCompound
+
- type: htnCompound
id: DouseFireTargetCompound
branches:
@@ -18,8 +18,9 @@
- !type:HTNPrimitiveTask
operator: !type:SpeakOperator
- speech: firebot-fire-detected
- hidden: true
+ speech: !type:SingleSpeakOperatorSpeech
+ line: firebot-fire-detected
+ hidden: true
- !type:HTNPrimitiveTask
operator: !type:MoveToOperator
diff --git a/Resources/Prototypes/NPCs/hugbot.yml b/Resources/Prototypes/NPCs/hugbot.yml
new file mode 100644
index 0000000000..1a56b22b92
--- /dev/null
+++ b/Resources/Prototypes/NPCs/hugbot.yml
@@ -0,0 +1,134 @@
+- type: htnCompound
+ id: HugBotCompound
+ branches:
+ - tasks:
+ - !type:HTNCompoundTask
+ task: HugNearbyCompound
+ - tasks:
+ - !type:HTNCompoundTask
+ task: IdleCompound
+
+- type: htnCompound
+ id: HugNearbyCompound
+ branches:
+ - tasks:
+ # Locate hug recipient.
+ - !type:HTNPrimitiveTask
+ operator: !type:UtilityOperator
+ proto: NearbyNeedingHug
+
+ # Announce intent to hug
+ - !type:HTNPrimitiveTask
+ operator: !type:SpeakOperator
+ speech: !type:LocalizedSetSpeakOperatorSpeech
+ lineSet: HugBotStarts
+ hidden: true
+
+ # Approach hug recipient
+ - !type:HTNPrimitiveTask
+ operator: !type:MoveToOperator
+ pathfindInPlanning: true
+ removeKeyOnFinish: false
+ targetKey: TargetCoordinates
+ pathfindKey: TargetPathfind
+ rangeKey: MeleeRange
+
+ # HUG!!
+ - !type:HTNCompoundTask
+ task: HugBotHugCompound
+
+ # Stick around to enjoy the hug instead of just running up, squeezing and leaving.
+ - !type:HTNPrimitiveTask
+ operator: !type:SetFloatOperator
+ targetKey: IdleTime
+ amount: 1
+ - !type:HTNPrimitiveTask
+ operator: !type:WaitOperator
+ key: IdleTime
+ preconditions:
+ - !type:KeyExistsPrecondition
+ key: IdleTime
+
+ # Special case operator which applies the hugbot cooldown.
+ - !type:HTNPrimitiveTask
+ operator: !type:RaiseEventForOwnerOperator
+ args: !type:HugBotDidHugEvent
+ targetKey: Target
+
+ # Announce that the hug is completed.
+ - !type:HTNCompoundTask
+ task: HugBotFinishSpeakCompound
+
+- type: htnCompound
+ id: HugBotHugCompound
+ branches:
+ # Hit if emagged
+ - preconditions:
+ - !type:IsEmaggedPrecondition
+ tasks:
+ - !type:HTNPrimitiveTask
+ preconditions:
+ - !type:TargetInRangePrecondition
+ targetKey: Target
+ rangeKey: InteractRange
+ operator: !type:MeleeAttackOperator
+ targetKey: Target
+ services:
+ - !type:UtilityService
+ id: HugService
+ proto: NearbyNeedingHug
+ key: Target
+ # Hug otherwise
+ - tasks:
+ - !type:HTNPrimitiveTask
+ preconditions:
+ - !type:TargetInRangePrecondition
+ targetKey: Target
+ rangeKey: InteractRange
+ operator: !type:InteractWithOperator
+ targetKey: Target
+ services:
+ - !type:UtilityService
+ id: HugService
+ proto: NearbyNeedingHug
+ key: Target
+
+- type: htnCompound
+ id: HugBotFinishSpeakCompound
+ branches:
+ # Say mean things if emagged
+ - preconditions:
+ - !type:IsEmaggedPrecondition
+ tasks:
+ - !type:HTNPrimitiveTask
+ operator: !type:SpeakOperator
+ speech: !type:LocalizedSetSpeakOperatorSpeech
+ lineSet: EmaggedHugBotFinishes
+ hidden: true
+ # Say nice thing otherwise
+ - tasks:
+ - !type:HTNPrimitiveTask
+ operator: !type:SpeakOperator
+ speech: !type:LocalizedSetSpeakOperatorSpeech
+ lineSet: HugBotFinishes
+ hidden: true
+
+- type: utilityQuery
+ id: NearbyNeedingHug
+ query:
+ - !type:ComponentQuery
+ components:
+ - type: HumanoidAppearance
+ species: Human # This specific value isn't actually used, so don't worry about it being just `Human`.
+ - !type:ComponentFilter
+ retainWithComp: false
+ components:
+ - type: RecentlyHuggedByHugBot
+ considerations:
+ - !type:TargetDistanceCon
+ curve: !type:PresetCurve
+ preset: TargetDistance
+ - !type:TargetAccessibleCon
+ curve: !type:BoolCurve
+ - !type:TargetInLOSOrCurrentCon
+ curve: !type:BoolCurve
diff --git a/Resources/Prototypes/NPCs/medibot.yml b/Resources/Prototypes/NPCs/medibot.yml
index c0853984ee..1cd6352e16 100644
--- a/Resources/Prototypes/NPCs/medibot.yml
+++ b/Resources/Prototypes/NPCs/medibot.yml
@@ -20,7 +20,8 @@
- !type:HTNPrimitiveTask
operator: !type:SpeakOperator
- speech: medibot-start-inject
+ speech: !type:SingleSpeakOperatorSpeech
+ line: medibot-start-inject
hidden: true
- !type:HTNPrimitiveTask
diff --git a/Resources/Prototypes/Recipes/Crafting/Graphs/bots/hugbot.yml b/Resources/Prototypes/Recipes/Crafting/Graphs/bots/hugbot.yml
new file mode 100644
index 0000000000..56ee3803e0
--- /dev/null
+++ b/Resources/Prototypes/Recipes/Crafting/Graphs/bots/hugbot.yml
@@ -0,0 +1,33 @@
+- type: constructionGraph
+ id: HugBot
+ start: start
+ graph:
+ - node: start
+ edges:
+ - to: bot
+ steps:
+ - tag: BoxHug
+ icon:
+ sprite: Objects/Storage/boxes.rsi
+ state: box_hug
+ name: construction-graph-tag-boxhug
+ - tag: ProximitySensor
+ icon:
+ sprite: Objects/Misc/proximity_sensor.rsi
+ state: icon
+ name: construction-graph-tag-proximity-sensor
+ doAfter: 2
+ - tag: BorgArm
+ icon:
+ sprite: Mobs/Silicon/drone.rsi
+ state: l_hand
+ name: construction-graph-tag-borg-arm
+ doAfter: 2
+ - tag: BorgArm
+ icon:
+ sprite: Mobs/Silicon/drone.rsi
+ state: l_hand
+ name: construction-graph-tag-borg-arm
+ doAfter: 2
+ - node: bot
+ entity: MobHugBot
diff --git a/Resources/Prototypes/Recipes/Crafting/bots.yml b/Resources/Prototypes/Recipes/Crafting/bots.yml
index f76d545e94..d5eb1f941e 100644
--- a/Resources/Prototypes/Recipes/Crafting/bots.yml
+++ b/Resources/Prototypes/Recipes/Crafting/bots.yml
@@ -53,3 +53,11 @@
targetNode: bot
category: construction-category-utilities
objectType: Item
+
+- type: construction
+ id: hugbot
+ graph: HugBot
+ startNode: start
+ targetNode: bot
+ category: construction-category-utilities
+ objectType: Item
diff --git a/Resources/Textures/Mobs/Silicon/Bots/hugbot.rsi/hugbot.png b/Resources/Textures/Mobs/Silicon/Bots/hugbot.rsi/hugbot.png
new file mode 100644
index 0000000000..4d3dbcfa07
Binary files /dev/null and b/Resources/Textures/Mobs/Silicon/Bots/hugbot.rsi/hugbot.png differ
diff --git a/Resources/Textures/Mobs/Silicon/Bots/hugbot.rsi/meta.json b/Resources/Textures/Mobs/Silicon/Bots/hugbot.rsi/meta.json
new file mode 100644
index 0000000000..07ab40378d
--- /dev/null
+++ b/Resources/Textures/Mobs/Silicon/Bots/hugbot.rsi/meta.json
@@ -0,0 +1,14 @@
+{
+ "version": 1,
+ "license": "CC-BY-SA-4.0",
+ "copyright": "Original sprite made by compilatron (Discord) for SS13, relicensed for SS14/Moffstation; modified by Centronias (GitHub) to have two arms",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "hugbot"
+ }
+ ]
+}