diff --git a/Content.Client/GameTicking/Managers/ClientGameTicker.cs b/Content.Client/GameTicking/Managers/ClientGameTicker.cs index 170b24c02a..4bd91bd06c 100644 --- a/Content.Client/GameTicking/Managers/ClientGameTicker.cs +++ b/Content.Client/GameTicking/Managers/ClientGameTicker.cs @@ -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, int?>> JobsAvailable => _jobsAvailable; [ViewVariables] public IReadOnlyDictionary StationNames => _stationNames; diff --git a/Content.Server/GameTicking/Commands/DynamicRuleCommand.cs b/Content.Server/GameTicking/Commands/DynamicRuleCommand.cs new file mode 100644 index 0000000000..798e7d0d3a --- /dev/null +++ b/Content.Server/GameTicking/Commands/DynamicRuleCommand.cs @@ -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 List() + { + _dynamicRuleSystem ??= GetSys(); + + return _dynamicRuleSystem.GetDynamicRules(); + } + + [CommandImplementation("get")] + public EntityUid Get() + { + _dynamicRuleSystem ??= GetSys(); + + return _dynamicRuleSystem.GetDynamicRules().FirstOrDefault(); + } + + [CommandImplementation("budget")] + public IEnumerable Budget([PipedArgument] IEnumerable input) + => input.Select(Budget); + + [CommandImplementation("budget")] + public float? Budget([PipedArgument] EntityUid input) + { + _dynamicRuleSystem ??= GetSys(); + + return _dynamicRuleSystem.GetRuleBudget(input); + } + + [CommandImplementation("adjust")] + public IEnumerable Adjust([PipedArgument] IEnumerable input, float value) + => input.Select(i => Adjust(i,value)); + + [CommandImplementation("adjust")] + public float? Adjust([PipedArgument] EntityUid input, float value) + { + _dynamicRuleSystem ??= GetSys(); + + return _dynamicRuleSystem.AdjustBudget(input, value); + } + + [CommandImplementation("set")] + public IEnumerable Set([PipedArgument] IEnumerable input, float value) + => input.Select(i => Set(i,value)); + + [CommandImplementation("set")] + public float? Set([PipedArgument] EntityUid input, float value) + { + _dynamicRuleSystem ??= GetSys(); + + return _dynamicRuleSystem.SetBudget(input, value); + } + + [CommandImplementation("dryrun")] + public IEnumerable> DryRun([PipedArgument] IEnumerable input) + => input.Select(DryRun); + + [CommandImplementation("dryrun")] + public IEnumerable DryRun([PipedArgument] EntityUid input) + { + _dynamicRuleSystem ??= GetSys(); + + return _dynamicRuleSystem.DryRun(input); + } + + [CommandImplementation("executenow")] + public IEnumerable> ExecuteNow([PipedArgument] IEnumerable input) + => input.Select(ExecuteNow); + + [CommandImplementation("executenow")] + public IEnumerable ExecuteNow([PipedArgument] EntityUid input) + { + _dynamicRuleSystem ??= GetSys(); + + return _dynamicRuleSystem.ExecuteNow(input); + } + + [CommandImplementation("rules")] + public IEnumerable> Rules([PipedArgument] IEnumerable input) + => input.Select(Rules); + + [CommandImplementation("rules")] + public IEnumerable Rules([PipedArgument] EntityUid input) + { + _dynamicRuleSystem ??= GetSys(); + + return _dynamicRuleSystem.Rules(input); + } +} + diff --git a/Content.Server/GameTicking/GameTicker.GameRule.cs b/Content.Server/GameTicking/GameTicker.GameRule.cs index cf0b0eceb1..1750d3c27a 100644 --- a/Content.Server/GameTicking/GameTicker.GameRule.cs +++ b/Content.Server/GameTicking/GameTicker.GameRule.cs @@ -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. /// - 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 index 0000000000..b23e9d40f2 --- /dev/null +++ b/Content.Server/GameTicking/Rules/DynamicRuleSystem.cs @@ -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 +{ + [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)); + } + + /// + /// Generates and returns a list of randomly selected, + /// valid rules to spawn based on . + /// + private IEnumerable GetRuleSpawns(Entity entity) + { + UpdateBudget((entity.Owner, entity.Comp)); + var ctx = new EntityTableContext(new Dictionary + { + { HasBudgetCondition.BudgetContextKey, entity.Comp.Budget }, + }); + + return _entityTable.GetSpawns(entity.Comp.Table, ctx: ctx); + } + + /// + /// Updates the budget of the provided dynamic rule component based on the amount of time since the last update + /// multiplied by the value. + /// + private void UpdateBudget(Entity entity) + { + var duration = (float) (Timing.CurTime - entity.Comp.LastBudgetUpdate).TotalSeconds; + + entity.Comp.Budget += duration * entity.Comp.BudgetPerSecond; + entity.Comp.LastBudgetUpdate = Timing.CurTime; + } + + /// + /// Executes this rule, generating new dynamic rules and starting them. + /// + /// + /// Returns a list of the rules that were executed. + /// + private List Execute(Entity entity) + { + entity.Comp.NextRuleTime = + Timing.CurTime + _random.Next(entity.Comp.MinRuleInterval, entity.Comp.MaxRuleInterval); + + var executedRules = new List(); + + foreach (var rule in GetRuleSpawns(entity)) + { + var res = GameTicker.StartGameRule(rule, out var ruleUid); + Debug.Assert(res); + + executedRules.Add(ruleUid); + + if (TryComp(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 GetDynamicRules() + { + var rules = new List(); + var query = EntityQueryEnumerator(); + 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 entity) + { + if (!Resolve(entity, ref entity.Comp)) + return null; + + UpdateBudget((entity.Owner, entity.Comp)); + return entity.Comp.Budget; + } + + public float? AdjustBudget(Entity 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 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 DryRun(Entity entity) + { + if (!Resolve(entity, ref entity.Comp)) + return new List(); + + return GetRuleSpawns((entity.Owner, entity.Comp)); + } + + public IEnumerable ExecuteNow(Entity entity) + { + if (!Resolve(entity, ref entity.Comp)) + return new List(); + + return Execute((entity.Owner, entity.Comp)); + } + + public IEnumerable Rules(Entity entity) + { + if (!Resolve(entity, ref entity.Comp)) + return new List(); + + 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 index 0000000000..f2489d04aa --- /dev/null +++ b/Content.Shared/EntityTable/Conditions/HasBudgetCondition.cs @@ -0,0 +1,51 @@ +using Content.Shared.EntityTable.EntitySelectors; +using Content.Shared.GameTicking.Rules; +using Robust.Shared.Prototypes; + +namespace Content.Shared.EntityTable.Conditions; + +/// +/// Condition that only succeeds if a table supplies a sufficient "cost" to a given +/// +public sealed partial class HasBudgetCondition : EntityTableCondition +{ + public const string BudgetContextKey = "Budget"; + + /// + /// Used for determining the cost for the budget. + /// If null, attempts to fetch the cost from the attached selector. + /// + [DataField] + public int? CostOverride; + + protected override bool EvaluateImplementation(EntityTableSelector root, + IEntityManager entMan, + IPrototypeManager proto, + EntityTableContext ctx) + { + if (!ctx.TryGetData(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 index 0000000000..1e55feb338 --- /dev/null +++ b/Content.Shared/EntityTable/Conditions/MaxRuleOccurenceCondition.cs @@ -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; + +/// +/// Condition that succeeds only when the specified gamerule has been run under a certain amount of times +/// +/// +/// 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. +/// +public sealed partial class MaxRuleOccurenceCondition : EntityTableCondition +{ + /// + /// The maximum amount of times this rule can have already be run. + /// + [DataField] + public int Max = 1; + + /// + /// The rule that is being checked for occurrences. + /// If null, it will use the value on the attached selector. + /// + [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(); + + 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 index 0000000000..0329592a4a --- /dev/null +++ b/Content.Shared/EntityTable/Conditions/ReoccurrenceDelayCondition.cs @@ -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 +{ + /// + /// The maximum amount of times this rule can have already be run. + /// + [DataField] + public TimeSpan Delay = TimeSpan.Zero; + + /// + /// The rule that is being checked for occurrences. + /// If null, it will use the value on the attached selector. + /// + [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(); + + 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 index 0000000000..518faf4bc6 --- /dev/null +++ b/Content.Shared/EntityTable/Conditions/RoundDurationCondition.cs @@ -0,0 +1,34 @@ +using Content.Shared.EntityTable.EntitySelectors; +using Content.Shared.GameTicking; +using Robust.Shared.Prototypes; + +namespace Content.Shared.EntityTable.Conditions; + +/// +/// Condition that passes only if the current round time falls between the minimum and maximum time values. +/// +public sealed partial class RoundDurationCondition : EntityTableCondition +{ + /// + /// Minimum time the round must have gone on for this condition to pass. + /// + [DataField] + public TimeSpan Min = TimeSpan.Zero; + + /// + /// Maximum amount of time the round could go on for this condition to pass. + /// + [DataField] + public TimeSpan Max = TimeSpan.MaxValue; + + protected override bool EvaluateImplementation(EntityTableSelector root, + IEntityManager entMan, + IPrototypeManager proto, + EntityTableContext ctx) + { + var gameTicker = entMan.System(); + var duration = gameTicker.RoundDuration(); + + return duration >= Min && duration <= Max; + } +} diff --git a/Content.Shared/EntityTable/EntitySelectors/GroupSelector.cs b/Content.Shared/EntityTable/EntitySelectors/GroupSelector.cs index 25c81a4565..0d2a451bdc 100644 --- a/Content.Shared/EntityTable/EntitySelectors/GroupSelector.cs +++ b/Content.Shared/EntityTable/EntitySelectors/GroupSelector.cs @@ -26,6 +26,9 @@ public sealed partial class GroupSelector : EntityTableSelector children.Add(child, child.Weight); } + if (children.Count == 0) + return Array.Empty(); + 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 index 0000000000..7782717758 --- /dev/null +++ b/Content.Shared/GameTicking/Rules/DynamicRuleComponent.cs @@ -0,0 +1,71 @@ +using Content.Shared.EntityTable.EntitySelectors; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; + +namespace Content.Shared.GameTicking.Rules; + +/// +/// Gamerule the spawns multiple antags at intervals based on a budget +/// +[RegisterComponent, AutoGenerateComponentPause] +public sealed partial class DynamicRuleComponent : Component +{ + /// + /// The total budget for antags. + /// + [DataField] + public float Budget; + + /// + /// The last time budget was updated. + /// + [DataField] + public TimeSpan LastBudgetUpdate; + + /// + /// The amount of budget accumulated every second. + /// + [DataField] + public float BudgetPerSecond = 0.1f; + + /// + /// The minimum or lower bound for budgets to start at. + /// + [DataField] + public int StartingBudgetMin = 200; + + /// + /// The maximum or upper bound for budgets to start at. + /// + [DataField] + public int StartingBudgetMax = 350; + + /// + /// The time at which the next rule will start + /// + [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoPausedField] + public TimeSpan NextRuleTime; + + /// + /// Minimum delay between rules + /// + [DataField] + public TimeSpan MinRuleInterval = TimeSpan.FromMinutes(10); + + /// + /// Maximum delay between rules + /// + [DataField] + public TimeSpan MaxRuleInterval = TimeSpan.FromMinutes(30); + + /// + /// A table of rules that are picked from. + /// + [DataField] + public EntityTableSelector Table = new NoneSelector(); + + /// + /// The rules that have been spawned + /// + [DataField] + public List Rules = new(); +} diff --git a/Content.Shared/GameTicking/Rules/DynamicRuleCostComponent.cs b/Content.Shared/GameTicking/Rules/DynamicRuleCostComponent.cs new file mode 100644 index 0000000000..180b168dc1 --- /dev/null +++ b/Content.Shared/GameTicking/Rules/DynamicRuleCostComponent.cs @@ -0,0 +1,14 @@ +namespace Content.Shared.GameTicking.Rules; + +/// +/// Component that tracks how much a rule "costs" for Dynamic +/// +[RegisterComponent] +public sealed partial class DynamicRuleCostComponent : Component +{ + /// + /// The amount of budget a rule takes up + /// + [DataField(required: true)] + public int Cost; +} diff --git a/Content.Shared/GameTicking/SharedGameTicker.cs b/Content.Shared/GameTicking/SharedGameTicker.cs index 6b8bc8685b..877a849d07 100644 --- a/Content.Shared/GameTicking/SharedGameTicker.cs +++ b/Content.Shared/GameTicking/SharedGameTicker.cs @@ -15,6 +15,12 @@ namespace Content.Shared.GameTicking [Dependency] private readonly IReplayRecordingManager _replay = default!; [Dependency] private readonly IGameTiming _gameTiming = default!; + /// + /// 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. + /// + 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. diff --git a/Resources/Locale/en-US/commands/toolshed-commands.ftl b/Resources/Locale/en-US/commands/toolshed-commands.ftl index cc5c03d52b..33bf53f9e3 100644 --- a/Resources/Locale/en-US/commands/toolshed-commands.ftl +++ b/Resources/Locale/en-US/commands/toolshed-commands.ftl @@ -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. diff --git a/Resources/Locale/en-US/game-ticking/game-presets/preset-secret.ftl b/Resources/Locale/en-US/game-ticking/game-presets/preset-secret.ftl index 892e5c3994..2551b0073d 100644 --- a/Resources/Locale/en-US/game-ticking/game-presets/preset-secret.ftl +++ b/Resources/Locale/en-US/game-ticking/game-presets/preset-secret.ftl @@ -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 index 0000000000..02298e0faa --- /dev/null +++ b/Resources/Prototypes/GameRules/dynamic_rules.yml @@ -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 diff --git a/Resources/Prototypes/GameRules/events.yml b/Resources/Prototypes/GameRules/events.yml index 78602f97b9..2642c0286b 100644 --- a/Resources/Prototypes/GameRules/events.yml +++ b/Resources/Prototypes/GameRules/events.yml @@ -30,15 +30,20 @@ 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 @@ -183,6 +188,8 @@ pickPlayer: false mindRoles: - MindRoleDragon + - type: DynamicRuleCost + cost: 75 - type: entity parent: BaseGameRule @@ -233,6 +240,8 @@ nameFormat: name-format-ninja mindRoles: - MindRoleNinja + - type: DynamicRuleCost + cost: 75 - type: entity parent: BaseGameRule @@ -269,6 +278,8 @@ sound: /Audio/Misc/paradox_clone_greeting.ogg mindRoles: - MindRoleParadoxClone + - type: DynamicRuleCost + cost: 50 - type: entity parent: BaseGameRule @@ -520,6 +531,8 @@ - type: InitialInfected mindRoles: - MindRoleInitialInfected + - type: DynamicRuleCost + cost: 200 - type: entity parent: BaseNukeopsRule @@ -556,6 +569,8 @@ - Syndicate mindRoles: - MindRoleNukeops + - type: DynamicRuleCost + cost: 75 - type: entity parent: BaseTraitorRule diff --git a/Resources/Prototypes/GameRules/roundstart.yml b/Resources/Prototypes/GameRules/roundstart.yml index a7c7af7f37..df4d59fd5a 100644 --- a/Resources/Prototypes/GameRules/roundstart.yml +++ b/Resources/Prototypes/GameRules/roundstart.yml @@ -173,6 +173,8 @@ - Syndicate mindRoles: - MindRoleNukeops + - type: DynamicRuleCost + cost: 200 - type: entity abstract: true @@ -188,6 +190,8 @@ maxDifficulty: 5 - type: AntagSelection agentName: traitor-round-end-agent-name + - type: DynamicRuleCost + cost: 100 - type: entity parent: BaseTraitorRule @@ -207,7 +211,7 @@ blacklist: components: - AntagImmune - lateJoinAdditional: true + lateJoinAdditional: false mindRoles: - MindRoleTraitor @@ -278,6 +282,8 @@ - type: HeadRevolutionary mindRoles: - MindRoleHeadRevolutionary + - type: DynamicRuleCost + cost: 200 - type: entity id: Sandbox @@ -341,6 +347,8 @@ nameFormat: name-format-wizard mindRoles: - MindRoleWizard + - type: DynamicRuleCost + cost: 150 - type: entity id: Zombie @@ -373,6 +381,8 @@ - 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). @@ -400,6 +410,8 @@ tableId: BasicCalmEventsTable - !type:NestedSelector tableId: BasicAntagEventsTable + - !type:NestedSelector + tableId: ModerateAntagEventsTable - !type:NestedSelector tableId: CargoGiftsTable - !type:NestedSelector @@ -407,6 +419,21 @@ - !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 @@ -426,6 +453,14 @@ 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 diff --git a/Resources/Prototypes/GameRules/subgamemodes.yml b/Resources/Prototypes/GameRules/subgamemodes.yml index 2213623c28..0dbef6e06b 100644 --- a/Resources/Prototypes/GameRules/subgamemodes.yml +++ b/Resources/Prototypes/GameRules/subgamemodes.yml @@ -34,6 +34,8 @@ - MindRoleThief briefing: sound: "/Audio/Misc/thief_greeting.ogg" + - type: DynamicRuleCost + cost: 75 # Needs testing - type: entity diff --git a/Resources/Prototypes/game_presets.yml b/Resources/Prototypes/game_presets.yml index 160bb5e4a0..a9d33b0b76 100644 --- a/Resources/Prototypes/game_presets.yml +++ b/Resources/Prototypes/game_presets.yml @@ -96,6 +96,23 @@ - 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: