diff --git a/Content.Server/Holiday/Christmas/LimitedItemGiverComponent.cs b/Content.Server/Holiday/Christmas/LimitedItemGiverComponent.cs
new file mode 100644
index 0000000000..119987c8cf
--- /dev/null
+++ b/Content.Server/Holiday/Christmas/LimitedItemGiverComponent.cs
@@ -0,0 +1,41 @@
+using Content.Shared.Storage;
+using Robust.Shared.Network;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+namespace Content.Server.Holiday.Christmas;
+
+///
+/// This is used for granting items to lucky souls, exactly once.
+///
+[RegisterComponent, Access(typeof(LimitedItemGiverSystem))]
+public sealed class LimitedItemGiverComponent : Component
+{
+ ///
+ /// Santa knows who you are behind the screen, only one gift per player per round!
+ ///
+ public readonly HashSet GrantedPlayers = new();
+
+ ///
+ /// Selects what entities can be given out by the giver.
+ ///
+ [DataField("spawnEntries", required: true)]
+ public List SpawnEntries = default!;
+
+ ///
+ /// The (localized) message shown upon receiving something.
+ ///
+ [DataField("receivedPopup", required: true)]
+ public string ReceivedPopup = default!;
+
+ ///
+ /// The (localized) message shown upon being denied.
+ ///
+ [DataField("deniedPopup", required: true)]
+ public string DeniedPopup = default!;
+
+ ///
+ /// The holiday required for this giver to work, if any.
+ ///
+ [DataField("requiredHoliday", customTypeSerializer: typeof(PrototypeIdSerializer)), ViewVariables(VVAccess.ReadWrite)]
+ public string? RequiredHoliday = null;
+}
diff --git a/Content.Server/Holiday/Christmas/LimitedItemGiverSystem.cs b/Content.Server/Holiday/Christmas/LimitedItemGiverSystem.cs
new file mode 100644
index 0000000000..d0790dd1a6
--- /dev/null
+++ b/Content.Server/Holiday/Christmas/LimitedItemGiverSystem.cs
@@ -0,0 +1,50 @@
+using Content.Server.Hands.Systems;
+using Content.Server.Mind.Components;
+using Content.Server.Popups;
+using Content.Shared.Interaction;
+using Content.Shared.Storage;
+using Robust.Server.GameObjects;
+
+namespace Content.Server.Holiday.Christmas;
+
+///
+/// This handles handing out items from item givers.
+///
+public sealed class LimitedItemGiverSystem : EntitySystem
+{
+ [Dependency] private readonly HandsSystem _hands = default!;
+ [Dependency] private readonly HolidaySystem _holiday = default!;
+ [Dependency] private readonly PopupSystem _popup = default!;
+ ///
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnInteractHand);
+ }
+
+ private void OnInteractHand(EntityUid uid, LimitedItemGiverComponent component, InteractHandEvent args)
+ {
+ if (!TryComp(args.User, out var actor))
+ return;
+
+ if (component.GrantedPlayers.Contains(actor.PlayerSession.UserId) || (component.RequiredHoliday is not null && !_holiday.IsCurrentlyHoliday(component.RequiredHoliday)))
+ {
+ _popup.PopupEntity(Loc.GetString(component.DeniedPopup), uid, args.User);
+ return;
+ }
+
+ var toGive = EntitySpawnCollection.GetSpawns(component.SpawnEntries);
+ var coords = Transform(args.User).Coordinates;
+
+ foreach (var item in toGive)
+ {
+ if (item is null)
+ continue;
+
+ var spawned = Spawn(item, coords);
+ _hands.PickupOrDrop(args.User, spawned);
+ }
+
+ component.GrantedPlayers.Add(actor.PlayerSession.UserId);
+ _popup.PopupEntity(Loc.GetString(component.ReceivedPopup), uid, args.User);
+ }
+}
diff --git a/Content.Server/Holiday/Christmas/RandomGiftComponent.cs b/Content.Server/Holiday/Christmas/RandomGiftComponent.cs
new file mode 100644
index 0000000000..e76f1ee9c4
--- /dev/null
+++ b/Content.Server/Holiday/Christmas/RandomGiftComponent.cs
@@ -0,0 +1,43 @@
+using Content.Shared.Whitelist;
+using Robust.Shared.Audio;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+namespace Content.Server.Holiday.Christmas;
+
+///
+/// This is used for gifts with COMPLETELY random things.
+///
+[RegisterComponent, Access(typeof(RandomGiftSystem))]
+public sealed class RandomGiftComponent : Component
+{
+ ///
+ /// The wrapper entity to spawn when unwrapping the gift.
+ ///
+ [DataField("wrapper", required: true, customTypeSerializer: typeof(PrototypeIdSerializer))]
+ public string? Wrapper;
+
+ ///
+ /// A sound to play when the items are spawned. For example, gift boxes being unwrapped.
+ ///
+ [DataField("sound", required: true)]
+ public SoundSpecifier? Sound;
+
+ ///
+ /// Whether or not the gift should be limited only to actual items.
+ ///
+ [DataField("insaneMode", required: true), ViewVariables(VVAccess.ReadWrite)]
+ public bool InsaneMode;
+
+ ///
+ /// What entities are allowed to examine this gift to see its contents.
+ ///
+ [DataField("contentsViewers", required: true)]
+ public EntityWhitelist ContentsViewers = default!;
+
+ ///
+ /// The currently selected entity to give out. Used so contents viewers can see inside.
+ ///
+ [DataField("selectedEntity"), ViewVariables(VVAccess.ReadWrite)]
+ public string? SelectedEntity;
+}
diff --git a/Content.Server/Holiday/Christmas/RandomGiftSystem.cs b/Content.Server/Holiday/Christmas/RandomGiftSystem.cs
new file mode 100644
index 0000000000..a830b85721
--- /dev/null
+++ b/Content.Server/Holiday/Christmas/RandomGiftSystem.cs
@@ -0,0 +1,102 @@
+using Content.Server.Hands.Systems;
+using Content.Shared.Examine;
+using Content.Shared.Interaction.Events;
+using Content.Shared.Item;
+using Robust.Server.GameObjects;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Physics.Components;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.Server.Holiday.Christmas;
+
+///
+/// This handles granting players their gift.
+///
+public sealed class RandomGiftSystem : EntitySystem
+{
+ [Dependency] private readonly AudioSystem _audio = default!;
+ [Dependency] private readonly HandsSystem _hands = default!;
+ [Dependency] private readonly IComponentFactory _componentFactory = default!;
+ [Dependency] private readonly IPrototypeManager _prototype = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+
+ private readonly List _possibleGiftsSafe = new();
+ private readonly List _possibleGiftsUnsafe = new();
+
+ ///
+ public override void Initialize()
+ {
+ _prototype.PrototypesReloaded += OnPrototypesReloaded;
+ SubscribeLocalEvent(OnGiftMapInit);
+ SubscribeLocalEvent(OnUseInHand);
+ SubscribeLocalEvent(OnExamined);
+ BuildIndex();
+ }
+
+ private void OnExamined(EntityUid uid, RandomGiftComponent component, ExaminedEvent args)
+ {
+ if (!component.ContentsViewers.IsValid(args.Examiner, EntityManager) || component.SelectedEntity is null)
+ return;
+
+ var name = _prototype.Index(component.SelectedEntity).Name;
+ args.Message.PushNewline();
+ args.Message.AddText(Loc.GetString("gift-packin-contains", ("name", name)));
+ }
+
+ private void OnUseInHand(EntityUid uid, RandomGiftComponent component, UseInHandEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ if (component.SelectedEntity is null)
+ return;
+
+ var coords = Transform(args.User).Coordinates;
+ var handsEnt = Spawn(component.SelectedEntity, coords);
+ EnsureComp(handsEnt); // For insane mode.
+ if (component.Wrapper is not null)
+ Spawn(component.Wrapper, coords);
+
+ args.Handled = true;
+ _audio.PlayPvs(component.Sound, args.User);
+ Del(uid);
+ _hands.PickupOrDrop(args.User, handsEnt);
+
+ }
+
+ private void OnGiftMapInit(EntityUid uid, RandomGiftComponent component, MapInitEvent args)
+ {
+ if (component.InsaneMode)
+ component.SelectedEntity = _random.Pick(_possibleGiftsUnsafe);
+ else
+ component.SelectedEntity = _random.Pick(_possibleGiftsSafe);
+ }
+
+ private void OnPrototypesReloaded(PrototypesReloadedEventArgs obj)
+ {
+ BuildIndex();
+ }
+
+ private void BuildIndex()
+ {
+ _possibleGiftsSafe.Clear();
+ _possibleGiftsUnsafe.Clear();
+ var itemCompName = _componentFactory.GetComponentName(typeof(ItemComponent));
+ var mapGridCompName = _componentFactory.GetComponentName(typeof(MapGridComponent));
+ var physicsCompName = _componentFactory.GetComponentName(typeof(PhysicsComponent));
+
+ foreach (var proto in _prototype.EnumeratePrototypes())
+ {
+ if (proto.Abstract || proto.NoSpawn || proto.Components.ContainsKey(mapGridCompName) || !proto.Components.ContainsKey(physicsCompName))
+ continue;
+
+ _possibleGiftsUnsafe.Add(proto.ID);
+
+ if (!proto.Components.ContainsKey(itemCompName))
+ continue;
+
+ _possibleGiftsSafe.Add(proto.ID);
+ }
+ }
+}
diff --git a/Content.Server/Holiday/Christmas/SantaComponent.cs b/Content.Server/Holiday/Christmas/SantaComponent.cs
new file mode 100644
index 0000000000..3769803f93
--- /dev/null
+++ b/Content.Server/Holiday/Christmas/SantaComponent.cs
@@ -0,0 +1,10 @@
+namespace Content.Server.Holiday.Christmas;
+
+///
+/// This is used as a marker component, allows them to see gift contents.
+///
+[RegisterComponent]
+public sealed class SantaComponent : Component
+{
+
+}
diff --git a/Resources/Locale/en-US/holiday/gifts.ftl b/Resources/Locale/en-US/holiday/gifts.ftl
new file mode 100644
index 0000000000..3374dc61d7
--- /dev/null
+++ b/Resources/Locale/en-US/holiday/gifts.ftl
@@ -0,0 +1,3 @@
+gift-packin-contains = This present appears to contain {INDEFINITE($name)} {$name}.
+christmas-tree-got-gift = After a bit of digging, you find a present with your name on it!
+christmas-tree-no-gift = There isn't a gift under the tree for you...
diff --git a/Resources/Prototypes/Entities/Objects/Decoration/flora.yml b/Resources/Prototypes/Entities/Objects/Decoration/flora.yml
index fd29dcba7f..72e31e3830 100644
--- a/Resources/Prototypes/Entities/Objects/Decoration/flora.yml
+++ b/Resources/Prototypes/Entities/Objects/Decoration/flora.yml
@@ -334,6 +334,7 @@
- type: entity
parent: BaseTreeConifer
id: FloraTreeChristmas02
+ suffix: PresentsGiver
name: christmas tree
components:
- type: Sprite
@@ -346,6 +347,19 @@
density: 4500
layer:
- WallLayer
+ - type: LimitedItemGiver
+ spawnEntries:
+ - id: PresentRandom
+ orGroup: present
+ - id: PresentRandomUnsafe
+ prob: 0.5
+ orGroup: present
+ - id: PresentRandomInsane
+ prob: 0.2
+ orGroup: present
+ receivedPopup: christmas-tree-got-gift
+ deniedPopup: christmas-tree-no-gift
+ requiredHoliday: FestiveSeason
- type: entity
parent: BaseTreeConifer
diff --git a/Resources/Prototypes/Entities/Objects/Decoration/present.yml b/Resources/Prototypes/Entities/Objects/Decoration/present.yml
index 081732a83b..fb186f7b07 100644
--- a/Resources/Prototypes/Entities/Objects/Decoration/present.yml
+++ b/Resources/Prototypes/Entities/Objects/Decoration/present.yml
@@ -1,32 +1,53 @@
- type: entity
- id: Present
- parent: BaseStorageItem
+ id: PresentBase
name: Present
- suffix: Empty
description: A little box with incredible surprises inside.
+ abstract: true
components:
- type: Sprite
sprite: Objects/Decoration/present.rsi
netsync: false
layers:
- state: present
+
+- type: entity
+ id: Present
+ parent: [PresentBase, BaseStorageItem]
+ suffix: Empty
+ components:
- type: Item
size: 30
- type: Storage
capacity: 30
- type: entity
- id: PresentRandom
- parent: BaseItem
- name: Present
- suffix: Filled Random
- description: A little box with incredible surprises inside.
+ id: PresentRandomUnsafe
+ parent: [PresentBase, BaseItem]
+ suffix: Filled Unsafe
+ components:
+ - type: RandomGift
+ wrapper: PresentTrash
+ sound:
+ path: /Audio/Effects/unwrap.ogg
+ insaneMode: false
+ contentsViewers:
+ components:
+ - Ghost
+ - Santa
+
+- type: entity
+ id: PresentRandomInsane
+ parent: PresentRandomUnsafe
+ suffix: Filled Insane
+ components:
+ - type: RandomGift
+ insaneMode: true
+
+- type: entity
+ id: PresentRandom
+ parent: [PresentBase, BaseItem]
+ suffix: Filled Safe
components:
- - type: Sprite
- sprite: Objects/Decoration/present.rsi
- netsync: false
- layers:
- - state: present
- type: SpawnItemsOnUse
items:
- id: PresentTrash