diff --git a/Content.Server/FloofStation/GameTicking/StationEventCondition.cs b/Content.Server/FloofStation/GameTicking/StationEventCondition.cs
new file mode 100644
index 00000000000..060357ef662
--- /dev/null
+++ b/Content.Server/FloofStation/GameTicking/StationEventCondition.cs
@@ -0,0 +1,141 @@
+using System.Linq;
+using Content.Server.GameTicking;
+using Content.Server.Mind;
+using Content.Server.StationEvents;
+using Content.Server.StationEvents.Components;
+using Content.Shared.Access.Systems;
+using Content.Shared.Roles;
+using Content.Shared.Roles.Jobs;
+using Robust.Server.Player;
+using Robust.Shared.Enums;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.Server.FloofStation.GameTicking;
+
+///
+/// Represents an abstract condition required for a station event to be chosen from the random event pool.
+///
+///
+/// Implementations should avoid performing expensive checks.
+/// Any data that may be expensive to compute should instead be precomputed and stored in
+///
+[Serializable, ImplicitDataDefinitionForInheritors]
+public abstract partial class StationEventCondition
+{
+ ///
+ /// If true, the event will only be run if this condition is NOT met.
+ ///
+ [DataField]
+ public bool Inverted = false;
+
+ public abstract bool IsMet(EntityPrototype proto, StationEventComponent component, Dependencies dependencies);
+
+ ///
+ /// Entity system and other dependencies used by station event conditions.
+ /// GameTicker allocates an instance of this before passing it to all events.
+ ///
+ public sealed class Dependencies(IEntityManager entMan, GameTicker ticker, EventManagerSystem eventManager)
+ {
+ public ISawmill Log = Logger.GetSawmill("station-event-conditions");
+
+ public IEntityManager EntMan => entMan;
+ public GameTicker Ticker => ticker;
+ public EventManagerSystem EventManager => eventManager;
+
+ public MindSystem Minds = default!;
+ public SharedIdCardSystem IdCard = default!;
+
+ [Dependency] public IPrototypeManager ProtoMan = default!;
+ [Dependency] public IRobustRandom Random = default!;
+ [Dependency] public IPlayerManager PlayerManager = default!;
+
+ ///
+ /// The list of all players along with their jobs.
+ ///
+ public List<(ICommonSession session, EntityUid uid, ProtoId job)> Players = new();
+ public Dictionary, int> JobCounts = new();
+ public Dictionary, int> DeptCounts = new();
+
+ // Lookups
+ private readonly Dictionary> _jobTitleToPrototype = new();
+ private readonly Dictionary, List>> _jobToDepts = new();
+
+ ///
+ /// Called once after the instantiation of the class.
+ ///
+ public void Initialize()
+ {
+ IoCManager.InjectDependencies(this);
+
+ // We cannot use entity system dependencies outside of ESC context.
+ IdCard = EntMan.System();
+ Minds = EntMan.System();
+
+ // Build the inverse lookups - SharedJobSystem contains methods that iterate over all of those lists each time,
+ // Resulting in an O(n^2 * m) performance cost for each update() call.
+ foreach (var job in ProtoMan.EnumeratePrototypes())
+ {
+ _jobTitleToPrototype[job.LocalizedName] = job.ID;
+
+ var depts = ProtoMan.EnumeratePrototypes()
+ .Where(it => it.Roles.Contains(job.ID))
+ .Select(it => new ProtoId(it.ID))
+ .ToList();
+
+ _jobToDepts[job.ID] = depts;
+ }
+ }
+
+ ///
+ /// Called once shortly before passing this object to IsMet() to collect the necessary data about the round.
+ ///
+ public void Update()
+ {
+ JobCounts.Clear();
+ DeptCounts.Clear();
+
+ // Collect data about the jobs of the players in the round
+ Players.Clear();
+ foreach (var session in PlayerManager.Sessions)
+ {
+ if (session.AttachedEntity is not {} player
+ || session.Status is SessionStatus.Zombie or SessionStatus.Disconnected
+ || !Minds.TryGetMind(session, out var mind, out var mindComponent))
+ continue;
+
+ ProtoId job = default;
+ // 1: Try to get the job from the ID the person holds
+ if (IdCard.TryFindIdCard(player, out var idCard) && idCard.Comp.JobTitle is {} jobTitle)
+ _jobTitleToPrototype.TryGetValue(jobTitle, out job);
+
+ // 2: If failed, try to fetch it from the mind component instead
+ if (job == default
+ && EntMan.TryGetComponent(mind, out var jobComp)
+ && jobComp.Prototype is {} mindJobProto
+ )
+ job = mindJobProto;
+
+ // If both have failed, skip the player
+ if (job == default)
+ continue;
+
+ // Update the info
+ Players.Add((session, player, job));
+ JobCounts[job] = JobCounts.GetValueOrDefault(job, 0) + 1;
+ // Increment the number of players in each dept this job belongs to
+ if (_jobToDepts.TryGetValue(job, out var depts))
+ {
+ foreach (var dept in depts)
+ DeptCounts[dept] = DeptCounts.GetValueOrDefault(dept, 0) + 1;
+ }
+ }
+
+ #if DEBUG
+ Log.Debug($"Event conditions data: Job counts: {string.Join(", ", JobCounts)}");
+ Log.Debug($"Dept counts: {string.Join(", ", DeptCounts)}");
+ #endif
+ }
+ }
+}
diff --git a/Content.Server/FloofStation/GameTicking/StationEventConditions.Players.cs b/Content.Server/FloofStation/GameTicking/StationEventConditions.Players.cs
new file mode 100644
index 00000000000..ef9fdb9504b
--- /dev/null
+++ b/Content.Server/FloofStation/GameTicking/StationEventConditions.Players.cs
@@ -0,0 +1,49 @@
+using Content.Server.StationEvents.Components;
+using Content.Shared.InteractionVerbs;
+using Content.Shared.Roles;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.FloofStation.GameTicking;
+
+///
+/// A condition that requires a number of players to be present in a specific department.
+///
+///
+/// - !type:DepartmentCountCondition
+/// department: Security
+/// range: {min: 5}
+///
+[Serializable]
+public sealed partial class DepartmentCountCondition : StationEventCondition
+{
+ [DataField(required: true)]
+ public ProtoId Department;
+
+ [DataField(required: true)]
+ public InteractionVerbPrototype.RangeSpecifier Range;
+
+ public override bool IsMet(EntityPrototype proto, StationEventComponent component, Dependencies dependencies)
+ {
+ var count = dependencies.DeptCounts.GetValueOrDefault(Department, 0);
+ return Range.IsInRange(count);
+ }
+}
+
+///
+/// Same as , but for specific jobs.
+///
+[Serializable]
+public sealed partial class JobCountCondition : StationEventCondition
+{
+ [DataField(required: true)]
+ public ProtoId Job;
+
+ [DataField(required: true)]
+ public InteractionVerbPrototype.RangeSpecifier Range;
+
+ public override bool IsMet(EntityPrototype proto, StationEventComponent component, Dependencies dependencies)
+ {
+ var count = dependencies.JobCounts.GetValueOrDefault(Job, 0);
+ return Range.IsInRange(count);
+ }
+}
diff --git a/Content.Server/FloofStation/GameTicking/StationEventConditions.Utility.cs b/Content.Server/FloofStation/GameTicking/StationEventConditions.Utility.cs
new file mode 100644
index 00000000000..ab2b7bc7570
--- /dev/null
+++ b/Content.Server/FloofStation/GameTicking/StationEventConditions.Utility.cs
@@ -0,0 +1,37 @@
+using System.Linq;
+using Content.Server.StationEvents.Components;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.FloofStation.GameTicking;
+
+///
+/// Combines a number of other conditions in a boolean AND or a boolean OR.
+///
+///
+///
+/// - !type:ComplexCondition
+/// requireAll: true
+/// conditions:
+/// - !type:SomeCondition1
+/// ...
+/// - !type:SomeCondition2
+/// ...
+///
+///
+[Serializable]
+public sealed partial class ComplexCondition : StationEventCondition
+{
+ ///
+ /// If true, this condition acts as a boolean AND. If false, it acts as a boolean OR.
+ ///
+ [DataField]
+ public bool RequireAll = false;
+
+ [DataField(required: true)]
+ public List Conditions = new();
+
+ public override bool IsMet(EntityPrototype proto, StationEventComponent component, Dependencies dependencies) =>
+ RequireAll
+ ? Conditions.All(it => it.Inverted ^ it.IsMet(proto, component, dependencies))
+ : Conditions.Any(it => it.Inverted ^ it.IsMet(proto, component, dependencies));
+}
diff --git a/Content.Server/StationEvents/Components/StationEventComponent.cs b/Content.Server/StationEvents/Components/StationEventComponent.cs
index 54af1a59d99..6edf3d8ac5a 100644
--- a/Content.Server/StationEvents/Components/StationEventComponent.cs
+++ b/Content.Server/StationEvents/Components/StationEventComponent.cs
@@ -1,3 +1,4 @@
+using Content.Server.FloofStation.GameTicking;
using Robust.Shared.Audio;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
@@ -82,4 +83,14 @@ public sealed partial class StationEventComponent : Component
[DataField("endTime", customTypeSerializer: typeof(TimeOffsetSerializer))]
[AutoPausedField]
public TimeSpan? EndTime;
+
+ // Floof section - custom conditions
+
+ ///
+ /// A list of conditions that must be met for the event to run.
+ ///
+ [DataField]
+ public List? Conditions;
+
+ // Floof section end
}
diff --git a/Content.Server/StationEvents/EventManagerSystem.cs b/Content.Server/StationEvents/EventManagerSystem.cs
index 9c0005d06db..ce62e35213a 100644
--- a/Content.Server/StationEvents/EventManagerSystem.cs
+++ b/Content.Server/StationEvents/EventManagerSystem.cs
@@ -1,5 +1,6 @@
using System.Linq;
using Content.Server.Chat.Managers;
+using Content.Server.FloofStation.GameTicking;
using Content.Server.GameTicking;
using Content.Server.StationEvents.Components;
using Content.Shared.CCVar;
@@ -24,11 +25,16 @@ public sealed class EventManagerSystem : EntitySystem
public bool EventsEnabled { get; private set; }
private void SetEnabled(bool value) => EventsEnabled = value;
+ private StationEventCondition.Dependencies _eventConditionDeps = default!; // Floof
+
public override void Initialize()
{
base.Initialize();
Subs.CVar(_configurationManager, CCVars.EventsEnabled, SetEnabled, true);
+
+ _eventConditionDeps = new(EntityManager, GameTicker, this); // Floof
+ _eventConditionDeps.Initialize();
}
///
@@ -113,6 +119,8 @@ private Dictionary AvailableEvents(bool
var result = new Dictionary();
+ _eventConditionDeps.Update(); // Floof
+
foreach (var (proto, stationEvent) in AllEvents())
{
if (CanRun(proto, stationEvent, playerCount, currentTime))
@@ -201,6 +209,13 @@ private bool CanRun(EntityPrototype prototype, StationEventComponent stationEven
}
// Nyano - End modified code block.
+ // Floof section - custom conditions
+ if (stationEvent.Conditions is { } conditions
+ && conditions.Any(it => it.Inverted ^ !it.IsMet(prototype, stationEvent, _eventConditionDeps))
+ )
+ return false;
+ // Floof section end
+
return true;
}
}
diff --git a/Resources/Prototypes/Floof/Gamerules/base.yml b/Resources/Prototypes/Floof/Gamerules/base.yml
new file mode 100644
index 00000000000..8bdbfa44ac7
--- /dev/null
+++ b/Resources/Prototypes/Floof/Gamerules/base.yml
@@ -0,0 +1,11 @@
+# A game rule base that requires at least 2 security members to be present
+- type: entity
+ id: BaseGameRuleSecurityRequirement
+ parent: BaseGameRule
+ abstract: true
+ components:
+ - type: StationEvent
+ conditions:
+ - !type:DepartmentCountCondition
+ department: Security
+ range: {min: 2}
diff --git a/Resources/Prototypes/GameRules/events.yml b/Resources/Prototypes/GameRules/events.yml
index 35c2fb2805d..7a742bf2de1 100644
--- a/Resources/Prototypes/GameRules/events.yml
+++ b/Resources/Prototypes/GameRules/events.yml
@@ -7,6 +7,10 @@
weight: 8
startDelay: 30
duration: 35
+ conditions: # Floof - at least one epi member
+ - !type:DepartmentCountCondition
+ department: Epistemics
+ range: {min: 1}
- type: AnomalySpawnRule
- type: entity
@@ -80,7 +84,7 @@
prototype: MobSkeletonCloset
- type: entity
- parent: BaseGameRule
+ parent: BaseGameRuleSecurityRequirement # Floof - changed parent
id: DragonSpawn
noSpawn: true
components:
@@ -94,7 +98,7 @@
prototype: SpawnPointGhostDragon
- type: entity
- parent: BaseGameRule
+ parent: BaseGameRuleSecurityRequirement # Floof - changed parent
id: NinjaSpawn
noSpawn: true
components:
@@ -168,6 +172,10 @@
endAnnouncement: true
duration: null #ending is handled by MeteorSwarmRule
startDelay: 30
+ conditions: # Floof
+ - !type:DepartmentCountCondition
+ department: Engineering
+ range: {min: 2}
- type: MeteorSwarmRule
- type: entity
@@ -371,7 +379,7 @@
- type: entity
id: LoneOpsSpawn
- parent: BaseGameRule
+ parent: BaseGameRuleSecurityRequirement # Floof - changed parent
noSpawn: true
components:
- type: StationEvent