diff --git a/Content.Server/GameTicking/GameTicker.GamePreset.cs b/Content.Server/GameTicking/GameTicker.GamePreset.cs index 3d44fa5ae3..f03616dfd3 100644 --- a/Content.Server/GameTicking/GameTicker.GamePreset.cs +++ b/Content.Server/GameTicking/GameTicker.GamePreset.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Linq; using Content.Server.GameTicking.Presets; using Content.Server.GameTicking.Rules; using Content.Server.Ghost.Components; @@ -14,7 +15,7 @@ namespace Content.Server.GameTicking { public const float PresetFailedCooldownIncrease = 30f; - private GamePresetPrototype? _preset; + public GamePresetPrototype? Preset { get; private set; } private bool StartPreset(IPlayerSession[] origReadyPlayers, bool force) { @@ -24,7 +25,7 @@ namespace Content.Server.GameTicking if (!startAttempt.Cancelled) return true; - var presetTitle = _preset != null ? Loc.GetString(_preset.ModeTitle) : string.Empty; + var presetTitle = Preset != null ? Loc.GetString(Preset.ModeTitle) : string.Empty; void FailedPresetRestart() { @@ -36,7 +37,7 @@ namespace Content.Server.GameTicking if (_configurationManager.GetCVar(CCVars.GameLobbyFallbackEnabled)) { - var oldPreset = _preset; + var oldPreset = Preset; ClearGameRules(); SetGamePreset(_configurationManager.GetCVar(CCVars.GameLobbyFallbackPreset)); AddGamePresetRules(); @@ -48,7 +49,7 @@ namespace Content.Server.GameTicking _chatManager.DispatchServerAnnouncement( Loc.GetString("game-ticker-start-round-cannot-start-game-mode-fallback", ("failedGameMode", presetTitle), - ("fallbackMode", Loc.GetString(_preset!.ModeTitle)))); + ("fallbackMode", Loc.GetString(Preset!.ModeTitle)))); if (startAttempt.Cancelled) { @@ -78,7 +79,7 @@ namespace Content.Server.GameTicking if (DummyTicker) return; - _preset = preset; + Preset = preset; UpdateInfoText(); if (force) @@ -120,10 +121,10 @@ namespace Content.Server.GameTicking private bool AddGamePresetRules() { - if (DummyTicker || _preset == null) + if (DummyTicker || Preset == null) return false; - foreach (var rule in _preset.Rules) + foreach (var rule in Preset.Rules) { if (!_prototypeManager.TryIndex(rule, out GameRulePrototype? ruleProto)) continue; @@ -136,7 +137,8 @@ namespace Content.Server.GameTicking private void StartGamePresetRules() { - foreach (var rule in _addedGameRules) + // May be touched by the preset during init. + foreach (var rule in _addedGameRules.ToArray()) { StartGameRule(rule); } diff --git a/Content.Server/GameTicking/GameTicker.Lobby.cs b/Content.Server/GameTicking/GameTicker.Lobby.cs index c917af498d..0abe89d920 100644 --- a/Content.Server/GameTicking/GameTicker.Lobby.cs +++ b/Content.Server/GameTicking/GameTicker.Lobby.cs @@ -35,7 +35,7 @@ namespace Content.Server.GameTicking private string GetInfoText() { - if (_preset == null) + if (Preset == null) { return string.Empty; } @@ -43,8 +43,8 @@ namespace Content.Server.GameTicking var playerCount = $"{_playerManager.PlayerCount}"; var map = _gameMapManager.GetSelectedMap(); var mapName = map?.MapName ?? Loc.GetString("game-ticker-no-map-selected"); - var gmTitle = Loc.GetString(_preset.ModeTitle); - var desc = Loc.GetString(_preset.Description); + var gmTitle = Loc.GetString(Preset.ModeTitle); + var desc = Loc.GetString(Preset.Description); return Loc.GetString("game-ticker-get-info-text",("roundId", RoundId), ("playerCount", playerCount),("mapName", mapName),("gmTitle", gmTitle),("desc", desc)); } diff --git a/Content.Server/GameTicking/GameTicker.RoundFlow.cs b/Content.Server/GameTicking/GameTicker.RoundFlow.cs index 0687dd5e77..444f42bec3 100644 --- a/Content.Server/GameTicking/GameTicker.RoundFlow.cs +++ b/Content.Server/GameTicking/GameTicker.RoundFlow.cs @@ -262,7 +262,7 @@ namespace Content.Server.GameTicking RunLevel = GameRunLevel.PostRound; //Tell every client the round has ended. - var gamemodeTitle = _preset != null ? Loc.GetString(_preset.ModeTitle) : string.Empty; + var gamemodeTitle = Preset != null ? Loc.GetString(Preset.ModeTitle) : string.Empty; // Let things add text here. var textEv = new RoundEndTextAppendEvent(); @@ -469,11 +469,11 @@ namespace Content.Server.GameTicking private void AnnounceRound() { - if (_preset == null) return; + if (Preset == null) return; foreach (var proto in _prototypeManager.EnumeratePrototypes()) { - if (!proto.GamePresets.Contains(_preset.ID)) continue; + if (!proto.GamePresets.Contains(Preset.ID)) continue; if (proto.Message != null) _chatManager.DispatchStationAnnouncement(Loc.GetString(proto.Message), playDefaultSound: false); diff --git a/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs b/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs index 72ebfff0e0..594ec15055 100644 --- a/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs +++ b/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs @@ -83,8 +83,10 @@ public sealed class NukeopsRuleSystem : GameRuleSystem _aliveNukeops.Clear(); - var numOps = (int)Math.Min(Math.Floor((double)ev.PlayerPool.Count / _cfg.GetCVar(CCVars.NukeopsPlayersPerOp)), - _cfg.GetCVar(CCVars.NukeopsMaxOps)); + // Between 1 and : needs at least n players per op. + var numOps = Math.Max(1, + (int)Math.Min( + Math.Floor((double)ev.PlayerPool.Count / _cfg.GetCVar(CCVars.NukeopsPlayersPerOp)), _cfg.GetCVar(CCVars.NukeopsMaxOps))); var ops = new IPlayerSession[numOps]; for (var i = 0; i < numOps; i++) { diff --git a/Content.Server/GameTicking/Rules/SecretRuleSystem.cs b/Content.Server/GameTicking/Rules/SecretRuleSystem.cs new file mode 100644 index 0000000000..cf820c2a73 --- /dev/null +++ b/Content.Server/GameTicking/Rules/SecretRuleSystem.cs @@ -0,0 +1,42 @@ +using System.Linq; +using Content.Server.GameTicking.Presets; +using Content.Shared.Random; +using Content.Shared.Random.Helpers; +using Robust.Shared.Prototypes; +using Robust.Shared.Random; + +namespace Content.Server.GameTicking.Rules; + +public sealed class SecretRuleSystem : GameRuleSystem +{ + [Dependency] private readonly IPrototypeManager _prototypeManager = default!; + [Dependency] private readonly IRobustRandom _random = default!; + [Dependency] private readonly GameTicker _ticker = default!; + + public override string Prototype => "Secret"; + + public override void Started() + { + PickRule(); + } + + public override void Ended() + { + // noop + // Preset should already handle it. + return; + } + + private void PickRule() + { + // TODO: This doesn't consider what can't start due to minimum player count, but currently there's no way to know anyway. + // as they use cvars. + var preset = _prototypeManager.Index("Secret").Pick(_random); + Logger.InfoS("gamepreset", $"Selected {preset} for secret."); + + foreach (var rule in _prototypeManager.Index(preset).Rules) + { + _ticker.AddGameRule(_prototypeManager.Index(rule)); + } + } +} diff --git a/Content.Server/GameTicking/Rules/TraitorRuleSystem.cs b/Content.Server/GameTicking/Rules/TraitorRuleSystem.cs index 765bf8ea42..d47d5c012c 100644 --- a/Content.Server/GameTicking/Rules/TraitorRuleSystem.cs +++ b/Content.Server/GameTicking/Rules/TraitorRuleSystem.cs @@ -28,6 +28,7 @@ public sealed class TraitorRuleSystem : GameRuleSystem [Dependency] private readonly IConfigurationManager _cfg = default!; [Dependency] private readonly IObjectivesManager _objectivesManager = default!; [Dependency] private readonly IChatManager _chatManager = default!; + [Dependency] private readonly GameTicker _gameTicker = default!; public override string Prototype => "Traitor"; @@ -47,11 +48,7 @@ public sealed class TraitorRuleSystem : GameRuleSystem SubscribeLocalEvent(OnRoundEndText); } - public override void Started() - { - // This seems silly, but I'll leave it. - _chatManager.DispatchServerAnnouncement(Loc.GetString("rule-traitor-added-announcement")); - } + public override void Started() {} public override void Ended() { @@ -63,6 +60,13 @@ public sealed class TraitorRuleSystem : GameRuleSystem if (!Enabled) return; + // If the current preset doesn't explicitly contain the traitor game rule, just carry on and remove self. + if (_gameTicker.Preset?.Rules.Contains(Prototype) ?? false) + { + _gameTicker.EndGameRule(_prototypeManager.Index(Prototype)); + return; + } + var minPlayers = _cfg.GetCVar(CCVars.TraitorMinPlayers); if (!ev.Forced && ev.Players.Length < minPlayers) { diff --git a/Content.Shared/Random/Helpers/SharedRandomExtensions.cs b/Content.Shared/Random/Helpers/SharedRandomExtensions.cs index 307e324ca0..41b91465e5 100644 --- a/Content.Shared/Random/Helpers/SharedRandomExtensions.cs +++ b/Content.Shared/Random/Helpers/SharedRandomExtensions.cs @@ -1,4 +1,5 @@ -using Content.Shared.Dataset; +using System.Linq; +using Content.Shared.Dataset; using Robust.Shared.Random; namespace Content.Shared.Random.Helpers @@ -9,5 +10,28 @@ namespace Content.Shared.Random.Helpers { return random.Pick(prototype.Values); } + + public static string Pick(this WeightedRandomPrototype prototype, IRobustRandom? random = null) + { + IoCManager.Resolve(ref random); + var picks = prototype.Weights; + var sum = picks.Values.Sum(); + var accumulated = 0f; + + var rand = random.NextFloat() * sum; + + foreach (var (key, weight) in picks) + { + accumulated += weight; + + if (accumulated >= rand) + { + return key; + } + } + + // Shouldn't happen + throw new InvalidOperationException($"Invalid weighted pick for {prototype.ID}!"); + } } } diff --git a/Content.Shared/Random/WeightedRandomPrototype.cs b/Content.Shared/Random/WeightedRandomPrototype.cs new file mode 100644 index 0000000000..01db7c7f59 --- /dev/null +++ b/Content.Shared/Random/WeightedRandomPrototype.cs @@ -0,0 +1,15 @@ +using Robust.Shared.Prototypes; + +namespace Content.Shared.Random; + +/// +/// Generic random weighting dataset to use. +/// +[Prototype("weightedRandom")] +public sealed class WeightedRandomPrototype : IPrototype +{ + [IdDataFieldAttribute] public string ID { get; } = default!; + + [DataField("weights")] + public Dictionary Weights = new(); +} 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 new file mode 100644 index 0000000000..892e5c3994 --- /dev/null +++ b/Resources/Locale/en-US/game-ticking/game-presets/preset-secret.ftl @@ -0,0 +1,2 @@ +secret-title = Secret +secret-description = It's a secret to everyone. The threats you encounter are randomized. diff --git a/Resources/Prototypes/game_presets.yml b/Resources/Prototypes/game_presets.yml index 5a4f342e71..735c359373 100644 --- a/Resources/Prototypes/game_presets.yml +++ b/Resources/Prototypes/game_presets.yml @@ -7,6 +7,17 @@ showInVote: true description: extended-description +- type: gamePreset + id: Secret + alias: + - secret + - sekrit + name: secret-title + showInVote: true + description: secret-description + rules: + - Secret + - type: gamePreset id: Sandbox alias: diff --git a/Resources/Prototypes/game_rules.yml b/Resources/Prototypes/game_rules.yml index c6d82a1a81..dff36b9cda 100644 --- a/Resources/Prototypes/game_rules.yml +++ b/Resources/Prototypes/game_rules.yml @@ -21,3 +21,6 @@ - type: gameRule id: Sandbox + +- type: gameRule + id: Secret diff --git a/Resources/Prototypes/secret_weights.yml b/Resources/Prototypes/secret_weights.yml new file mode 100644 index 0000000000..6866e6875c --- /dev/null +++ b/Resources/Prototypes/secret_weights.yml @@ -0,0 +1,6 @@ +- type: weightedRandom + id: Secret + weights: + Extended: 0.25 + Nukeops: 0.25 + Traitor: 0.75 \ No newline at end of file