From eb6d24abd06803410cffaab4d2293e7a82df3cb4 Mon Sep 17 00:00:00 2001 From: Moony Date: Sat, 20 Nov 2021 12:32:07 -0600 Subject: [PATCH] Makes map vote and roundstart smart about player count. (#5418) * Makes map vote and roundstart smart about player count. No more Saltern with 30 players, or Knight Ship with 50. * a typo * Address reviews. * Localized. --- Content.Client/Entry/EntryPoint.cs | 1 + .../ContentIntegrationTest.cs | 5 +- .../Tests/Commands/RestartRoundTest.cs | 2 +- Content.Server/Entry/EntryPoint.cs | 2 + .../GameTicking/Commands/ForceMapCommand.cs | 5 +- .../GameTicking/GameTicker.CVars.cs | 4 - .../GameTicking/GameTicker.RoundFlow.cs | 2 +- Content.Server/GameTicking/GameTicker.cs | 2 + Content.Server/IoC/ServerContentIoC.cs | 2 + Content.Server/Maps/GameMapManager.cs | 120 ++++++++++++++++++ Content.Server/Maps/GameMapPrototype.cs | 53 ++++++++ Content.Server/Maps/IGameMapManager.cs | 68 ++++++++++ .../Managers/VoteManager.DefaultVotes.cs | 16 +-- Content.Server/Voting/Managers/VoteManager.cs | 3 + Content.Shared/CCVar/CCVars.cs | 4 +- Resources/Locale/en-US/maps/gamemap.ftl | 1 + Resources/Prototypes/Maps/game.yml | 20 +++ Resources/Prototypes/Maps/test.yml | 7 + 18 files changed, 298 insertions(+), 19 deletions(-) create mode 100644 Content.Server/Maps/GameMapManager.cs create mode 100644 Content.Server/Maps/GameMapPrototype.cs create mode 100644 Content.Server/Maps/IGameMapManager.cs create mode 100644 Resources/Locale/en-US/maps/gamemap.ftl create mode 100644 Resources/Prototypes/Maps/game.yml create mode 100644 Resources/Prototypes/Maps/test.yml diff --git a/Content.Client/Entry/EntryPoint.cs b/Content.Client/Entry/EntryPoint.cs index 0479752ff2..7043970921 100644 --- a/Content.Client/Entry/EntryPoint.cs +++ b/Content.Client/Entry/EntryPoint.cs @@ -89,6 +89,7 @@ namespace Content.Client.Entry prototypes.RegisterIgnore("objective"); prototypes.RegisterIgnore("holiday"); prototypes.RegisterIgnore("aiFaction"); + prototypes.RegisterIgnore("gameMap"); prototypes.RegisterIgnore("behaviorSet"); prototypes.RegisterIgnore("advertisementsPack"); prototypes.RegisterIgnore("metabolizerType"); diff --git a/Content.IntegrationTests/ContentIntegrationTest.cs b/Content.IntegrationTests/ContentIntegrationTest.cs index 5fae291766..4a6851f690 100644 --- a/Content.IntegrationTests/ContentIntegrationTest.cs +++ b/Content.IntegrationTests/ContentIntegrationTest.cs @@ -33,7 +33,10 @@ namespace Content.IntegrationTests (CCVars.HolidaysEnabled.Name, "false", false), // Avoid loading a large map by default for integration tests if none has been specified. - (CCVars.GameMap.Name, "Maps/Test/empty.yml", true) + (CCVars.GameMap.Name, "empty", true), + + // Makes sure IGameMapManager actually listens. + (CCVars.GameMapForced.Name, "true", true) }; private static void SetServerTestCvars(IntegrationOptions options) diff --git a/Content.IntegrationTests/Tests/Commands/RestartRoundTest.cs b/Content.IntegrationTests/Tests/Commands/RestartRoundTest.cs index b3f98ec8ad..f482f8e1b7 100644 --- a/Content.IntegrationTests/Tests/Commands/RestartRoundTest.cs +++ b/Content.IntegrationTests/Tests/Commands/RestartRoundTest.cs @@ -24,7 +24,7 @@ namespace Content.IntegrationTests.Tests.Commands { CVarOverrides = { - [CCVars.GameMap.Name] = "Maps/saltern.yml" + [CCVars.GameMap.Name] = "saltern" } }); diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs index 116a55018a..0628a1e04a 100644 --- a/Content.Server/Entry/EntryPoint.cs +++ b/Content.Server/Entry/EntryPoint.cs @@ -10,6 +10,7 @@ using Content.Server.EUI; using Content.Server.GameTicking; using Content.Server.Holiday.Interfaces; using Content.Server.IoC; +using Content.Server.Maps; using Content.Server.NodeContainer.NodeGroups; using Content.Server.Preferences.Managers; using Content.Server.Sandbox; @@ -91,6 +92,7 @@ namespace Content.Server.Entry IoCManager.Resolve().Initialize(); _euiManager.Initialize(); + IoCManager.Resolve().Initialize(); IoCManager.Resolve().GetEntitySystem().PostInitialize(); IoCManager.Resolve().DoAutoRegistrations(); } diff --git a/Content.Server/GameTicking/Commands/ForceMapCommand.cs b/Content.Server/GameTicking/Commands/ForceMapCommand.cs index 0738029d4a..96776c8a76 100644 --- a/Content.Server/GameTicking/Commands/ForceMapCommand.cs +++ b/Content.Server/GameTicking/Commands/ForceMapCommand.cs @@ -1,4 +1,5 @@ using Content.Server.Administration; +using Content.Server.Maps; using Content.Shared.Administration; using Content.Shared.CCVar; using Robust.Shared.Configuration; @@ -24,10 +25,10 @@ namespace Content.Server.GameTicking.Commands return; } - var cfg = IoCManager.Resolve(); + var gameMap = IoCManager.Resolve(); var name = args[0]; - cfg.SetCVar(CCVars.GameMap, name); + gameMap.ForceSelectMap(name); shell.WriteLine(Loc.GetString("forcemap-command-success", ("map", name))); } } diff --git a/Content.Server/GameTicking/GameTicker.CVars.cs b/Content.Server/GameTicking/GameTicker.CVars.cs index 58cdc6d9cc..94e081cfe5 100644 --- a/Content.Server/GameTicking/GameTicker.CVars.cs +++ b/Content.Server/GameTicking/GameTicker.CVars.cs @@ -12,9 +12,6 @@ namespace Content.Server.GameTicking [ViewVariables] public bool DummyTicker { get; private set; } = false; - [ViewVariables] - public string ChosenMap { get; private set; } = string.Empty; - [ViewVariables] public TimeSpan LobbyDuration { get; private set; } = TimeSpan.Zero; @@ -34,7 +31,6 @@ namespace Content.Server.GameTicking { _configurationManager.OnValueChanged(CCVars.GameLobbyEnabled, value => LobbyEnabled = value, true); _configurationManager.OnValueChanged(CCVars.GameDummyTicker, value => DummyTicker = value, true); - _configurationManager.OnValueChanged(CCVars.GameMap, value => ChosenMap = value, true); _configurationManager.OnValueChanged(CCVars.GameLobbyDuration, value => LobbyDuration = TimeSpan.FromSeconds(value), true); _configurationManager.OnValueChanged(CCVars.GameDisallowLateJoins, value => { DisallowLateJoin = value; UpdateLateJoinStatus(); UpdateJobsAvailable(); }, true); diff --git a/Content.Server/GameTicking/GameTicker.RoundFlow.cs b/Content.Server/GameTicking/GameTicker.RoundFlow.cs index fe15feb7c8..117c294150 100644 --- a/Content.Server/GameTicking/GameTicker.RoundFlow.cs +++ b/Content.Server/GameTicking/GameTicker.RoundFlow.cs @@ -58,7 +58,7 @@ namespace Content.Server.GameTicking { DefaultMap = _mapManager.CreateMap(); var startTime = _gameTiming.RealTime; - var map = ChosenMap; + var map = _gameMapManager.GetSelectedMapChecked(true).MapPath; var grid = _mapLoader.LoadBlueprint(DefaultMap, map); if (grid == null) diff --git a/Content.Server/GameTicking/GameTicker.cs b/Content.Server/GameTicking/GameTicker.cs index 3460ea6eb6..20cff11e69 100644 --- a/Content.Server/GameTicking/GameTicker.cs +++ b/Content.Server/GameTicking/GameTicker.cs @@ -1,4 +1,5 @@ using Content.Server.Chat.Managers; +using Content.Server.Maps; using Content.Server.Preferences.Managers; using Content.Shared.Chat; using Content.Shared.GameTicking; @@ -85,5 +86,6 @@ namespace Content.Server.GameTicking [Dependency] private readonly IBaseServer _baseServer = default!; [Dependency] private readonly IWatchdogApi _watchdogApi = default!; [Dependency] private readonly IReflectionManager _reflectionManager = default!; + [Dependency] private readonly IGameMapManager _gameMapManager = default!; } } diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs index d2cecd0c59..6152a9d3ae 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -11,6 +11,7 @@ using Content.Server.DeviceNetwork; using Content.Server.EUI; using Content.Server.Holiday; using Content.Server.Holiday.Interfaces; +using Content.Server.Maps; using Content.Server.Module; using Content.Server.MoMMI; using Content.Server.NodeContainer.NodeGroups; @@ -52,6 +53,7 @@ namespace Content.Server.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Server/Maps/GameMapManager.cs b/Content.Server/Maps/GameMapManager.cs new file mode 100644 index 0000000000..ac6e151305 --- /dev/null +++ b/Content.Server/Maps/GameMapManager.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Content.Server.Chat.Managers; +using Content.Shared.CCVar; +using Robust.Server.Player; +using Robust.Shared.Configuration; +using Robust.Shared.IoC; +using Robust.Shared.Localization; +using Robust.Shared.Prototypes; +using Robust.Shared.Random; + +namespace Content.Server.Maps +{ + public class GameMapManager : IGameMapManager + { + [Dependency] private readonly IPrototypeManager _prototypeManager = default!; + [Dependency] private readonly IConfigurationManager _configurationManager = default!; + [Dependency] private readonly IPlayerManager _playerManager = default!; + [Dependency] private readonly IRobustRandom _random = default!; + [Dependency] private readonly IChatManager _chatManager = default!; + + private GameMapPrototype _currentMap = default!; + private bool _currentMapForced; + + public void Initialize() + { + _configurationManager.OnValueChanged(CCVars.GameMap, value => + { + if (TryLookupMap(value, out var map)) + _currentMap = map; + else + throw new ArgumentException($"Unknown map prototype {value} was selected!"); + }, true); + _configurationManager.OnValueChanged(CCVars.GameMapForced, value => _currentMapForced = value, true); + } + + public IEnumerable CurrentlyEligibleMaps() + { + var maps = AllVotableMaps().Where(IsMapEligible).ToArray(); + + return maps.Length == 0 ? AllMaps().Where(x => x.Fallback) : maps; + } + + public IEnumerable AllVotableMaps() + { + return _prototypeManager.EnumeratePrototypes().Where(x => x.Votable); + } + + public IEnumerable AllMaps() + { + return _prototypeManager.EnumeratePrototypes(); + } + + public bool TrySelectMap(string gameMap) + { + if (!TryLookupMap(gameMap, out var map) || !IsMapEligible(map)) return false; + + _currentMap = map; + _currentMapForced = false; + return true; + + } + + public void ForceSelectMap(string gameMap) + { + if (!TryLookupMap(gameMap, out var map)) + throw new ArgumentException($"The map \"{gameMap}\" is invalid!"); + _currentMap = map; + _currentMapForced = true; + } + + public void SelectRandomMap() + { + var maps = CurrentlyEligibleMaps().ToList(); + _random.Shuffle(maps); + _currentMap = maps[0]; + _currentMapForced = false; + } + + public GameMapPrototype GetSelectedMap() + { + return _currentMap; + } + + public GameMapPrototype GetSelectedMapChecked(bool loud = false) + { + if (!_currentMapForced && !IsMapEligible(GetSelectedMap())) + { + var oldMap = GetSelectedMap().MapName; + SelectRandomMap(); + if (loud) + { + _chatManager.DispatchServerAnnouncement( + Loc.GetString("gamemap-could-not-use-map-error", + ("oldMap", oldMap), ("newMap", GetSelectedMap().MapName) + )); + } + } + + return GetSelectedMap(); + } + + public bool CheckMapExists(string gameMap) + { + return TryLookupMap(gameMap, out _); + } + + private bool IsMapEligible(GameMapPrototype map) + { + return map.MaxPlayers >= _playerManager.PlayerCount && map.MinPlayers <= _playerManager.PlayerCount; + } + + private bool TryLookupMap(string gameMap, [NotNullWhen(true)] out GameMapPrototype? map) + { + return _prototypeManager.TryIndex(gameMap, out map); + } + } +} diff --git a/Content.Server/Maps/GameMapPrototype.cs b/Content.Server/Maps/GameMapPrototype.cs new file mode 100644 index 0000000000..0ee82bb08e --- /dev/null +++ b/Content.Server/Maps/GameMapPrototype.cs @@ -0,0 +1,53 @@ +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Manager.Attributes; +using Robust.Shared.ViewVariables; + +namespace Content.Server.Maps +{ + /// + /// Prototype data for a game map. + /// + [Prototype("gameMap")] + public class GameMapPrototype : IPrototype + { + /// + [ViewVariables, DataField("id", required: true)] + public string ID { get; } = default!; + + /// + /// Minimum players for the given map. + /// + [ViewVariables, DataField("minPlayers", required: true)] + public uint MinPlayers { get; } + + /// + /// Maximum players for the given map. + /// + [ViewVariables, DataField("maxPlayers")] + public uint MaxPlayers { get; } = uint.MaxValue; + + /// + /// Name of the given map. + /// + [ViewVariables, DataField("mapName", required: true)] + public string MapName { get; } = default!; + + /// + /// Relative directory path to the given map, i.e. `Maps/saltern.yml` + /// + [ViewVariables, DataField("mapPath", required: true)] + public string MapPath { get; } = default!; + + /// + /// Controls if the map can be used as a fallback if no maps are eligible. + /// + [ViewVariables, DataField("fallback")] + public bool Fallback { get; } + + /// + /// Controls if the map can be voted for. + /// + [ViewVariables, DataField("votable")] + public bool Votable { get; } = true; + } +} diff --git a/Content.Server/Maps/IGameMapManager.cs b/Content.Server/Maps/IGameMapManager.cs new file mode 100644 index 0000000000..5af6eadfd4 --- /dev/null +++ b/Content.Server/Maps/IGameMapManager.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; + +namespace Content.Server.Maps +{ + /// + /// Manages which station map will be used for the next round. + /// + public interface IGameMapManager + { + void Initialize(); + + /// + /// Returns all maps eligible to be played right now. + /// + /// enumerator of map prototypes + IEnumerable CurrentlyEligibleMaps(); + + /// + /// Returns all maps that can be voted for. + /// + /// enumerator of map prototypes + IEnumerable AllVotableMaps(); + + /// + /// Returns all maps. + /// + /// enumerator of map prototypes + IEnumerable AllMaps(); + + /// + /// Attempts to select the given map. + /// + /// map prototype + /// success or failure + bool TrySelectMap(string gameMap); + + /// + /// Forces the given map, making sure the game map manager won't reselect if conditions are no longer met at round restart. + /// + /// map prototype + /// success or failure + void ForceSelectMap(string gameMap); + + /// + /// Selects a random map. + /// + void SelectRandomMap(); + + /// + /// Gets the currently selected map, without double-checking if it can be used. + /// + /// selected map + GameMapPrototype GetSelectedMap(); + + /// + /// Gets the currently selected map, double-checking if it can be used. + /// + /// selected map + GameMapPrototype GetSelectedMapChecked(bool loud = false); + + /// + /// Checks if the given map exists + /// + /// name of the map + /// existence + bool CheckMapExists(string gameMap); + } +} diff --git a/Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs b/Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs index 3901a66622..114f44142a 100644 --- a/Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs +++ b/Content.Server/Voting/Managers/VoteManager.DefaultVotes.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Linq; using Content.Server.GameTicking; +using Content.Server.Maps; using Content.Server.RoundEnd; using Content.Shared.CCVar; using Content.Shared.Voting; @@ -146,11 +148,7 @@ namespace Content.Server.Voting.Managers private void CreateMapVote(IPlayerSession? initiator) { - var maps = new Dictionary - { - ["Maps/saltern.yml"] = "Saltern", - ["Maps/packedstation.yml"] = "PackedStation", - }; + var maps = _gameMapManager.CurrentlyEligibleMaps().ToDictionary(map => map, map => map.MapName); var alone = _playerManager.PlayerCount == 1 && initiator != null; var options = new VoteOptions @@ -175,21 +173,21 @@ namespace Content.Server.Voting.Managers vote.OnFinished += (_, args) => { - string picked; + GameMapPrototype picked; if (args.Winner == null) { - picked = (string) _random.Pick(args.Winners); + picked = (GameMapPrototype) _random.Pick(args.Winners); _chatManager.DispatchServerAnnouncement( Loc.GetString("ui-vote-map-tie", ("picked", maps[picked]))); } else { - picked = (string) args.Winner; + picked = (GameMapPrototype) args.Winner; _chatManager.DispatchServerAnnouncement( Loc.GetString("ui-vote-map-win", ("winner", maps[picked]))); } - _cfg.SetCVar(CCVars.GameMap, picked); + _gameMapManager.TrySelectMap(picked.ID); }; } diff --git a/Content.Server/Voting/Managers/VoteManager.cs b/Content.Server/Voting/Managers/VoteManager.cs index 659bc41066..e0945c631d 100644 --- a/Content.Server/Voting/Managers/VoteManager.cs +++ b/Content.Server/Voting/Managers/VoteManager.cs @@ -8,6 +8,7 @@ using Content.Server.Administration; using Content.Server.Administration.Managers; using Content.Server.Afk; using Content.Server.Chat.Managers; +using Content.Server.Maps; using Content.Shared.Administration; using Content.Shared.Voting; using Robust.Server.Player; @@ -16,6 +17,7 @@ using Robust.Shared.Enums; using Robust.Shared.IoC; using Robust.Shared.Localization; using Robust.Shared.Network; +using Robust.Shared.Prototypes; using Robust.Shared.Random; using Robust.Shared.Timing; using Robust.Shared.Utility; @@ -33,6 +35,7 @@ namespace Content.Server.Voting.Managers [Dependency] private readonly IAdminManager _adminMgr = default!; [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly IAfkManager _afkManager = default!; + [Dependency] private readonly IGameMapManager _gameMapManager = default!; private int _nextVoteId = 1; diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index 843ff1c2e3..1c6b242f02 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -75,7 +75,9 @@ namespace Content.Shared.CCVar GameMaxCharacterSlots = CVarDef.Create("game.maxcharacterslots", 10, CVar.ARCHIVE | CVar.SERVERONLY); public static readonly CVarDef - GameMap = CVarDef.Create("game.map", "Maps/saltern.yml", CVar.SERVERONLY); + GameMap = CVarDef.Create("game.map", "saltern", CVar.SERVERONLY); + public static readonly CVarDef + GameMapForced = CVarDef.Create("game.mapforced", false, CVar.SERVERONLY); /// /// Whether a random position offset will be applied to the station on roundstart. diff --git a/Resources/Locale/en-US/maps/gamemap.ftl b/Resources/Locale/en-US/maps/gamemap.ftl new file mode 100644 index 0000000000..3a222483d5 --- /dev/null +++ b/Resources/Locale/en-US/maps/gamemap.ftl @@ -0,0 +1 @@ +gamemap-could-not-use-map-error = Failed to load map {$oldMap} due to it no longer being eligible! Picking {$newMap} instead. diff --git a/Resources/Prototypes/Maps/game.yml b/Resources/Prototypes/Maps/game.yml new file mode 100644 index 0000000000..53656f8093 --- /dev/null +++ b/Resources/Prototypes/Maps/game.yml @@ -0,0 +1,20 @@ +- type: gameMap + id: saltern + mapName: Saltern + mapPath: Maps/saltern.yml + minPlayers: 0 + maxPlayers: 20 + fallback: true + +- type: gameMap + id: packedstation + mapName: Packedstation + mapPath: Maps/packedstation.yml + minPlayers: 15 + +- type: gameMap + id: knightship + mapName: Knight Ship + mapPath: Maps/knightship.yml + minPlayers: 0 + maxPlayers: 8 diff --git a/Resources/Prototypes/Maps/test.yml b/Resources/Prototypes/Maps/test.yml new file mode 100644 index 0000000000..34e4af3aa1 --- /dev/null +++ b/Resources/Prototypes/Maps/test.yml @@ -0,0 +1,7 @@ +- type: gameMap + id: empty + mapName: Empty + mapPath: Maps/Test/empty.yml + minPlayers: 0 + maxPlayers: 0 + votable: false