diff --git a/Shared/LobbyClient/DedicatedServer.cs b/Shared/LobbyClient/DedicatedServer.cs index faa163e63..d4c38716f 100644 --- a/Shared/LobbyClient/DedicatedServer.cs +++ b/Shared/LobbyClient/DedicatedServer.cs @@ -22,7 +22,7 @@ public class DedicatedServer : IDisposable public static EventHandler AnyDedicatedExited; private readonly SpringPaths paths; - private readonly Timer timer = new Timer(20000); + private readonly Timer timer = new Timer(1000); private Dictionary > gamePrivateMessages = new Dictionary >(); @@ -514,8 +514,7 @@ private void timer_Elapsed(object sender, ElapsedEventArgs e) try { var timeSinceStart = DateTime.UtcNow.Subtract(Context.StartTime).TotalSeconds; - const int timeToWait = 160; // force start after 180s - const int timeToWarn = 100; // warn people after 120s + const int timeToWait = 160; // force start after this many seconds if (Context.IsHosting && IsRunning && (Context.IngameStartTime == null)) { @@ -524,7 +523,10 @@ private void timer_Elapsed(object sender, ElapsedEventArgs e) Context.IsTimeoutForceStarted = true; ForceStart(); } - else if (timeSinceStart > timeToWarn) SayGame($"Game will be force started in {Math.Max(20, timeToWait - Math.Round(timeSinceStart))} seconds"); + else + { + talker.SendText($"/luarules pregame_timer_seconds {Convert.ToInt32(timeToWait - timeSinceStart)}"); + } } } catch (Exception ex) diff --git a/Shared/LobbyClient/Spring.SpringBattleContext.cs b/Shared/LobbyClient/Spring.SpringBattleContext.cs index 4d0ad9ef0..36114ecb0 100644 --- a/Shared/LobbyClient/Spring.SpringBattleContext.cs +++ b/Shared/LobbyClient/Spring.SpringBattleContext.cs @@ -47,6 +47,8 @@ public class SpringBattleContext public BattlePlayerResult GetOrAddPlayer(string name) { + if (string.IsNullOrEmpty(name)) return null; // we don't want to add null players + var ret = ActualPlayers.FirstOrDefault(y => y.Name == name); if (ret == null) { diff --git a/Shared/PlasmaShared/GlobalConst.cs b/Shared/PlasmaShared/GlobalConst.cs index ec5429859..9f4001492 100644 --- a/Shared/PlasmaShared/GlobalConst.cs +++ b/Shared/PlasmaShared/GlobalConst.cs @@ -266,7 +266,7 @@ public static void OverrideContentServiceClient(IContentServiceClient client) } - public static string UnitSyncEngine = "unitsync"; + public static string UnitSyncEngine = "105.1.1-1485-g78f9a2c"; public static int SteamContributionJarID = 2; public static Dictionary DlcToKudos = new Dictionary() { { 842950, 100 }, { 842951, 250 }, { 842952, 500 } }; diff --git a/Zero-K.info/Controllers/TourneyController.cs b/Zero-K.info/Controllers/TourneyController.cs index 2fbe8dd09..5660dbc59 100644 --- a/Zero-K.info/Controllers/TourneyController.cs +++ b/Zero-K.info/Controllers/TourneyController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Web; using System.Web.Mvc; @@ -17,7 +18,9 @@ public class TourneyModel public List Team1Ids { get; set; } = new List(); public List Team2Ids { get; set; } = new List(); public string Title { get; set; } - public string ModoptString { get; set; } + + [DisplayFormat(ConvertEmptyStringToNull = false)] + public string ModoptString { get; set; } = ""; } // GET: Tourney diff --git a/ZkLobbyServer/ServerBattle.cs b/ZkLobbyServer/ServerBattle.cs index f1f760915..69b6e4734 100644 --- a/ZkLobbyServer/ServerBattle.cs +++ b/ZkLobbyServer/ServerBattle.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -70,6 +71,12 @@ public class ServerBattle : Battle public CommandPoll ActivePoll { get; private set; } + // Dictionary tracking cooldown for each user that fails a poll + private readonly ConcurrentDictionary pollFailCooldowns = new ConcurrentDictionary(); + + // Returns the count of non-spectators in the current battle + public int NonSpectatorPlayerCount => Users.Values.Count(x => x!= null && !x.IsSpectator); + public bool IsAutohost { get; private set; } public bool IsDefaultGame { get; private set; } = true; public bool IsCbalEnabled { get; private set; } = true; @@ -174,7 +181,7 @@ public List GetAllUserNames() { var ret = Users.Select(x => x.Key).ToList(); if (spring.IsRunning) ret.AddRange(spring.Context.ActualPlayers.Select(x => x.Name)); - return ret.Distinct().ToList(); + return ret.Distinct().Where(x=>x!=null).ToList(); } public BattleCommand GetCommandByName(string name) @@ -652,6 +659,15 @@ public async Task StartVote(Func eligibilitySelector, List await Respond(creator, $"Please wait, another poll already in progress: {ActivePoll.Topic}"); return false; } + + // Check if the user is on cooldown due to a failed poll + if (creator != null && IsOnPollCooldown(creator?.User, out var remain)) + { + await Respond(creator, $"You cannot start a vote for {remain} seconds."); + return false; + } + + await poll.Setup(eligibilitySelector, options, creator, topic); ActivePoll = poll; pollTimer.Interval = timeout * 1000; @@ -659,6 +675,32 @@ public async Task StartVote(Func eligibilitySelector, List return true; } + + private bool IsUserModerator(string username) + { + if (Users.TryGetValue(username, out var ubs) && (ubs?.LobbyUser?.IsAdmin == true)) + return true; + if (server.ConnectedUsers.TryGetValue(username, out var con) && (con?.User?.IsAdmin == true)) // command can be sent by someone not in the battle + return true; + return false; + } + + + private bool IsOnPollCooldown(string username, out int remainSeconds) + { + remainSeconds = 0; + if (pollFailCooldowns.TryGetValue(username, out var blockedUntil)) + { + var diff = blockedUntil - DateTime.UtcNow; + if (diff.TotalSeconds > 0) + { + remainSeconds = (int)Math.Ceiling(diff.TotalSeconds); + return true; + } + } + return false; + } + public async void StopVote() { @@ -669,7 +711,26 @@ public async void StopVote() if (ActivePoll != null) await ActivePoll.End(false); if (pollTimer != null) pollTimer.Enabled = false; ActivePoll = null; + + // Let the poll announce results or do final DB logging await oldPoll?.PublishResult(); + + + // 1) Did the poll pass? + bool pollPassed = oldPoll?.Outcome?.ChosenOption != null; + + // 2) Who started this poll? + string creatorName = oldPoll?.Creator?.User; + + // 3) If poll failed and conditions are met => apply 30s cooldown + if (!string.IsNullOrEmpty(creatorName) && // user is known + !pollPassed // poll is a failure + && IsAutohost // only relevant in autohost + && NonSpectatorPlayerCount >= 10 + && !IsUserModerator(creatorName)) + { + pollFailCooldowns[creatorName] = DateTime.UtcNow.AddSeconds(30); + } } catch (Exception ex) { diff --git a/ZkLobbyServer/autohost/Commands/CmdKick.cs b/ZkLobbyServer/autohost/Commands/CmdKick.cs index 68ea77d61..63d1cf466 100644 --- a/ZkLobbyServer/autohost/Commands/CmdKick.cs +++ b/ZkLobbyServer/autohost/Commands/CmdKick.cs @@ -90,7 +90,7 @@ private bool NotifyAdminChannel(ServerBattle battle, Say e, bool isActualKick) { gtype = string.Format("game on map {0}", battle.MapName); PlasmaShared.BattlePlayerResult res = battle.spring.Context.GetOrAddPlayer(target); - isspec = res.IsSpectator; + isspec = res?.IsSpectator == true; } else {