diff --git a/Content.Server/GameTicking/GameTicker.GamePreset.cs b/Content.Server/GameTicking/GameTicker.GamePreset.cs index 97e91f04c1..9ccddd110d 100644 --- a/Content.Server/GameTicking/GameTicker.GamePreset.cs +++ b/Content.Server/GameTicking/GameTicker.GamePreset.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using Content.Server.GameTicking.Presets; using Content.Server.Ghost.Components; +using Content.Server.Maps; using Content.Shared.CCVar; using Content.Shared.Damage; using Content.Shared.Damage.Prototypes; @@ -17,8 +18,16 @@ namespace Content.Server.GameTicking { public const float PresetFailedCooldownIncrease = 30f; + /// + /// The selected preset that will be used at the start of the next round. + /// public GamePresetPrototype? Preset { get; private set; } + /// + /// The preset that's currently active. + /// + public GamePresetPrototype? CurrentPreset { get; private set; } + private bool StartPreset(IPlayerSession[] origReadyPlayers, bool force) { var startAttempt = new RoundStartAttemptEvent(origReadyPlayers, force); @@ -27,7 +36,7 @@ namespace Content.Server.GameTicking if (!startAttempt.Cancelled) return true; - var presetTitle = Preset != null ? Loc.GetString(Preset.ModeTitle) : string.Empty; + var presetTitle = CurrentPreset != null ? Loc.GetString(CurrentPreset.ModeTitle) : string.Empty; void FailedPresetRestart() { @@ -93,6 +102,7 @@ namespace Content.Server.GameTicking Preset = preset; UpdateInfoText(); + ValidateMap(); if (force) { @@ -131,12 +141,39 @@ namespace Content.Server.GameTicking return prototype != null; } + public bool IsMapEligible(GameMapPrototype map) + { + if (Preset == null) + return true; + + if (Preset.MapPool == null || !_prototypeManager.TryIndex(Preset.MapPool, out var pool)) + return true; + + return pool.Maps.Contains(map.ID); + } + + private void ValidateMap() + { + if (Preset == null || _gameMapManager.GetSelectedMap() is not { } map) + return; + + if (Preset.MapPool == null || + !_prototypeManager.TryIndex(Preset.MapPool, out var pool)) + return; + + if (pool.Maps.Contains(map.ID)) + return; + + _gameMapManager.SelectMapRandom(); + } + [PublicAPI] private bool AddGamePresetRules() { if (DummyTicker || Preset == null) return false; + CurrentPreset = Preset; foreach (var rule in Preset.Rules) { AddGameRule(rule); diff --git a/Content.Server/GameTicking/GameTicker.Lobby.cs b/Content.Server/GameTicking/GameTicker.Lobby.cs index 1050c7c568..b7b6a29a5a 100644 --- a/Content.Server/GameTicking/GameTicker.Lobby.cs +++ b/Content.Server/GameTicking/GameTicker.Lobby.cs @@ -44,7 +44,8 @@ namespace Content.Server.GameTicking private string GetInfoText() { - if (Preset == null) + var preset = CurrentPreset ?? Preset; + if (preset == null) { return string.Empty; } @@ -72,8 +73,8 @@ namespace Content.Server.GameTicking stationNames.Append(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(RunLevel == GameRunLevel.PreRoundLobby ? "game-ticker-get-info-preround-text" : "game-ticker-get-info-text", ("roundId", RoundId), ("playerCount", playerCount), ("readyCount", readyCount), ("mapName", stationNames.ToString()),("gmTitle", gmTitle),("desc", desc)); } diff --git a/Content.Server/GameTicking/GameTicker.RoundFlow.cs b/Content.Server/GameTicking/GameTicker.RoundFlow.cs index 4883d8e3c0..6808ca4d4a 100644 --- a/Content.Server/GameTicking/GameTicker.RoundFlow.cs +++ b/Content.Server/GameTicking/GameTicker.RoundFlow.cs @@ -114,6 +114,17 @@ namespace Content.Server.GameTicking throw new Exception("invalid config; couldn't select a valid station map!"); } + if (CurrentPreset?.MapPool != null && + _prototypeManager.TryIndex(CurrentPreset.MapPool, out var pool) && + pool.Maps.Contains(mainStationMap.ID)) + { + var msg = Loc.GetString("game-ticker-start-round-invalid-map", + ("map", mainStationMap.MapName), + ("mode", Loc.GetString(CurrentPreset.ModeTitle))); + Log.Debug(msg); + SendServerMessage(msg); + } + // Let game rules dictate what maps we should load. RaiseLocalEvent(new LoadingMapsEvent(maps)); @@ -292,7 +303,7 @@ namespace Content.Server.GameTicking _adminLogger.Add(LogType.EmergencyShuttle, LogImpact.High, $"Round ended, showing summary"); //Tell every client the round has ended. - var gamemodeTitle = Preset != null ? Loc.GetString(Preset.ModeTitle) : string.Empty; + var gamemodeTitle = CurrentPreset != null ? Loc.GetString(CurrentPreset.ModeTitle) : string.Empty; // Let things add text here. var textEv = new RoundEndTextAppendEvent(); @@ -306,7 +317,7 @@ namespace Content.Server.GameTicking //Generate a list of basic player info to display in the end round summary. var listOfPlayerInfo = new List(); // Grab the great big book of all the Minds, we'll need them for this. - var allMinds = Get().AllMinds; + var allMinds = _mindTracker.AllMinds; foreach (var mind in allMinds) { // TODO don't list redundant observer roles? @@ -447,6 +458,7 @@ namespace Content.Server.GameTicking // Clear up any game rules. ClearGameRules(); + CurrentPreset = null; _allPreviousGameRules.Clear(); @@ -514,7 +526,7 @@ namespace Content.Server.GameTicking private void AnnounceRound() { - if (Preset == null) return; + if (CurrentPreset == null) return; var options = _prototypeManager.EnumeratePrototypes().ToList(); diff --git a/Content.Server/GameTicking/GameTicker.cs b/Content.Server/GameTicking/GameTicker.cs index 9554e00989..c50243fdf3 100644 --- a/Content.Server/GameTicking/GameTicker.cs +++ b/Content.Server/GameTicking/GameTicker.cs @@ -39,6 +39,7 @@ namespace Content.Server.GameTicking [Dependency] private readonly SharedTransformSystem _transform = default!; [Dependency] private readonly GhostSystem _ghost = default!; [Dependency] private readonly MindSystem _mind = default!; + [Dependency] private readonly MindTrackerSystem _mindTracker = default!; [Dependency] private readonly MobStateSystem _mobState = default!; [ViewVariables] private bool _initialized; @@ -92,7 +93,8 @@ namespace Content.Server.GameTicking private void SendServerMessage(string message) { - _chatManager.ChatMessageToAll(ChatChannel.Server, message, "", default, false, true); + var wrappedMessage = Loc.GetString("chat-manager-server-wrap-message", ("message", message)); + _chatManager.ChatMessageToAll(ChatChannel.Server, message, wrappedMessage, default, false, true); } public override void Update(float frameTime) diff --git a/Content.Server/GameTicking/Presets/GamePresetPrototype.cs b/Content.Server/GameTicking/Presets/GamePresetPrototype.cs index ff6a3d17ba..e3edb894c0 100644 --- a/Content.Server/GameTicking/Presets/GamePresetPrototype.cs +++ b/Content.Server/GameTicking/Presets/GamePresetPrototype.cs @@ -1,5 +1,7 @@ +using Content.Server.Maps; using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List; namespace Content.Server.GameTicking.Presets @@ -33,5 +35,12 @@ namespace Content.Server.GameTicking.Presets [DataField("rules", customTypeSerializer: typeof(PrototypeIdListSerializer))] public IReadOnlyList Rules { get; } = Array.Empty(); + + /// + /// If specified, the gamemode will only be run with these maps. + /// If none are elligible, the global fallback will be used. + /// + [DataField("supportedMaps", customTypeSerializer: typeof(PrototypeIdSerializer))] + public readonly string? MapPool; } } diff --git a/Content.Server/Maps/GameMapManager.cs b/Content.Server/Maps/GameMapManager.cs index a29c009cef..2fb531e5d0 100644 --- a/Content.Server/Maps/GameMapManager.cs +++ b/Content.Server/Maps/GameMapManager.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; +using Content.Server.GameTicking; using Content.Shared.CCVar; using Robust.Server.Player; using Robust.Shared.Configuration; @@ -11,24 +12,29 @@ namespace Content.Server.Maps; public sealed class GameMapManager : IGameMapManager { + [Dependency] private readonly IEntityManager _entityManager = default!; [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly IConfigurationManager _configurationManager = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IRobustRandom _random = default!; - + [ViewVariables(VVAccess.ReadOnly)] private readonly Queue _previousMaps = new(); [ViewVariables(VVAccess.ReadOnly)] - private GameMapPrototype? _configSelectedMap = default; + private GameMapPrototype? _configSelectedMap; [ViewVariables(VVAccess.ReadOnly)] - private GameMapPrototype? _selectedMap = default; // Don't change this value during a round! + private GameMapPrototype? _selectedMap; // Don't change this value during a round! [ViewVariables(VVAccess.ReadOnly)] private bool _mapRotationEnabled; [ViewVariables(VVAccess.ReadOnly)] private int _mapQueueDepth = 1; + private ISawmill _log = default!; + public void Initialize() { + _log = Logger.GetSawmill("mapsel"); + _configurationManager.OnValueChanged(CCVars.GameMap, value => { if (TryLookupMap(value, out GameMapPrototype? map)) @@ -43,7 +49,7 @@ public sealed class GameMapManager : IGameMapManager } else { - Logger.ErrorS("mapsel", $"Unknown map prototype {value} was selected!"); + _log.Error($"Unknown map prototype {value} was selected!"); } } }, true); @@ -76,21 +82,25 @@ public sealed class GameMapManager : IGameMapManager public IEnumerable AllVotableMaps() { + var poolPrototype = _entityManager.System().Preset?.MapPool ?? + _configurationManager.GetCVar(CCVars.GameMapPool); + if (_prototypeManager.TryIndex(_configurationManager.GetCVar(CCVars.GameMapPool), out var pool)) { foreach (var map in pool.Maps) { if (!_prototypeManager.TryIndex(map, out var mapProto)) { - Logger.Error("Couldn't index map " + map + " in pool " + pool.ID); + _log.Error($"Couldn't index map {map} in pool {poolPrototype}"); continue; } yield return mapProto; } - } else + } + else { - throw new Exception("Could not index map pool prototype " + _configurationManager.GetCVar(CCVars.GameMapPool) + "!"); + throw new Exception($"Could not index map pool prototype {poolPrototype}!"); } } @@ -144,12 +154,12 @@ public sealed class GameMapManager : IGameMapManager { if (_mapRotationEnabled) { - Logger.InfoS("mapsel", "selecting the next map from the rotation queue"); + _log.Info("selecting the next map from the rotation queue"); SelectMapFromRotationQueue(true); } else { - Logger.InfoS("mapsel", "selecting a random map"); + _log.Info("selecting a random map"); SelectMapRandom(); } } @@ -163,7 +173,8 @@ public sealed class GameMapManager : IGameMapManager { return map.MaxPlayers >= _playerManager.PlayerCount && map.MinPlayers <= _playerManager.PlayerCount && - map.Conditions.All(x => x.Check(map)); + map.Conditions.All(x => x.Check(map)) && + _entityManager.System().IsMapEligible(map); } private bool TryLookupMap(string gameMap, [NotNullWhen(true)] out GameMapPrototype? map) @@ -185,23 +196,22 @@ public sealed class GameMapManager : IGameMapManager private GameMapPrototype GetFirstInRotationQueue() { - Logger.InfoS("mapsel", $"map queue: {string.Join(", ", _previousMaps)}"); + _log.Info($"map queue: {string.Join(", ", _previousMaps)}"); var eligible = CurrentlyEligibleMaps() .Select(x => (proto: x, weight: GetMapRotationQueuePriority(x.ID))) .OrderByDescending(x => x.weight) .ToArray(); - Logger.InfoS("mapsel", $"eligible queue: {string.Join(", ", eligible.Select(x => (x.proto.ID, x.weight)))}"); + _log.Info($"eligible queue: {string.Join(", ", eligible.Select(x => (x.proto.ID, x.weight)))}"); // YML "should" be configured with at least one fallback map Debug.Assert(eligible.Length != 0, $"couldn't select a map with {nameof(GetFirstInRotationQueue)}()! No eligible maps and no fallback maps!"); var weight = eligible[0].weight; return eligible.Where(x => x.Item2 == weight) - .OrderBy(x => x.proto.ID) - .First() - .proto; + .MinBy(x => x.proto.ID) + .proto; } private void EnqueueMap(string mapProtoName) diff --git a/Content.Server/Voting/Managers/VoteManager.cs b/Content.Server/Voting/Managers/VoteManager.cs index 94933a0f16..98ad8d8341 100644 --- a/Content.Server/Voting/Managers/VoteManager.cs +++ b/Content.Server/Voting/Managers/VoteManager.cs @@ -5,7 +5,6 @@ using System.Linq; using Content.Server.Administration; using Content.Server.Administration.Logs; using Content.Server.Administration.Managers; -using Content.Server.Afk; using Content.Server.Chat.Managers; using Content.Server.GameTicking; using Content.Server.Maps; @@ -58,13 +57,15 @@ namespace Content.Server.Voting.Managers _playerManager.PlayerStatusChanged += PlayerManagerOnPlayerStatusChanged; _adminMgr.OnPermsChanged += AdminPermsChanged; - _cfg.OnValueChanged(CCVars.VoteEnabled, value => { + _cfg.OnValueChanged(CCVars.VoteEnabled, _ => + { DirtyCanCallVoteAll(); }); foreach (var kvp in _voteTypesToEnableCVars) { - _cfg.OnValueChanged(kvp.Value, value => { + _cfg.OnValueChanged(kvp.Value, _ => + { DirtyCanCallVoteAll(); }); } @@ -294,7 +295,7 @@ namespace Content.Server.Voting.Managers var votesUnavailable = new List<(StandardVoteType, TimeSpan)>(); foreach (var v in _standardVoteTypeValues) { - if (CanCallVote(player, v, out var _isAdmin, out var typeTimeSpan)) + if (CanCallVote(player, v, out _, out var typeTimeSpan)) continue; votesUnavailable.Add((v, typeTimeSpan)); } @@ -324,7 +325,7 @@ namespace Content.Server.Voting.Managers if (!_cfg.GetCVar(CCVars.VoteEnabled)) return false; // Specific standard vote types can be disabled with cvars. - if ((voteType != null) && _voteTypesToEnableCVars.TryGetValue(voteType.Value, out var cvar) && !_cfg.GetCVar(cvar)) + if (voteType != null && _voteTypesToEnableCVars.TryGetValue(voteType.Value, out var cvar) && !_cfg.GetCVar(cvar)) return false; // Cannot start vote if vote is already active (as non-admin). @@ -345,7 +346,7 @@ namespace Content.Server.Voting.Managers if (voteType == StandardVoteType.Preset) { var presets = GetGamePresets(); - if (presets.Count() == 1 && presets.Select(x => x.Key).Single() == EntitySystem.Get().Preset?.ID) + if (presets.Count == 1 && presets.Select(x => x.Key).Single() == _entityManager.System().Preset?.ID) return false; } diff --git a/Resources/Locale/en-US/game-ticking/game-ticker.ftl b/Resources/Locale/en-US/game-ticking/game-ticker.ftl index 5dbeb5088d..004b6aaae0 100644 --- a/Resources/Locale/en-US/game-ticking/game-ticker.ftl +++ b/Resources/Locale/en-US/game-ticking/game-ticker.ftl @@ -2,6 +2,7 @@ game-ticker-restart-round = Restarting round... game-ticker-start-round = The round is starting now... game-ticker-start-round-cannot-start-game-mode-fallback = Failed to start {$failedGameMode} mode! Defaulting to {$fallbackMode}... game-ticker-start-round-cannot-start-game-mode-restart = Failed to start {$failedGameMode} mode! Restarting round... +game-ticker-start-round-invalid-map = Selected map {$map} is inelligible for gamemode {$mode}. Gamemode may not function as intended... game-ticker-unknown-role = Unknown game-ticker-delay-start = Round start has been delayed for {$seconds} seconds. game-ticker-pause-start = Round start has been paused.