]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Multiantag Gamemode (#37783)
authorNemanja <98561806+EmoGarbage404@users.noreply.github.com>
Fri, 15 Aug 2025 14:06:51 +0000 (10:06 -0400)
committerGitHub <noreply@github.com>
Fri, 15 Aug 2025 14:06:51 +0000 (16:06 +0200)
Co-authored-by: slarticodefast <161409025+slarticodefast@users.noreply.github.com>
Co-authored-by: Southbridge <7013162+southbridge-fur@users.noreply.github.com>
Co-authored-by: ScarKy0 <106310278+ScarKy0@users.noreply.github.com>
19 files changed:
Content.Client/GameTicking/Managers/ClientGameTicker.cs
Content.Server/GameTicking/Commands/DynamicRuleCommand.cs [new file with mode: 0644]
Content.Server/GameTicking/GameTicker.GameRule.cs
Content.Server/GameTicking/Rules/DynamicRuleSystem.cs [new file with mode: 0644]
Content.Shared/EntityTable/Conditions/HasBudgetCondition.cs [new file with mode: 0644]
Content.Shared/EntityTable/Conditions/MaxRuleOccurenceCondition.cs [new file with mode: 0644]
Content.Shared/EntityTable/Conditions/ReoccurrenceDelayCondition.cs [new file with mode: 0644]
Content.Shared/EntityTable/Conditions/RoundDurationCondition.cs [new file with mode: 0644]
Content.Shared/EntityTable/EntitySelectors/GroupSelector.cs
Content.Shared/GameTicking/Rules/DynamicRuleComponent.cs [new file with mode: 0644]
Content.Shared/GameTicking/Rules/DynamicRuleCostComponent.cs [new file with mode: 0644]
Content.Shared/GameTicking/SharedGameTicker.cs
Resources/Locale/en-US/commands/toolshed-commands.ftl
Resources/Locale/en-US/game-ticking/game-presets/preset-secret.ftl
Resources/Prototypes/GameRules/dynamic_rules.yml [new file with mode: 0644]
Resources/Prototypes/GameRules/events.yml
Resources/Prototypes/GameRules/roundstart.yml
Resources/Prototypes/GameRules/subgamemodes.yml
Resources/Prototypes/game_presets.yml

index 170b24c02a5dbcc1f60ba9a5236398f4543fb090..4bd91bd06c3403d955e7bc0053f91aa5ff673148 100644 (file)
@@ -34,6 +34,8 @@ namespace Content.Client.GameTicking.Managers
         [ViewVariables] public TimeSpan StartTime { get; private set; }
         [ViewVariables] public new bool Paused { get; private set; }
 
+        public override IReadOnlyList<(TimeSpan, string)> AllPreviousGameRules => new List<(TimeSpan, string)>();
+
         [ViewVariables] public IReadOnlyDictionary<NetEntity, Dictionary<ProtoId<JobPrototype>, int?>> JobsAvailable => _jobsAvailable;
         [ViewVariables] public IReadOnlyDictionary<NetEntity, string> StationNames => _stationNames;
 
diff --git a/Content.Server/GameTicking/Commands/DynamicRuleCommand.cs b/Content.Server/GameTicking/Commands/DynamicRuleCommand.cs
new file mode 100644 (file)
index 0000000..798e7d0
--- /dev/null
@@ -0,0 +1,103 @@
+using System.Linq;
+using Content.Server.Administration;
+using Content.Server.GameTicking.Rules;
+using Content.Shared.Administration;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Toolshed;
+
+namespace Content.Server.GameTicking.Commands;
+
+[ToolshedCommand, AdminCommand(AdminFlags.Round)]
+public sealed class DynamicRuleCommand : ToolshedCommand
+{
+    private DynamicRuleSystem? _dynamicRuleSystem;
+
+    [CommandImplementation("list")]
+    public IEnumerable<EntityUid> List()
+    {
+        _dynamicRuleSystem ??= GetSys<DynamicRuleSystem>();
+
+        return _dynamicRuleSystem.GetDynamicRules();
+    }
+
+    [CommandImplementation("get")]
+    public EntityUid Get()
+    {
+        _dynamicRuleSystem ??= GetSys<DynamicRuleSystem>();
+
+        return _dynamicRuleSystem.GetDynamicRules().FirstOrDefault();
+    }
+
+    [CommandImplementation("budget")]
+    public IEnumerable<float?> Budget([PipedArgument] IEnumerable<EntityUid> input)
+        => input.Select(Budget);
+
+    [CommandImplementation("budget")]
+    public float? Budget([PipedArgument] EntityUid input)
+    {
+        _dynamicRuleSystem ??= GetSys<DynamicRuleSystem>();
+
+        return _dynamicRuleSystem.GetRuleBudget(input);
+    }
+
+    [CommandImplementation("adjust")]
+    public IEnumerable<float?> Adjust([PipedArgument] IEnumerable<EntityUid> input, float value)
+        => input.Select(i => Adjust(i,value));
+
+    [CommandImplementation("adjust")]
+    public float? Adjust([PipedArgument] EntityUid input, float value)
+    {
+        _dynamicRuleSystem ??= GetSys<DynamicRuleSystem>();
+
+        return _dynamicRuleSystem.AdjustBudget(input, value);
+    }
+
+    [CommandImplementation("set")]
+    public IEnumerable<float?> Set([PipedArgument] IEnumerable<EntityUid> input, float value)
+        => input.Select(i => Set(i,value));
+
+    [CommandImplementation("set")]
+    public float? Set([PipedArgument] EntityUid input, float value)
+    {
+        _dynamicRuleSystem ??= GetSys<DynamicRuleSystem>();
+
+        return _dynamicRuleSystem.SetBudget(input, value);
+    }
+
+    [CommandImplementation("dryrun")]
+    public IEnumerable<IEnumerable<EntProtoId>> DryRun([PipedArgument] IEnumerable<EntityUid> input)
+        => input.Select(DryRun);
+
+    [CommandImplementation("dryrun")]
+    public IEnumerable<EntProtoId> DryRun([PipedArgument] EntityUid input)
+    {
+        _dynamicRuleSystem ??= GetSys<DynamicRuleSystem>();
+
+        return _dynamicRuleSystem.DryRun(input);
+    }
+
+    [CommandImplementation("executenow")]
+    public IEnumerable<IEnumerable<EntityUid>> ExecuteNow([PipedArgument] IEnumerable<EntityUid> input)
+        => input.Select(ExecuteNow);
+
+    [CommandImplementation("executenow")]
+    public IEnumerable<EntityUid> ExecuteNow([PipedArgument] EntityUid input)
+    {
+        _dynamicRuleSystem ??= GetSys<DynamicRuleSystem>();
+
+        return _dynamicRuleSystem.ExecuteNow(input);
+    }
+
+    [CommandImplementation("rules")]
+    public IEnumerable<IEnumerable<EntityUid>> Rules([PipedArgument] IEnumerable<EntityUid> input)
+        => input.Select(Rules);
+
+    [CommandImplementation("rules")]
+    public IEnumerable<EntityUid> Rules([PipedArgument] EntityUid input)
+    {
+        _dynamicRuleSystem ??= GetSys<DynamicRuleSystem>();
+
+        return _dynamicRuleSystem.Rules(input);
+    }
+}
+
index cf0b0eceb11aa5d5584434d2fc7368c3ff50f65f..1750d3c27a0d8f14c21ffa04f763f8baef144aaa 100644 (file)
@@ -21,7 +21,7 @@ public sealed partial class GameTicker
     ///     A list storing the start times of all game rules that have been started this round.
     ///     Game rules can be started and stopped at any time, including midround.
     /// </summary>
-    public IReadOnlyList<(TimeSpan, string)> AllPreviousGameRules => _allPreviousGameRules;
+    public override IReadOnlyList<(TimeSpan, string)> AllPreviousGameRules => _allPreviousGameRules;
 
     private void InitializeGameRules()
     {
diff --git a/Content.Server/GameTicking/Rules/DynamicRuleSystem.cs b/Content.Server/GameTicking/Rules/DynamicRuleSystem.cs
new file mode 100644 (file)
index 0000000..b23e9d4
--- /dev/null
@@ -0,0 +1,195 @@
+using System.Diagnostics;
+using Content.Server.Administration.Logs;
+using Content.Server.RoundEnd;
+using Content.Shared.Database;
+using Content.Shared.EntityTable;
+using Content.Shared.EntityTable.Conditions;
+using Content.Shared.GameTicking.Components;
+using Content.Shared.GameTicking.Rules;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.Server.GameTicking.Rules;
+
+public sealed class DynamicRuleSystem : GameRuleSystem<DynamicRuleComponent>
+{
+    [Dependency] private readonly IAdminLogManager _adminLog = default!;
+    [Dependency] private readonly EntityTableSystem _entityTable = default!;
+    [Dependency] private readonly RoundEndSystem _roundEnd = default!;
+    [Dependency] private readonly IRobustRandom _random = default!;
+
+    protected override void Added(EntityUid uid, DynamicRuleComponent component, GameRuleComponent gameRule, GameRuleAddedEvent args)
+    {
+        base.Added(uid, component, gameRule, args);
+
+        component.Budget = _random.Next(component.StartingBudgetMin, component.StartingBudgetMax);;
+        component.NextRuleTime = Timing.CurTime + _random.Next(component.MinRuleInterval, component.MaxRuleInterval);
+    }
+
+    protected override void Started(EntityUid uid, DynamicRuleComponent component, GameRuleComponent gameRule, GameRuleStartedEvent args)
+    {
+        base.Started(uid, component, gameRule, args);
+
+        // Since we don't know how long until this rule is activated, we need to
+        // set the last budget update to now so it doesn't immediately give the component a bunch of points.
+        component.LastBudgetUpdate = Timing.CurTime;
+        Execute((uid, component));
+    }
+
+    protected override void Ended(EntityUid uid, DynamicRuleComponent component, GameRuleComponent gameRule, GameRuleEndedEvent args)
+    {
+        base.Ended(uid, component, gameRule, args);
+
+        foreach (var rule in component.Rules)
+        {
+            GameTicker.EndGameRule(rule);
+        }
+    }
+
+    protected override void ActiveTick(EntityUid uid, DynamicRuleComponent component, GameRuleComponent gameRule, float frameTime)
+    {
+        base.ActiveTick(uid, component, gameRule, frameTime);
+
+        if (Timing.CurTime < component.NextRuleTime)
+            return;
+
+        // don't spawn antags during evac
+        if (_roundEnd.IsRoundEndRequested())
+            return;
+
+        Execute((uid, component));
+    }
+
+    /// <summary>
+    /// Generates and returns a list of randomly selected,
+    /// valid rules to spawn based on <see cref="DynamicRuleComponent.Table"/>.
+    /// </summary>
+    private IEnumerable<EntProtoId> GetRuleSpawns(Entity<DynamicRuleComponent> entity)
+    {
+        UpdateBudget((entity.Owner, entity.Comp));
+        var ctx = new EntityTableContext(new Dictionary<string, object>
+        {
+            { HasBudgetCondition.BudgetContextKey, entity.Comp.Budget },
+        });
+
+        return _entityTable.GetSpawns(entity.Comp.Table, ctx: ctx);
+    }
+
+    /// <summary>
+    /// Updates the budget of the provided dynamic rule component based on the amount of time since the last update
+    /// multiplied by the <see cref="DynamicRuleComponent.BudgetPerSecond"/> value.
+    /// </summary>
+    private void UpdateBudget(Entity<DynamicRuleComponent> entity)
+    {
+        var duration = (float) (Timing.CurTime - entity.Comp.LastBudgetUpdate).TotalSeconds;
+
+        entity.Comp.Budget += duration * entity.Comp.BudgetPerSecond;
+        entity.Comp.LastBudgetUpdate = Timing.CurTime;
+    }
+
+    /// <summary>
+    /// Executes this rule, generating new dynamic rules and starting them.
+    /// </summary>
+    /// <returns>
+    /// Returns a list of the rules that were executed.
+    /// </returns>
+    private List<EntityUid> Execute(Entity<DynamicRuleComponent> entity)
+    {
+        entity.Comp.NextRuleTime =
+            Timing.CurTime + _random.Next(entity.Comp.MinRuleInterval, entity.Comp.MaxRuleInterval);
+
+        var executedRules = new List<EntityUid>();
+
+        foreach (var rule in GetRuleSpawns(entity))
+        {
+            var res = GameTicker.StartGameRule(rule, out var ruleUid);
+            Debug.Assert(res);
+
+            executedRules.Add(ruleUid);
+
+            if (TryComp<DynamicRuleCostComponent>(ruleUid, out var cost))
+            {
+                entity.Comp.Budget -= cost.Cost;
+                _adminLog.Add(LogType.EventRan, LogImpact.High, $"{ToPrettyString(entity)} ran rule {ToPrettyString(ruleUid)} with cost {cost.Cost} on budget {entity.Comp.Budget}.");
+            }
+            else
+            {
+                _adminLog.Add(LogType.EventRan, LogImpact.High, $"{ToPrettyString(entity)} ran rule {ToPrettyString(ruleUid)} which had no cost.");
+            }
+        }
+
+        entity.Comp.Rules.AddRange(executedRules);
+        return executedRules;
+    }
+
+    #region Command Methods
+
+    public List<EntityUid> GetDynamicRules()
+    {
+        var rules = new List<EntityUid>();
+        var query = EntityQueryEnumerator<DynamicRuleComponent, GameRuleComponent>();
+        while (query.MoveNext(out var uid, out _, out var comp))
+        {
+            if (!GameTicker.IsGameRuleActive(uid, comp))
+                continue;
+            rules.Add(uid);
+        }
+
+        return rules;
+    }
+
+    public float? GetRuleBudget(Entity<DynamicRuleComponent?> entity)
+    {
+        if (!Resolve(entity, ref entity.Comp))
+            return null;
+
+        UpdateBudget((entity.Owner, entity.Comp));
+        return entity.Comp.Budget;
+    }
+
+    public float? AdjustBudget(Entity<DynamicRuleComponent?> entity, float amount)
+    {
+        if (!Resolve(entity, ref entity.Comp))
+            return null;
+
+        UpdateBudget((entity.Owner, entity.Comp));
+        entity.Comp.Budget += amount;
+        return entity.Comp.Budget;
+    }
+
+    public float? SetBudget(Entity<DynamicRuleComponent?> entity, float amount)
+    {
+        if (!Resolve(entity, ref entity.Comp))
+            return null;
+
+        entity.Comp.LastBudgetUpdate = Timing.CurTime;
+        entity.Comp.Budget = amount;
+        return entity.Comp.Budget;
+    }
+
+    public IEnumerable<EntProtoId> DryRun(Entity<DynamicRuleComponent?> entity)
+    {
+        if (!Resolve(entity, ref entity.Comp))
+            return new List<EntProtoId>();
+
+        return GetRuleSpawns((entity.Owner, entity.Comp));
+    }
+
+    public IEnumerable<EntityUid> ExecuteNow(Entity<DynamicRuleComponent?> entity)
+    {
+        if (!Resolve(entity, ref entity.Comp))
+            return new List<EntityUid>();
+
+        return Execute((entity.Owner, entity.Comp));
+    }
+
+    public IEnumerable<EntityUid> Rules(Entity<DynamicRuleComponent?> entity)
+    {
+        if (!Resolve(entity, ref entity.Comp))
+            return new List<EntityUid>();
+
+        return entity.Comp.Rules;
+    }
+
+    #endregion
+}
diff --git a/Content.Shared/EntityTable/Conditions/HasBudgetCondition.cs b/Content.Shared/EntityTable/Conditions/HasBudgetCondition.cs
new file mode 100644 (file)
index 0000000..f2489d0
--- /dev/null
@@ -0,0 +1,51 @@
+using Content.Shared.EntityTable.EntitySelectors;
+using Content.Shared.GameTicking.Rules;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.EntityTable.Conditions;
+
+/// <summary>
+/// Condition that only succeeds if a table supplies a sufficient "cost" to a given
+/// </summary>
+public sealed partial class HasBudgetCondition : EntityTableCondition
+{
+    public const string BudgetContextKey = "Budget";
+
+    /// <summary>
+    /// Used for determining the cost for the budget.
+    /// If null, attempts to fetch the cost from the attached selector.
+    /// </summary>
+    [DataField]
+    public int? CostOverride;
+
+    protected override bool EvaluateImplementation(EntityTableSelector root,
+        IEntityManager entMan,
+        IPrototypeManager proto,
+        EntityTableContext ctx)
+    {
+        if (!ctx.TryGetData<float>(BudgetContextKey, out var budget))
+            return false;
+
+        int cost;
+        if (CostOverride != null)
+        {
+            cost = CostOverride.Value;
+        }
+        else
+        {
+            if (root is not EntSelector entSelector)
+                return false;
+
+            if (!proto.Index(entSelector.Id).TryGetComponent(out DynamicRuleCostComponent? costComponent, entMan.ComponentFactory))
+            {
+                var log = Logger.GetSawmill("HasBudgetCondition");
+                log.Error($"Rule {entSelector.Id} does not have a DynamicRuleCostComponent.");
+                return false;
+            }
+
+            cost = costComponent.Cost;
+        }
+
+        return budget >= cost;
+    }
+}
diff --git a/Content.Shared/EntityTable/Conditions/MaxRuleOccurenceCondition.cs b/Content.Shared/EntityTable/Conditions/MaxRuleOccurenceCondition.cs
new file mode 100644 (file)
index 0000000..1e55feb
--- /dev/null
@@ -0,0 +1,54 @@
+using System.Linq;
+using Content.Shared.EntityTable.EntitySelectors;
+using Content.Shared.GameTicking;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.EntityTable.Conditions;
+
+/// <summary>
+/// Condition that succeeds only when the specified gamerule has been run under a certain amount of times
+/// </summary>
+/// <remarks>
+/// This is meant to be attached directly to EntSelector. If it is not, then you'll need to specify what rule
+/// is being used inside RuleOverride.
+/// </remarks>
+public sealed partial class MaxRuleOccurenceCondition : EntityTableCondition
+{
+    /// <summary>
+    /// The maximum amount of times this rule can have already be run.
+    /// </summary>
+    [DataField]
+    public int Max = 1;
+
+    /// <summary>
+    /// The rule that is being checked for occurrences.
+    /// If null, it will use the value on the attached selector.
+    /// </summary>
+    [DataField]
+    public EntProtoId? RuleOverride;
+
+    protected override bool EvaluateImplementation(EntityTableSelector root,
+        IEntityManager entMan,
+        IPrototypeManager proto,
+        EntityTableContext ctx)
+    {
+        string rule;
+        if (RuleOverride is { } ruleOverride)
+        {
+            rule = ruleOverride;
+        }
+        else
+        {
+            rule = root is EntSelector entSelector
+                ? entSelector.Id
+                : string.Empty;
+        }
+
+        if (rule == string.Empty)
+            return false;
+
+        var gameTicker = entMan.System<SharedGameTicker>();
+
+        return gameTicker.AllPreviousGameRules.Count(p => p.Item2 == rule) < Max;
+    }
+}
diff --git a/Content.Shared/EntityTable/Conditions/ReoccurrenceDelayCondition.cs b/Content.Shared/EntityTable/Conditions/ReoccurrenceDelayCondition.cs
new file mode 100644 (file)
index 0000000..0329592
--- /dev/null
@@ -0,0 +1,49 @@
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using Content.Shared.EntityTable.EntitySelectors;
+using Content.Shared.GameTicking;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.EntityTable.Conditions;
+
+public sealed partial class ReoccurrenceDelayCondition : EntityTableCondition
+{
+    /// <summary>
+    /// The maximum amount of times this rule can have already be run.
+    /// </summary>
+    [DataField]
+    public TimeSpan Delay = TimeSpan.Zero;
+
+    /// <summary>
+    /// The rule that is being checked for occurrences.
+    /// If null, it will use the value on the attached selector.
+    /// </summary>
+    [DataField]
+    public EntProtoId? RuleOverride;
+
+    protected override bool EvaluateImplementation(EntityTableSelector root,
+        IEntityManager entMan,
+        IPrototypeManager proto,
+        EntityTableContext ctx)
+    {
+        string rule;
+        if (RuleOverride is { } ruleOverride)
+        {
+            rule = ruleOverride;
+        }
+        else
+        {
+            rule = root is EntSelector entSelector
+                ? entSelector.Id
+                : string.Empty;
+        }
+
+        if (rule == string.Empty)
+            return false;
+
+        var gameTicker = entMan.System<SharedGameTicker>();
+
+        return gameTicker.AllPreviousGameRules.Any(
+            p => p.Item2 == rule && p.Item1 + Delay <= gameTicker.RoundDuration());
+    }
+}
diff --git a/Content.Shared/EntityTable/Conditions/RoundDurationCondition.cs b/Content.Shared/EntityTable/Conditions/RoundDurationCondition.cs
new file mode 100644 (file)
index 0000000..518faf4
--- /dev/null
@@ -0,0 +1,34 @@
+using Content.Shared.EntityTable.EntitySelectors;
+using Content.Shared.GameTicking;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.EntityTable.Conditions;
+
+/// <summary>
+/// Condition that passes only if the current round time falls between the minimum and maximum time values.
+/// </summary>
+public sealed partial class RoundDurationCondition : EntityTableCondition
+{
+    /// <summary>
+    /// Minimum time the round must have gone on for this condition to pass.
+    /// </summary>
+    [DataField]
+    public TimeSpan Min = TimeSpan.Zero;
+
+    /// <summary>
+    /// Maximum amount of time the round could go on for this condition to pass.
+    /// </summary>
+    [DataField]
+    public TimeSpan Max = TimeSpan.MaxValue;
+
+    protected override bool EvaluateImplementation(EntityTableSelector root,
+        IEntityManager entMan,
+        IPrototypeManager proto,
+        EntityTableContext ctx)
+    {
+        var gameTicker = entMan.System<SharedGameTicker>();
+        var duration = gameTicker.RoundDuration();
+
+        return duration >= Min && duration <= Max;
+    }
+}
index 25c81a45650d9c82d50d0439e8b900366c3febcb..0d2a451bdc3ee5350942a14f6d9d580d4df79b8b 100644 (file)
@@ -26,6 +26,9 @@ public sealed partial class GroupSelector : EntityTableSelector
             children.Add(child, child.Weight);
         }
 
+        if (children.Count == 0)
+            return Array.Empty<EntProtoId>();
+
         var pick = SharedRandomExtensions.Pick(children, rand);
 
         return pick.GetSpawns(rand, entMan, proto, ctx);
diff --git a/Content.Shared/GameTicking/Rules/DynamicRuleComponent.cs b/Content.Shared/GameTicking/Rules/DynamicRuleComponent.cs
new file mode 100644 (file)
index 0000000..7782717
--- /dev/null
@@ -0,0 +1,71 @@
+using Content.Shared.EntityTable.EntitySelectors;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared.GameTicking.Rules;
+
+/// <summary>
+/// Gamerule the spawns multiple antags at intervals based on a budget
+/// </summary>
+[RegisterComponent, AutoGenerateComponentPause]
+public sealed partial class DynamicRuleComponent : Component
+{
+    /// <summary>
+    /// The total budget for antags.
+    /// </summary>
+    [DataField]
+    public float Budget;
+
+    /// <summary>
+    /// The last time budget was updated.
+    /// </summary>
+    [DataField]
+    public TimeSpan LastBudgetUpdate;
+
+    /// <summary>
+    /// The amount of budget accumulated every second.
+    /// </summary>
+    [DataField]
+    public float BudgetPerSecond = 0.1f;
+
+    /// <summary>
+    /// The minimum or lower bound for budgets to start at.
+    /// </summary>
+    [DataField]
+    public int StartingBudgetMin = 200;
+
+    /// <summary>
+    /// The maximum or upper bound for budgets to start at.
+    /// </summary>
+    [DataField]
+    public int StartingBudgetMax = 350;
+
+    /// <summary>
+    /// The time at which the next rule will start
+    /// </summary>
+    [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoPausedField]
+    public TimeSpan NextRuleTime;
+
+    /// <summary>
+    /// Minimum delay between rules
+    /// </summary>
+    [DataField]
+    public TimeSpan MinRuleInterval = TimeSpan.FromMinutes(10);
+
+    /// <summary>
+    /// Maximum delay between rules
+    /// </summary>
+    [DataField]
+    public TimeSpan MaxRuleInterval = TimeSpan.FromMinutes(30);
+
+    /// <summary>
+    /// A table of rules that are picked from.
+    /// </summary>
+    [DataField]
+    public EntityTableSelector Table = new NoneSelector();
+
+    /// <summary>
+    /// The rules that have been spawned
+    /// </summary>
+    [DataField]
+    public List<EntityUid> Rules = new();
+}
diff --git a/Content.Shared/GameTicking/Rules/DynamicRuleCostComponent.cs b/Content.Shared/GameTicking/Rules/DynamicRuleCostComponent.cs
new file mode 100644 (file)
index 0000000..180b168
--- /dev/null
@@ -0,0 +1,14 @@
+namespace Content.Shared.GameTicking.Rules;
+
+/// <summary>
+/// Component that tracks how much a rule "costs" for Dynamic
+/// </summary>
+[RegisterComponent]
+public sealed partial class DynamicRuleCostComponent : Component
+{
+    /// <summary>
+    /// The amount of budget a rule takes up
+    /// </summary>
+    [DataField(required: true)]
+    public int Cost;
+}
index 6b8bc8685b22a3e86e088309172e7f42dcc27fc8..877a849d07740b2e173cf1d22702daf66311a593 100644 (file)
@@ -15,6 +15,12 @@ namespace Content.Shared.GameTicking
         [Dependency] private readonly IReplayRecordingManager _replay = default!;
         [Dependency] private readonly IGameTiming _gameTiming = default!;
 
+        /// <summary>
+        ///     A list storing the start times of all game rules that have been started this round.
+        ///     Game rules can be started and stopped at any time, including midround.
+        /// </summary>
+        public abstract IReadOnlyList<(TimeSpan, string)> AllPreviousGameRules { get; }
+
         // See ideally these would be pulled from the job definition or something.
         // But this is easier, and at least it isn't hardcoded.
         //TODO: Move these, they really belong in StationJobsSystem or a cvar.
index cc5c03d52b60a322a75459e4ac24fb8d13fdb08e..33bf53f9e3676e612fe57f807de177da5c6a6267 100644 (file)
@@ -106,3 +106,19 @@ command-description-scale-multiplyvector =
     Multiply an entity's sprite size with a certain 2d vector (without changing its fixture).
 command-description-scale-multiplywithfixture =
     Multiply an entity's sprite size with a certain factor (including its fixture).
+command-description-dynamicrule-list =
+    Lists all currently active dynamic rules, usually this is just one.
+command-description-dynamicrule-get =
+    Gets the currently active dynamic rule.
+command-description-dynamicrule-budget =
+    Gets the current budget of the piped dynamic rule(s).
+command-description-dynamicrule-adjust =
+    Adjusts the budget of the piped dynamic rule(s) by the specified amount.
+command-description-dynamicrule-set =
+    Sets the budget of the piped dynamic rule(s) to the specified amount.
+command-description-dynamicrule-dryrun =
+    Returns a list of rules that could be activated if the rule ran at this moment with all current context. This is not a complete list of every single rule that could be run, just a sample of the current valid ones.
+command-description-dynamicrule-executenow =
+    Executes the piped dynamic rule as if it had reached its regular update time.
+command-description-dynamicrule-rules =
+    Gets a list of all the rules spawned by the piped dynamic rule.
index 892e5c3994788068afaaeebb719b1afdd8613f6c..2551b0073d45583821058303327d6ab78fdcafa9 100644 (file)
@@ -1,2 +1,5 @@
 secret-title = Secret
 secret-description = It's a secret to everyone. The threats you encounter are randomized.
+
+dynamic-title = Dynamic
+dynamic-description = No one knows what's coming. You can encounter any number of threats.
diff --git a/Resources/Prototypes/GameRules/dynamic_rules.yml b/Resources/Prototypes/GameRules/dynamic_rules.yml
new file mode 100644 (file)
index 0000000..02298e0
--- /dev/null
@@ -0,0 +1,112 @@
+- type: entity
+  parent: BaseGameRule
+  id: DynamicRule
+  components:
+  - type: GameRule
+    minPlayers: 5 # <5 is greenshift hours, buddy.
+  - type: DynamicRule
+    startingBudgetMin: 200
+    startingBudgetMax: 350
+    table: !type:AllSelector
+      children:
+      # Roundstart Major Rules
+      - !type:GroupSelector
+        conditions:
+        - !type:RoundDurationCondition
+          max: 1
+        children:
+        - id: Traitor
+          weight: 60
+          conditions:
+          - !type:HasBudgetCondition
+          - !type:MaxRuleOccurenceCondition
+        - id: Nukeops
+          weight: 25
+          conditions:
+          - !type:HasBudgetCondition
+          - !type:MaxRuleOccurenceCondition
+          - !type:PlayerCountCondition
+            min: 20
+        - id: Revolutionary
+          weight: 5
+          conditions:
+          - !type:HasBudgetCondition
+          - !type:MaxRuleOccurenceCondition
+        - id: Zombie
+          weight: 5
+          conditions:
+          - !type:HasBudgetCondition
+          - !type:MaxRuleOccurenceCondition
+          - !type:PlayerCountCondition
+            min: 20
+        - id: Wizard
+          weight: 5
+          conditions:
+          - !type:HasBudgetCondition
+          - !type:MaxRuleOccurenceCondition
+          - !type:PlayerCountCondition
+            min: 10
+      # Roundstart Minor Rules
+      - !type:GroupSelector
+        conditions:
+        - !type:RoundDurationCondition
+          max: 1
+        children:
+        - id: Thief
+          prob: 0.5
+          conditions:
+          - !type:HasBudgetCondition
+          - !type:MaxRuleOccurenceCondition
+      # Midround rules
+      - !type:GroupSelector
+        conditions:
+        - !type:RoundDurationCondition
+          min: 300 # minimum 5 minutes
+        children:
+        - id: SleeperAgents
+          weight: 15
+          conditions:
+          - !type:HasBudgetCondition
+          - !type:MaxRuleOccurenceCondition
+          - !type:RoundDurationCondition
+            min: 900 # 15 minutes
+        - id: DragonSpawn
+          weight: 15
+          conditions:
+          - !type:HasBudgetCondition
+          - !type:MaxRuleOccurenceCondition
+          - !type:RoundDurationCondition
+            min: 900 # 15 minutes
+        - id: NinjaSpawn
+          weight: 20
+          conditions:
+          - !type:HasBudgetCondition
+          - !type:MaxRuleOccurenceCondition
+          - !type:RoundDurationCondition
+            min: 900 # 15 minutes
+        - id: ParadoxCloneSpawn
+          weight: 25
+          conditions:
+          - !type:HasBudgetCondition
+          - !type:MaxRuleOccurenceCondition
+            max: 2
+          - !type:RoundDurationCondition
+            min: 600 # 10 minutes
+        - id: ZombieOutbreak
+          weight: 2.5
+          conditions:
+          - !type:HasBudgetCondition
+          - !type:MaxRuleOccurenceCondition
+          - !type:PlayerCountCondition
+            min: 20
+          - !type:RoundDurationCondition
+            min: 2700 # 45 minutes
+        - id: LoneOpsSpawn
+          weight: 5
+          conditions:
+          - !type:HasBudgetCondition
+          - !type:MaxRuleOccurenceCondition
+          - !type:PlayerCountCondition
+            min: 20
+          - !type:RoundDurationCondition
+            min: 2100 # 35 minutes
index 78602f97b9515c428d25dc4782dcfb3ccca1c40e..2642c0286bf2a76408d8ad8e68661626ba213dd4 100644 (file)
   table: !type:AllSelector # we need to pass a list of rules, since rules have further restrictions to consider via StationEventComp
     children:
     - id: ClosetSkeleton
-    - id: DragonSpawn
     - id: KingRatMigration
+    - id: RevenantSpawn
+    - id: DerelictCyborgSpawn
+
+- type: entityTable
+  id: ModerateAntagEventsTable
+  table: !type:AllSelector # we need to pass a list of rules, since rules have further restrictions to consider via StationEventComp
+    children:
+    - id: DragonSpawn
     - id: NinjaSpawn
     - id: ParadoxCloneSpawn
-    - id: RevenantSpawn
     - id: SleeperAgents
     - id: ZombieOutbreak
     - id: LoneOpsSpawn
-    - id: DerelictCyborgSpawn
     - id: WizardSpawn
 
 - type: entity
       pickPlayer: false
       mindRoles:
       - MindRoleDragon
+  - type: DynamicRuleCost
+    cost: 75
 
 - type: entity
   parent: BaseGameRule
         nameFormat: name-format-ninja
       mindRoles:
       - MindRoleNinja
+  - type: DynamicRuleCost
+    cost: 75
 
 - type: entity
   parent: BaseGameRule
         sound: /Audio/Misc/paradox_clone_greeting.ogg
       mindRoles:
       - MindRoleParadoxClone
+  - type: DynamicRuleCost
+    cost: 50
 
 - type: entity
   parent: BaseGameRule
       - type: InitialInfected
       mindRoles:
       - MindRoleInitialInfected
+  - type: DynamicRuleCost
+    cost: 200
 
 - type: entity
   parent: BaseNukeopsRule
         - Syndicate
       mindRoles:
       - MindRoleNukeops
+  - type: DynamicRuleCost
+    cost: 75
 
 - type: entity
   parent: BaseTraitorRule
index a7c7af7f37bcd2e179823ebdea217f505c4a4543..df4d59fd5a6a6aeebd130728e5d1346f6b2d8712 100644 (file)
         - Syndicate
       mindRoles:
       - MindRoleNukeops
+  - type: DynamicRuleCost
+    cost: 200
 
 - type: entity
   abstract: true
     maxDifficulty: 5
   - type: AntagSelection
     agentName: traitor-round-end-agent-name
+  - type: DynamicRuleCost
+    cost: 100
 
 - type: entity
   parent: BaseTraitorRule
       blacklist:
         components:
         - AntagImmune
-      lateJoinAdditional: true
+      lateJoinAdditional: false
       mindRoles:
       - MindRoleTraitor
 
       - type: HeadRevolutionary
       mindRoles:
       - MindRoleHeadRevolutionary
+  - type: DynamicRuleCost
+    cost: 200
 
 - type: entity
   id: Sandbox
         nameFormat: name-format-wizard
       mindRoles:
       - MindRoleWizard
+  - type: DynamicRuleCost
+    cost: 150
 
 - type: entity
   id: Zombie
       - type: InitialInfected
       mindRoles:
       - MindRoleInitialInfected
+  - type: DynamicRuleCost
+    cost: 200
 
 # This rule makes the chosen players unable to get other antag rules, as a way to prevent metagaming job rolls.
 # Put this before antags assigned to station jobs, but after non-job antags (NukeOps/Wiz).
         tableId: BasicCalmEventsTable
       - !type:NestedSelector
         tableId: BasicAntagEventsTable
+      - !type:NestedSelector
+        tableId: ModerateAntagEventsTable
       - !type:NestedSelector
         tableId: CargoGiftsTable
       - !type:NestedSelector
       - !type:NestedSelector
         tableId: SpicyPestEventsTable
 
+- type: entityTable
+  id: DynamicGameRulesTable
+  table: !type:AllSelector # we need to pass a list of rules, since rules have further restrictions to consider via StationEventComp
+    children:
+    - !type:NestedSelector
+      tableId: BasicCalmEventsTable
+    - !type:NestedSelector
+      tableId: BasicAntagEventsTable
+    - !type:NestedSelector
+      tableId: CargoGiftsTable
+    - !type:NestedSelector
+      tableId: CalmPestEventsTable
+    - !type:NestedSelector
+      tableId: SpicyPestEventsTable
+
 - type: entityTable
   id: SpaceTrafficControlTable
   table: !type:AllSelector # we need to pass a list of rules, since rules have further restrictions to consider via StationEventComp
     scheduledGameRules: !type:NestedSelector
       tableId: BasicGameRulesTable
 
+- type: entity
+  id: DynamicStationEventScheduler # this isn't the dynamic mode, but rather the station event scheduler used for dynamic
+  parent: BaseGameRule
+  components:
+  - type: BasicStationEventScheduler
+    scheduledGameRules: !type:NestedSelector
+      tableId: DynamicGameRulesTable
+
 - type: entity
   id: RampingStationEventScheduler
   parent: BaseGameRule
index 2213623c2873c78ced612c34ba94932edf993104..0dbef6e06be7ccd9f27e52d53cd458d7d5b06399 100644 (file)
@@ -34,6 +34,8 @@
       - MindRoleThief
       briefing:
         sound: "/Audio/Misc/thief_greeting.ogg"
+  - type: DynamicRuleCost
+    cost: 75
 
 # Needs testing
 - type: entity
index 160bb5e4a08ba71c535ec14c77281dd59f3fb288..a9d33b0b7681ce5db7a79c8b78803e22c29843da 100644 (file)
   - SpaceTrafficControlFriendlyEventScheduler
   - BasicRoundstartVariation
 
+- type: gamePreset
+  id: Dynamic
+  alias:
+  - dynamic
+  - multiantag
+  - director
+  name: dynamic-title
+  showInVote: true
+  description: dynamic-description
+  rules:
+  - DynamicRule
+  - DummyNonAntag
+  - DynamicStationEventScheduler
+  - MeteorSwarmScheduler
+  - SpaceTrafficControlEventScheduler
+  - BasicRoundstartVariation
+
 - type: gamePreset
   id: Secret
   alias: