From d3ccbaa3372ee27708552906997cd542c5159743 Mon Sep 17 00:00:00 2001 From: Licho Date: Sun, 12 Apr 2026 14:04:43 +0200 Subject: [PATCH 1/4] paralell attacks --- .../.idea/projectSettingsUpdater.xml | 1 + Shared/PlasmaShared/GlobalConst.cs | 4 +- Shared/PlasmaShared/Utils.cs | 8 +- .../SpringieInterface/BattleResultHandler.cs | 2 +- .../SpringieInterface/PlanetWarsMatchMaker.cs | 922 ++++++++++++------ .../PlanetWarsMatchMakerState.cs | 26 +- .../PlanetWarsTurnHandler.cs | 119 +-- 7 files changed, 719 insertions(+), 363 deletions(-) diff --git a/.idea/.idea.Zero-K/.idea/projectSettingsUpdater.xml b/.idea/.idea.Zero-K/.idea/projectSettingsUpdater.xml index 64af657f5c..ef20cb08cd 100644 --- a/.idea/.idea.Zero-K/.idea/projectSettingsUpdater.xml +++ b/.idea/.idea.Zero-K/.idea/projectSettingsUpdater.xml @@ -2,6 +2,7 @@ \ No newline at end of file diff --git a/Shared/PlasmaShared/GlobalConst.cs b/Shared/PlasmaShared/GlobalConst.cs index ae1e4dbf56..aec6c31ff6 100644 --- a/Shared/PlasmaShared/GlobalConst.cs +++ b/Shared/PlasmaShared/GlobalConst.cs @@ -210,8 +210,8 @@ static void SetMode(ModeType newMode) public const int PostVoteHideThreshold = -6; public const bool OnlyAdminsSeePostVoters = false; public const int PlanetWarsMinutesToAttackIfNoOption = 2; - public const int PlanetWarsMinutesToAttack = 20; - public const int PlanetWarsMinutesToAccept = 5; + public const int PlanetWarsMinutesToAttack = 5; + public const int PlanetWarsMinutesToAccept = 10; public const int PlanetWarsDropshipsStayForMinutes = 2*60; public const int PlanetWarsMaxTeamsize = 4; public const double PlanetWarsDefenderWinKillCcMultiplier = 0.2; diff --git a/Shared/PlasmaShared/Utils.cs b/Shared/PlasmaShared/Utils.cs index 249f1dc81f..24baf9a709 100644 --- a/Shared/PlasmaShared/Utils.cs +++ b/Shared/PlasmaShared/Utils.cs @@ -537,12 +537,18 @@ public static Thread SafeThread(Action action) public static U Get(this IDictionary dict, T key) - where U : class { U val = default(U); if (key != null) dict.TryGetValue(key, out val); return val; } + + public static U GetOrDefault(this IDictionary dict, T key, U defaultValue) + { + U val = defaultValue; + if (key != null) dict.TryGetValue(key, out val); + return val; + } public static List Shuffle(this IEnumerable source) diff --git a/ZkLobbyServer/SpringieInterface/BattleResultHandler.cs b/ZkLobbyServer/SpringieInterface/BattleResultHandler.cs index 6ec45d6319..3523a01eb4 100644 --- a/ZkLobbyServer/SpringieInterface/BattleResultHandler.cs +++ b/ZkLobbyServer/SpringieInterface/BattleResultHandler.cs @@ -184,7 +184,7 @@ private static void ProcessPlanetWars(SpringBattleContext result, ZkLobbyServer. if (winNum > 1) winNum = null; } - PlanetWarsTurnHandler.EndTurn(result.LobbyStartContext.Map, + PlanetWarsTurnHandler.ProcessBattleResult(result.LobbyStartContext.Map, result.OutputExtras, db, winNum, diff --git a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs index dae6ac55a0..4ddf059316 100644 --- a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs +++ b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -8,6 +8,7 @@ using LobbyClient; using Newtonsoft.Json; using PlasmaShared; +using Ratings; using ZkData; using ZkLobbyServer; @@ -20,13 +21,12 @@ public class PlanetWarsMatchMaker : PlanetWarsMatchMakerState { private readonly List factions; - private int missedDefenseCount = 0; private int missedDefenseFactionID = 0; private ZkLobbyServer.ZkLobbyServer server; - private Timer timer; + /// /// Faction that should attack this turn /// @@ -35,18 +35,16 @@ public class PlanetWarsMatchMaker : PlanetWarsMatchMakerState public PlanetWarsMatchMaker(ZkLobbyServer.ZkLobbyServer server) { - this.server = server; AttackOptions = new List(); + FormedSquads = new List(); + DefenderVotes = new Dictionary>(); RunningBattles = new Dictionary(); - var db = new ZkDataContext(); - var gal = db.Galaxies.FirstOrDefault(x => x.IsDefault); if (gal == null) return; - factions = db.Factions.Where(x => !x.IsDeleted).ToList(); PlanetWarsMatchMakerState dbState = null; @@ -62,16 +60,20 @@ public PlanetWarsMatchMaker(ZkLobbyServer.ZkLobbyServer server) if (dbState != null) { AttackerSideCounter = dbState.AttackerSideCounter; - AttackOptions = dbState.AttackOptions; - Challenge = dbState.Challenge; - ChallengeTime = dbState.ChallengeTime; + AttackOptions = dbState.AttackOptions ?? new List(); + Phase = dbState.Phase; + PhaseStartTime = dbState.PhaseStartTime; + FormedSquads = dbState.FormedSquads ?? new List(); + DefenderVotes = dbState.DefenderVotes ?? new Dictionary>(); AttackerSideChangeTime = dbState.AttackerSideChangeTime; - RunningBattles = dbState.RunningBattles; + RunningBattles = dbState.RunningBattles ?? new Dictionary(); } else { AttackerSideCounter = gal.AttackerSideCounter; AttackerSideChangeTime = gal.AttackerSideChangeTime ?? DateTime.UtcNow; + Phase = PwPhase.AttackCollect; + PhaseStartTime = GetNextTurnBoundary(); } timer = new Timer(1045); @@ -80,94 +82,379 @@ public PlanetWarsMatchMaker(ZkLobbyServer.ZkLobbyServer server) timer.Start(); } - private async Task AcceptChallenge() + + // ===================== TIMER / STATE MACHINE ===================== + + private PlanetWarsModes? lastPlanetWarsMode; + + private async void TimerOnElapsed(object sender, ElapsedEventArgs elapsedEventArgs) { - if (missedDefenseFactionID == Challenge.OwnerFactionID) + try { - missedDefenseCount = 0; - missedDefenseFactionID = 0; + timer.Stop(); + + // auto change PW mode based on time + if (MiscVar.PlanetWarsNextModeTime != null && MiscVar.PlanetWarsNextModeTime < DateTime.UtcNow && MiscVar.PlanetWarsNextMode != null) + { + MiscVar.PlanetWarsMode = MiscVar.PlanetWarsNextMode ?? PlanetWarsModes.AllOffline; + MiscVar.PlanetWarsNextMode = null; + MiscVar.PlanetWarsNextModeTime = null; + + using (var db = new ZkDataContext()) + { + db.Events.Add(server.PlanetWarsEventCreator.CreateEvent("PlanetWars changed status to {0}", MiscVar.PlanetWarsMode.Description())); + db.SaveChanges(); + } + } + + if (MiscVar.PlanetWarsMode != lastPlanetWarsMode) + { + server.Broadcast(GeneratePwStatus()); + UpdateLobby(); + lastPlanetWarsMode = MiscVar.PlanetWarsMode; + } + + if (MiscVar.PlanetWarsMode != PlanetWarsModes.Running) return; + + switch (Phase) + { + case PwPhase.AttackCollect: + if (DateTime.UtcNow > GetAttackDeadline()) + { + RunSquadFormation(); + if (FormedSquads.Any()) + { + // transition to defend + Phase = PwPhase.DefendCollect; + PhaseStartTime = DateTime.UtcNow; + UpdateLobby(); + } + else + { + // nobody attacked, skip to next faction + AttackerSideCounter++; + ResetAttackOptions(); + } + } + break; + + case PwPhase.DefendCollect: + if (DateTime.UtcNow > GetDefendDeadline()) + { + RunDefenderAssignment(); + await LaunchAllBattles(); + RunGalaxyTick(); + AttackerSideCounter++; + ResetAttackOptions(); + } + break; + } + } + catch (Exception ex) + { + Trace.TraceError("PlanetWars timer error: {0}", ex); } + finally + { + timer.Start(); + } + } + - // only really start if attackers are present, otherwise missed battle opportunity basically - Challenge.Attackers = Challenge.Attackers.Where(x => server.ConnectedUsers.ContainsKey(x)).ToList(); - Challenge.Defenders = Challenge.Defenders.Where(x => server.ConnectedUsers.ContainsKey(x)).ToList(); - if (Challenge.Attackers.Any() || Challenge.Defenders.Any()) + // ===================== SQUAD FORMATION (PIERCING) ===================== + + private void RunSquadFormation() + { + FormedSquads.Clear(); + + // collect all attackers still connected, grouped by planet + var playerPlanet = new Dictionary(); // player -> their chosen option + foreach (var opt in AttackOptions) { - var battle = new PlanetWarsServerBattle(server, Challenge); - await server.AddBattle(battle); - RunningBattles[battle.BattleID] = Challenge; + opt.Attackers = opt.Attackers.Where(x => server.ConnectedUsers.ContainsKey(x)).ToList(); + foreach (var name in opt.Attackers) + playerPlanet[name] = opt; + } - // also join in lobby - foreach (var usr in Challenge.Attackers.Union(Challenge.Defenders)) await server.ForceJoinBattle(usr, battle); + if (!playerPlanet.Any()) return; - if (await battle.StartGame()) + // look up PW-WHR and PW-Rank for each player + var playerWhr = new Dictionary(); + var playerRoleOrder = new Dictionary(); // lower = higher faction rank + using (var db = new ZkDataContext()) + { + foreach (var name in playerPlanet.Keys.ToList()) { + var user = server.ConnectedUsers.Get(name)?.User; + if (user == null) { playerPlanet.Remove(name); continue; } - var text = - $"Battle for planet {Challenge.Name} starts on zk://@join_player:{Challenge.Attackers.FirstOrDefault()} Roster: {string.Join(",", Challenge.Attackers)} vs {string.Join(",", Challenge.Defenders)}"; + var rating = RatingSystems.GetRatingSystem(RatingCategory.Planetwars).GetPlayerRating(user.AccountID); + playerWhr[name] = rating.LadderElo; - foreach (var fac in factions) await server.GhostChanSay(fac.Shortcut, text); + // PW-Rank: faction role DisplayOrder, lower = higher rank. No role = int.MaxValue + var account = db.Accounts.Find(user.AccountID); + var factionRole = account?.AccountRolesByAccountID + .Where(r => r.RoleType != null && !r.RoleType.IsClanOnly && r.RoleType.RestrictFactionID == AttackingFaction.FactionID) + .Select(r => r.RoleType.DisplayOrder) + .OrderBy(x => x) + .Cast() + .FirstOrDefault(); + playerRoleOrder[name] = factionRole ?? int.MaxValue; } - else await server.RemoveBattle(battle); } - AttackerSideCounter++; - ResetAttackOptions(); + var pool = new HashSet(playerPlanet.Keys); + + // Pass 1: while any planet has >= TeamSize players, form squads from top WHR + bool formed; + do + { + formed = false; + foreach (var opt in AttackOptions) + { + var available = opt.Attackers.Where(pool.Contains).OrderByDescending(x => playerWhr.Get(x)).ToList(); + while (available.Count >= opt.TeamSize) + { + var squad = CreateSquadFromOption(opt); + squad.Attackers = available.Take(opt.TeamSize).ToList(); + FormedSquads.Add(squad); + foreach (var p in squad.Attackers) pool.Remove(p); + available = available.Skip(opt.TeamSize).ToList(); + formed = true; + } + } + } while (formed); // repeat in case removing players from one planet frees up nothing, but be safe + + // Pass 2: piercing — top PW-Rank player pulls others to their planet + while (pool.Count > 0) + { + // find top PW-Rank player (lowest DisplayOrder, tiebreak by WHR desc) + var leader = pool + .OrderBy(x => playerRoleOrder.GetOrDefault(x, int.MaxValue)) + .ThenByDescending(x => playerWhr.Get(x)) + .First(); + + var leaderOption = playerPlanet[leader]; + if (pool.Count < leaderOption.TeamSize) + break; // not enough players for any squad + + var fillers = pool + .Where(x => x != leader) + .OrderByDescending(x => playerWhr.Get(x)) + .Take(leaderOption.TeamSize - 1) + .ToList(); + + if (fillers.Count < leaderOption.TeamSize - 1) + break; // not enough + + var squad = CreateSquadFromOption(leaderOption); + squad.Attackers = new List { leader }; + squad.Attackers.AddRange(fillers); + FormedSquads.Add(squad); + + pool.Remove(leader); + foreach (var p in fillers) pool.Remove(p); + } + + AttackOptions.Clear(); + + // initialize defender votes for attacked planets + DefenderVotes.Clear(); + foreach (var planetId in FormedSquads.Select(s => s.PlanetID).Distinct()) + DefenderVotes[planetId] = new List(); + + // notify attackers + foreach (var squad in FormedSquads) + server.Broadcast(squad.Attackers, new PwAttackingPlanet() { PlanetID = squad.PlanetID }); } - /// - /// Invoked from web page - /// - /// - public void AddAttackOption(Planet planet) + private AttackOption CreateSquadFromOption(AttackOption source) { - try + return new AttackOption { - if (MiscVar.PlanetWarsMode != PlanetWarsModes.Running) return; + PlanetID = source.PlanetID, + Map = source.Map, + Name = source.Name, + OwnerFactionID = source.OwnerFactionID, + TeamSize = source.TeamSize, + PlanetImage = source.PlanetImage, + IconSize = source.IconSize, + StructureImages = source.StructureImages, + Attackers = new List(), + Defenders = new List() + }; + } - if (!AttackOptions.Any(x => x.PlanetID == planet.PlanetID) && (Challenge == null) && - (planet.OwnerFactionID != AttackingFaction.FactionID)) + + // ===================== DEFENDER ASSIGNMENT ===================== + + private void RunDefenderAssignment() + { + // look up defender WHR + var defenderWhr = new Dictionary(); + foreach (var kv in DefenderVotes) + { + foreach (var name in kv.Value) { - InternalAddOption(planet); - UpdateLobby(); + if (defenderWhr.ContainsKey(name)) continue; + if (!server.ConnectedUsers.ContainsKey(name)) continue; + var user = server.ConnectedUsers.Get(name)?.User; + if (user == null) continue; + var rating = RatingSystems.GetRatingSystem(RatingCategory.Planetwars).GetPlayerRating(user.AccountID); + defenderWhr[name] = rating.LadderElo; } } - catch (Exception ex) + + // per-planet: assign defenders, overflow to pool + var floatingPool = new List(); + var assignedDefenders = new Dictionary>(); // planetID -> assigned defender names + + foreach (var planetId in FormedSquads.Select(s => s.PlanetID).Distinct()) { - Trace.TraceError("PlanetWars error adding option {0}: {1}", planet, ex); + var totalSlotsNeeded = FormedSquads.Where(s => s.PlanetID == planetId).Sum(s => s.TeamSize); + var volunteers = (DefenderVotes.ContainsKey(planetId) ? DefenderVotes[planetId] : new List()) + .Where(x => server.ConnectedUsers.ContainsKey(x) && defenderWhr.ContainsKey(x)) + .OrderByDescending(x => defenderWhr[x]) + .ToList(); + + if (volunteers.Count > totalSlotsNeeded) + { + assignedDefenders[planetId] = volunteers.Take(totalSlotsNeeded).ToList(); + floatingPool.AddRange(volunteers.Skip(totalSlotsNeeded)); + } + else + { + assignedDefenders[planetId] = volunteers; + } + } + + // floating pool fills unfilled slots on other planets (WHR order) + floatingPool = floatingPool.OrderByDescending(x => defenderWhr.Get(x)).ToList(); + foreach (var planetId in FormedSquads.Select(s => s.PlanetID).Distinct()) + { + var totalSlotsNeeded = FormedSquads.Where(s => s.PlanetID == planetId).Sum(s => s.TeamSize); + var assigned = assignedDefenders[planetId]; + var deficit = totalSlotsNeeded - assigned.Count; + if (deficit > 0 && floatingPool.Count > 0) + { + var toAdd = floatingPool.Take(deficit).ToList(); + assigned.AddRange(toAdd); + foreach (var p in toAdd) floatingPool.Remove(p); + } + } + + // slice defenders into squads: sort squads by avg attacker WHR desc, assign best defenders to best attackers + foreach (var planetId in FormedSquads.Select(s => s.PlanetID).Distinct()) + { + var squadsForPlanet = FormedSquads + .Where(s => s.PlanetID == planetId) + .OrderByDescending(s => s.Attackers.Average(a => GetPlayerWhr(a))) // sort by attacker strength + .ToList(); + + var defenders = assignedDefenders.ContainsKey(planetId) + ? assignedDefenders[planetId].OrderByDescending(x => defenderWhr.Get(x)).ToList() + : new List(); + + int idx = 0; + foreach (var squad in squadsForPlanet) + { + var count = Math.Min(squad.TeamSize, defenders.Count - idx); + if (count == squad.TeamSize) + { + squad.Defenders = defenders.Skip(idx).Take(count).ToList(); + idx += count; + } + // else: squad gets no defenders (concede) - Defenders stays empty + } } } - public PwMatchCommand GenerateLobbyCommand() + private double GetPlayerWhr(string name) { - PwMatchCommand command = null; - try + var user = server.ConnectedUsers.Get(name)?.User; + if (user == null) return 0; + return RatingSystems.GetRatingSystem(RatingCategory.Planetwars).GetPlayerRating(user.AccountID).LadderElo; + } + + + // ===================== LAUNCH BATTLES ===================== + + private async Task LaunchAllBattles() + { + foreach (var squad in FormedSquads) { - if (MiscVar.PlanetWarsMode != PlanetWarsModes.Running) return new PwMatchCommand(PwMatchCommand.ModeType.Clear); + // filter to still-connected + squad.Attackers = squad.Attackers.Where(x => server.ConnectedUsers.ContainsKey(x)).ToList(); + squad.Defenders = squad.Defenders.Where(x => server.ConnectedUsers.ContainsKey(x)).ToList(); - if (Challenge == null) - command = new PwMatchCommand(PwMatchCommand.ModeType.Attack) + if (squad.Defenders.Count == squad.TeamSize && squad.Attackers.Count == squad.TeamSize) + { + // full battle + try { - Options = AttackOptions.Select(x => x.ToVoteOption(PwMatchCommand.ModeType.Attack)).ToList(), - Deadline = GetAttackDeadline(), - DeadlineSeconds = (int)GetAttackDeadline().Subtract(DateTime.UtcNow).TotalSeconds, - AttackerFaction = AttackingFaction.Shortcut - }; - else - command = new PwMatchCommand(PwMatchCommand.ModeType.Defend) + var battle = new PlanetWarsServerBattle(server, squad); + await server.AddBattle(battle); + RunningBattles[battle.BattleID] = squad; + + foreach (var usr in squad.Attackers.Union(squad.Defenders)) + await server.ForceJoinBattle(usr, battle); + + if (await battle.StartGame()) + { + var text = $"Battle for planet {squad.Name} starts on zk://@join_player:{squad.Attackers.FirstOrDefault()} Roster: {string.Join(",", squad.Attackers)} vs {string.Join(",", squad.Defenders)}"; + foreach (var fac in factions) await server.GhostChanSay(fac.Shortcut, text); + } + else + { + await server.RemoveBattle(battle); + RunningBattles.Remove(battle.BattleID); + } + } + catch (Exception ex) { - Options = new List { Challenge.ToVoteOption(PwMatchCommand.ModeType.Defend) }, - Deadline = GetAcceptDeadline(), - DeadlineSeconds = (int)GetAcceptDeadline().Subtract(DateTime.UtcNow).TotalSeconds, - AttackerFaction = AttackingFaction.Shortcut, - DefenderFactions = GetDefendingFactions(Challenge).Select(x => x.Shortcut).ToList() - }; + Trace.TraceError("PlanetWars LaunchBattle error: {0}", ex); + } + } + else if (squad.Attackers.Count == squad.TeamSize) + { + // concede - not enough defenders + RecordPlanetwarsLoss(squad); + } + // else: attackers also disconnected, skip entirely + } + + FormedSquads.Clear(); + DefenderVotes.Clear(); + } + + + // ===================== GALAXY TICK ===================== + + private void RunGalaxyTick() + { + try + { + var text = new StringBuilder(); + using (var db = new ZkDataContext()) + { + PlanetWarsTurnHandler.ProcessGalaxyTick(db, text, server.PlanetWarsEventCreator, server); + } } catch (Exception ex) { - Trace.TraceError("PlanetWars {0}: {1}", nameof(GenerateLobbyCommand), ex); + Trace.TraceError("PlanetWars galaxy tick error: {0}", ex); + } + } + + + // ===================== PLAYER ACTIONS ===================== + + public async Task OnJoinPlanet(ConnectedUser conus, PwJoinPlanet args) + { + if (MiscVar.PlanetWarsMode == PlanetWarsModes.Running) + { + if (conus.User.CanUserPlanetWars()) await JoinPlanet(conus.Name, args.PlanetID); } - return command; } private async Task JoinPlanet(string name, int planetId) @@ -175,12 +462,15 @@ private async Task JoinPlanet(string name, int planetId) try { var user = server.ConnectedUsers.Get(name)?.User; - if (user != null) - { - var faction = factions.First(x => x.Shortcut == user.Faction); - if (faction == AttackingFaction) await JoinPlanetAttack(planetId, name); - else if ((Challenge != null) && GetDefendingFactions(Challenge).Any(y=>y.FactionID == faction.FactionID)) await JoinPlanetDefense(planetId, name); - } + if (user == null) return; + + var faction = factions.FirstOrDefault(x => x.Shortcut == user.Faction); + if (faction == null) return; + + if (Phase == PwPhase.AttackCollect && faction == AttackingFaction) + await JoinPlanetAttack(planetId, name); + else if (Phase == PwPhase.DefendCollect && faction != AttackingFaction) + await JoinPlanetDefense(planetId, name); } catch (Exception ex) { @@ -188,14 +478,72 @@ private async Task JoinPlanet(string name, int planetId) } } - public async Task OnJoinPlanet(ConnectedUser conus, PwJoinPlanet args) + private async Task JoinPlanetAttack(int targetPlanetId, string userName) { - if (MiscVar.PlanetWarsMode == PlanetWarsModes.Running) + var attackOption = AttackOptions.Find(x => x.PlanetID == targetPlanetId); + if (attackOption == null) return; + + var conus = server.ConnectedUsers.Get(userName); + var user = conus?.User; + if (user == null) return; + + using (var db = new ZkDataContext()) { - if (conus.User.CanUserPlanetWars()) await JoinPlanet(conus.Name, args.PlanetID); + var account = db.Accounts.Find(user.AccountID); + if (account == null || account.FactionID != AttackingFaction.FactionID || !account.CanPlayerPlanetWars()) return; + + // remove from other options + foreach (var aop in AttackOptions.Where(x => x.PlanetID != targetPlanetId)) + aop.Attackers.RemoveAll(x => x == userName); + + // add to this option (no cap — it's a vote, squad formation handles sizing) + if (!attackOption.Attackers.Contains(userName)) + { + attackOption.Attackers.Add(user.Name); + await server.GhostChanSay(user.Faction, $"{userName} joins attack on {attackOption.Name}"); + await conus.SendCommand(new PwJoinPlanetSuccess() { PlanetID = targetPlanetId }); + await UpdateLobby(); + } } } + private async Task JoinPlanetDefense(int targetPlanetId, string userName) + { + if (!DefenderVotes.ContainsKey(targetPlanetId)) return; + + var conus = server.ConnectedUsers.Get(userName); + var user = conus?.User; + if (user == null) return; + + using (var db = new ZkDataContext()) + { + var account = db.Accounts.Find(user.AccountID); + if (account == null || !account.CanPlayerPlanetWars()) return; + + // check this user's faction can defend at least one squad on this planet + var squadsOnPlanet = FormedSquads.Where(s => s.PlanetID == targetPlanetId).ToList(); + if (!squadsOnPlanet.Any()) return; + var defendingFactions = GetDefendingFactions(squadsOnPlanet.First()); + if (!defendingFactions.Any(f => f.FactionID == account.FactionID)) return; + + // remove from other planets + foreach (var kv in DefenderVotes) + kv.Value.RemoveAll(x => x == userName); + + // add to this planet + if (!DefenderVotes[targetPlanetId].Contains(userName)) + { + DefenderVotes[targetPlanetId].Add(userName); + await server.GhostChanSay(user.Faction, $"{userName} joins defense of {squadsOnPlanet.First().Name}"); + await conus.SendCommand(new PwJoinPlanetSuccess() { PlanetID = targetPlanetId }); + await UpdateLobby(); + } + } + } + + + // ===================== CONNECTION EVENTS ===================== + public async Task OnLoginAccepted(ConnectedUser connectedUser) { await connectedUser.SendCommand(GeneratePwStatus()); @@ -211,23 +559,26 @@ public async Task OnUserDisconnected(string name) { try { - if (MiscVar.PlanetWarsMode == PlanetWarsModes.Running) + if (MiscVar.PlanetWarsMode != PlanetWarsModes.Running) return; + + bool changed = false; + if (Phase == PwPhase.AttackCollect) { - if (Challenge == null) - { - if (AttackOptions.Count > 0) - { - var sumRemoved = 0; - foreach (var aop in AttackOptions) sumRemoved += aop.Attackers.RemoveAll(x => x == name); - if (sumRemoved > 0) await UpdateLobby(); - } - } - else - { - var userName = name; - if (Challenge.Defenders.RemoveAll(x => x == userName) > 0) await UpdateLobby(); - } + foreach (var aop in AttackOptions) + changed |= aop.Attackers.RemoveAll(x => x == name) > 0; } + else if (Phase == PwPhase.DefendCollect) + { + // remove from defender votes + foreach (var kv in DefenderVotes) + changed |= kv.Value.RemoveAll(x => x == name) > 0; + + // also remove from formed squads (attacker who disconnected after squad formation) + foreach (var squad in FormedSquads) + changed |= squad.Attackers.RemoveAll(x => x == name) > 0; + } + + if (changed) await UpdateLobby(); } catch (Exception ex) { @@ -236,61 +587,160 @@ public async Task OnUserDisconnected(string name) } - public void RemoveFromRunningBattles(int battleID) - { - RunningBattles.Remove(battleID); - } + // ===================== LOBBY COMMANDS ===================== - private async Task UpdateLobby() + public PwMatchCommand GenerateLobbyCommand() { - await - server.Broadcast(server.ConnectedUsers.Values.Where(x => x.User.CanUserPlanetWars()), GenerateLobbyCommand()); - SaveStateToDb(); - } + PwMatchCommand command = null; + try + { + if (MiscVar.PlanetWarsMode != PlanetWarsModes.Running) + return new PwMatchCommand(PwMatchCommand.ModeType.Clear); - private Task UpdateLobby(string player) - { - return server.ConnectedUsers.Get(player)?.SendCommand(GenerateLobbyCommand()); - } + if (Phase == PwPhase.AttackCollect) + { + command = new PwMatchCommand(PwMatchCommand.ModeType.Attack) + { + Options = AttackOptions.Select(x => x.ToVoteOption(PwMatchCommand.ModeType.Attack)).ToList(), + Deadline = GetAttackDeadline(), + DeadlineSeconds = (int)GetAttackDeadline().Subtract(DateTime.UtcNow).TotalSeconds, + AttackerFaction = AttackingFaction.Shortcut + }; + } + else if (Phase == PwPhase.DefendCollect) + { + // aggregate per planet: one VoteOption per planet showing total slots needed + var options = new List(); + foreach (var planetId in FormedSquads.Select(s => s.PlanetID).Distinct()) + { + var squads = FormedSquads.Where(s => s.PlanetID == planetId).ToList(); + var first = squads.First(); + var totalNeeded = squads.Sum(s => s.TeamSize); + var volunteered = DefenderVotes.ContainsKey(planetId) ? DefenderVotes[planetId].Count : 0; - private DateTime GetAcceptDeadline() - { - return ChallengeTime.Value.AddMinutes(GlobalConst.PlanetWarsMinutesToAccept); + options.Add(new PwMatchCommand.VoteOption + { + PlanetID = first.PlanetID, + PlanetName = first.Name, + Map = first.Map, + IconSize = first.IconSize, + StructureImages = first.StructureImages, + PlanetImage = first.PlanetImage, + Count = volunteered, + Needed = totalNeeded + }); + } + + // collect all defending factions across all attacked planets + var allDefFactions = FormedSquads + .SelectMany(s => GetDefendingFactions(s).Select(f => f.Shortcut)) + .Distinct() + .ToList(); + + command = new PwMatchCommand(PwMatchCommand.ModeType.Defend) + { + Options = options, + Deadline = GetDefendDeadline(), + DeadlineSeconds = (int)GetDefendDeadline().Subtract(DateTime.UtcNow).TotalSeconds, + AttackerFaction = AttackingFaction.Shortcut, + DefenderFactions = allDefFactions + }; + } + } + catch (Exception ex) + { + Trace.TraceError("PlanetWars {0}: {1}", nameof(GenerateLobbyCommand), ex); + } + return command; } - private DateTime GetAttackDeadline() + + // ===================== ATTACK OPTIONS ===================== + + /// + /// Invoked from web page + /// + public void AddAttackOption(Planet planet) { - var extra = 0; - if (AttackOptions.Count == 0) + try { - return AttackerSideChangeTime.AddMinutes(GlobalConst.PlanetWarsMinutesToAttackIfNoOption); + if (MiscVar.PlanetWarsMode != PlanetWarsModes.Running) return; + if (Phase != PwPhase.AttackCollect) return; + + if (!AttackOptions.Any(x => x.PlanetID == planet.PlanetID) && + (planet.OwnerFactionID != AttackingFaction.FactionID)) + { + InternalAddOption(planet); + UpdateLobby(); + } } + catch (Exception ex) + { + Trace.TraceError("PlanetWars error adding option {0}: {1}", planet, ex); + } + } - if (missedDefenseFactionID == AttackingFaction.FactionID) extra = Math.Min(missedDefenseCount * GlobalConst.PlanetWarsMinutesToAttack, 60); + /// + /// Total turn duration in minutes (attack + defend). Turns snap to this boundary on the wall clock. + /// + private static readonly int TurnIntervalMinutes = GlobalConst.PlanetWarsMinutesToAttack + GlobalConst.PlanetWarsMinutesToAccept; - return AttackerSideChangeTime.AddMinutes(GlobalConst.PlanetWarsMinutesToAttack + extra); + /// + /// Returns the next wall-clock boundary for attack phase start (e.g. :00, :15, :30, :45 for 15-min turns). + /// If we're already past the current boundary, snaps to the next one. + /// + private static DateTime GetNextTurnBoundary() + { + var now = DateTime.UtcNow; + var minutesSinceHour = now.Minute; + var currentSlot = (minutesSinceHour / TurnIntervalMinutes) * TurnIntervalMinutes; + var nextSlot = currentSlot + TurnIntervalMinutes; + var boundary = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, DateTimeKind.Utc) + .AddMinutes(nextSlot); + return boundary; } - public List GetDefendingFactions(AttackOption target) + private void ResetAttackOptions() { - if (target.OwnerFactionID != null) + AttackOptions.Clear(); + FormedSquads.Clear(); + DefenderVotes.Clear(); + Phase = PwPhase.AttackCollect; + PhaseStartTime = GetNextTurnBoundary(); + AttackerSideChangeTime = PhaseStartTime; + + var contestedPlanetIds = RunningBattles.Values.Select(x => x.PlanetID).ToHashSet(); + + using (var db = new ZkDataContext()) { - var ret = new List(); - ret.Add(factions.Find(x => x.FactionID == target.OwnerFactionID)); + var gal = db.Galaxies.First(x => x.IsDefault); + var cnt = 6; + var attacker = db.Factions.Single(x => x.FactionID == AttackingFaction.FactionID); + var planets = + gal.Planets.Where(x => x.OwnerFactionID != AttackingFaction.FactionID) + .OrderByDescending(x => x.PlanetFactions.Where(y => y.FactionID == AttackingFaction.FactionID).Sum(y => y.Dropships)) + .ThenByDescending(x => x.PlanetFactions.Where(y => y.FactionID == AttackingFaction.FactionID).Sum(y => y.Influence)) + .ToList(); - // add allies as defenders - using (var db = new ZkDataContext()) + foreach (var planet in planets) { - var planet = db.Planets.Find(target.PlanetID); - foreach (var of in db.Factions.Where(x=>!x.IsDeleted && x.FactionID != target.OwnerFactionID && x.FactionID != AttackingFaction.FactionID)) + if (planet.CanMatchMakerPlay(attacker) && !contestedPlanetIds.Contains(planet.PlanetID)) { - if (of.GaveTreatyRight(planet, x=>x.EffectBalanceSameSide == true)) ret.Add(factions.First(x=>x.FactionID == of.FactionID)); + InternalAddOption(planet); + cnt--; } + if (cnt == 0) break; + } + + if (!AttackOptions.Any(y => y.TeamSize == 2)) + { + var planet = planets.FirstOrDefault(x => (x.TeamSize == 2) && x.CanMatchMakerPlay(attacker) && !contestedPlanetIds.Contains(x.PlanetID)); + if (planet != null) InternalAddOption(planet); } - return ret; } - return factions.Where(x => x != AttackingFaction).ToList(); + UpdateLobby(); + server.GhostChanSay(AttackingFaction.Shortcut, "It's your turn! Select a planet to attack"); } private void InternalAddOption(Planet planet) @@ -308,145 +758,97 @@ private void InternalAddOption(Planet planet) }); } - private async Task JoinPlanetAttack(int targetPlanetId, string userName) - { - var attackOption = AttackOptions.Find(x => x.PlanetID == targetPlanetId); - if (attackOption != null) - { - var conus = server.ConnectedUsers.Get(userName); - var user = conus?.User; - if (user != null) - using (var db = new ZkDataContext()) - { - var account = db.Accounts.Find(user.AccountID); - if ((account != null) && (account.FactionID == AttackingFaction.FactionID) && account.CanPlayerPlanetWars()) - { - // remove existing user from other options - foreach (var aop in AttackOptions.Where(x => x.PlanetID != targetPlanetId)) aop.Attackers.RemoveAll(x => x == userName); - - // add user to this option - if (attackOption.Attackers.Count < attackOption.TeamSize && !attackOption.Attackers.Contains(userName)) - { - attackOption.Attackers.Add(user.Name); - await server.GhostChanSay(user.Faction, $"{userName} joins attack on {attackOption.Name}"); - await conus.SendCommand(new PwJoinPlanetSuccess() { PlanetID = targetPlanetId }); + // ===================== HELPERS ===================== - if (attackOption.Attackers.Count >= attackOption.TeamSize) await StartChallenge(attackOption); - else await UpdateLobby(); - } - } - - } - } - } - - private async Task JoinPlanetDefense(int targetPlanetID, string userName) + public List GetDefendingFactions(AttackOption target) { - if ((Challenge != null) && (Challenge.PlanetID == targetPlanetID) && (Challenge.Defenders.Count < Challenge.TeamSize)) + if (target.OwnerFactionID != null) { - var conus = server.ConnectedUsers.Get(userName); - var user = conus?.User; - if (user != null) - { - var db = new ZkDataContext(); - var account = db.Accounts.Find(user.AccountID); - if ((account != null) && GetDefendingFactions(Challenge).Any(y => y.FactionID == account.FactionID) && - account.CanPlayerPlanetWars()) - if (!Challenge.Defenders.Any(y => y == user.Name)) - { - Challenge.Defenders.Add(user.Name); - - await server.GhostChanSay(user.Faction, $"{userName} joins defense of {Challenge.Name}"); - - await conus.SendCommand(new PwJoinPlanetSuccess() { PlanetID = targetPlanetID }); + var ret = new List(); + ret.Add(factions.Find(x => x.FactionID == target.OwnerFactionID)); - if (Challenge.Defenders.Count >= Challenge.TeamSize) await AcceptChallenge(); - else await UpdateLobby(); - } + using (var db = new ZkDataContext()) + { + var planet = db.Planets.Find(target.PlanetID); + foreach (var of in db.Factions.Where(x => !x.IsDeleted && x.FactionID != target.OwnerFactionID && x.FactionID != AttackingFaction.FactionID)) + { + if (of.GaveTreatyRight(planet, x => x.EffectBalanceSameSide == true)) + ret.Add(factions.First(x => x.FactionID == of.FactionID)); + } } + return ret; } + + return factions.Where(x => x != AttackingFaction).ToList(); } private void RecordPlanetwarsLoss(AttackOption option) { if (option.OwnerFactionID != null) if (option.OwnerFactionID == missedDefenseFactionID) - { missedDefenseCount++; - } else { missedDefenseCount = 0; missedDefenseFactionID = option.OwnerFactionID.Value; } - var message = string.Format("{0} won because nobody tried to defend", AttackingFaction.Name); + var message = $"{AttackingFaction.Name} won {option.Name} because nobody tried to defend"; foreach (var fac in factions) server.GhostChanSay(fac.Shortcut, message); - var text = new StringBuilder(); try { var db = new ZkDataContext(); - var playerIds = option.Attackers.Select(x => x).Union(option.Defenders.Select(x => x)).ToList(); + var playerIds = option.Attackers.Union(option.Defenders).ToList(); - PlanetWarsTurnHandler.EndTurn(option.Map, + PlanetWarsTurnHandler.ProcessBattleResult(option.Map, null, db, 0, db.Accounts.Where(x => playerIds.Contains(x.Name) && (x.Faction != null)).ToList(), - text, + new StringBuilder(), null, db.Accounts.Where(x => option.Attackers.Contains(x.Name) && (x.Faction != null)).ToList(), server.PlanetWarsEventCreator, server); } catch (Exception ex) { - Trace.TraceError(ex.ToString()); - text.Append(ex); + Trace.TraceError("PlanetWars RecordLoss error: {0}", ex); } } - private void ResetAttackOptions() + private DateTime GetAttackDeadline() { - AttackOptions.Clear(); - AttackerSideChangeTime = DateTime.UtcNow; - Challenge = null; - ChallengeTime = null; + if (AttackOptions.Count == 0) + return PhaseStartTime.AddMinutes(GlobalConst.PlanetWarsMinutesToAttackIfNoOption); - using (var db = new ZkDataContext()) - { - var gal = db.Galaxies.First(x => x.IsDefault); - var cnt = 6; - var attacker = db.Factions.Single(x => x.FactionID == AttackingFaction.FactionID); - var planets = - gal.Planets.Where(x => x.OwnerFactionID != AttackingFaction.FactionID) - .OrderByDescending(x => x.PlanetFactions.Where(y => y.FactionID == AttackingFaction.FactionID).Sum(y => y.Dropships)) - .ThenByDescending(x => x.PlanetFactions.Where(y => y.FactionID == AttackingFaction.FactionID).Sum(y => y.Influence)) - .ToList(); - // list of planets by attacker's influence + var extra = 0; + if (missedDefenseFactionID == AttackingFaction.FactionID) + extra = Math.Min(missedDefenseCount * GlobalConst.PlanetWarsMinutesToAttack, 60); - foreach (var planet in planets) - { - if (planet.CanMatchMakerPlay(attacker)) - { - // pick only those where you can actually attack atm - InternalAddOption(planet); - cnt--; - } - if (cnt == 0) break; - } + return PhaseStartTime.AddMinutes(GlobalConst.PlanetWarsMinutesToAttack + extra); + } - if (!AttackOptions.Any(y => y.TeamSize == 2)) - { - var planet = planets.FirstOrDefault(x => (x.TeamSize == 2) && x.CanMatchMakerPlay(attacker)); - if (planet != null) InternalAddOption(planet); - } - } + private DateTime GetDefendDeadline() + { + return PhaseStartTime.AddMinutes(GlobalConst.PlanetWarsMinutesToAccept); + } - UpdateLobby(); + public void RemoveFromRunningBattles(int battleID) + { + RunningBattles.Remove(battleID); + } - server.GhostChanSay(AttackingFaction.Shortcut, "It's your turn! Select a planet to attack"); + private async Task UpdateLobby() + { + await server.Broadcast(server.ConnectedUsers.Values.Where(x => x.User.CanUserPlanetWars()), GenerateLobbyCommand()); + SaveStateToDb(); + } + + private Task UpdateLobby(string player) + { + return server.ConnectedUsers.Get(player)?.SendCommand(GenerateLobbyCommand()); } private void SaveStateToDb() @@ -461,81 +863,6 @@ private void SaveStateToDb() db.SaveChanges(); } - - private async Task StartChallenge(AttackOption attackOption) - { - Challenge = attackOption; - ChallengeTime = DateTime.UtcNow; - AttackOptions.Clear(); - await UpdateLobby(); - await server.Broadcast(attackOption.Attackers, new PwAttackingPlanet() { PlanetID = attackOption.PlanetID }); - } - - private PlanetWarsModes? lastPlanetWarsMode; - - private void TimerOnElapsed(object sender, ElapsedEventArgs elapsedEventArgs) - { - try - { - timer.Stop(); - - // auto change PW mode based on time - if (MiscVar.PlanetWarsNextModeTime != null && MiscVar.PlanetWarsNextModeTime < DateTime.UtcNow && MiscVar.PlanetWarsNextMode != null) - { - MiscVar.PlanetWarsMode = MiscVar.PlanetWarsNextMode ?? PlanetWarsModes.AllOffline; - - MiscVar.PlanetWarsNextMode = null; - MiscVar.PlanetWarsNextModeTime = null; - - using (var db = new ZkDataContext()) - { - db.Events.Add(server.PlanetWarsEventCreator.CreateEvent("PlanetWars changed status to {0}", MiscVar.PlanetWarsMode.Description())); - db.SaveChanges(); - } - } - - - if (MiscVar.PlanetWarsMode != lastPlanetWarsMode) - { - server.Broadcast(GeneratePwStatus()); - UpdateLobby(); - lastPlanetWarsMode = MiscVar.PlanetWarsMode; - } - - if (MiscVar.PlanetWarsMode != PlanetWarsModes.Running) return; - - if (Challenge == null) - { - // attack timer - if (DateTime.UtcNow > GetAttackDeadline()) - { - AttackerSideCounter++; - ResetAttackOptions(); - } - } - else - { - // accept timer - if (DateTime.UtcNow > GetAcceptDeadline()) - if ((Challenge.Defenders.Count >= Challenge.Attackers.Count - 1) && (Challenge.Defenders.Count > 0)) AcceptChallenge(); - else - { - RecordPlanetwarsLoss(Challenge); - AttackerSideCounter++; - ResetAttackOptions(); - } - } - } - catch (Exception ex) - { - Trace.TraceError("PlanetWars timer error: {0}", ex); - } - finally - { - timer.Start(); - } - } - private static PwStatus GeneratePwStatus() { return new PwStatus() @@ -547,10 +874,12 @@ private static PwStatus GeneratePwStatus() }; } + + // ===================== NESTED TYPES ===================== + public class AttackOption { public List Attackers { get; set; } - public List Defenders { get; set; } public string Map { get; set; } public string Name { get; set; } @@ -559,8 +888,6 @@ public class AttackOption public int TeamSize { get; set; } public List StructureImages { get; set; } = new List(); public int IconSize { get; set; } - - public string PlanetImage { get; set; } public AttackOption() @@ -571,7 +898,7 @@ public AttackOption() public PwMatchCommand.VoteOption ToVoteOption(PwMatchCommand.ModeType mode) { - var opt = new PwMatchCommand.VoteOption + return new PwMatchCommand.VoteOption { PlanetID = PlanetID, PlanetName = Name, @@ -582,10 +909,7 @@ public PwMatchCommand.VoteOption ToVoteOption(PwMatchCommand.ModeType mode) Count = mode == PwMatchCommand.ModeType.Attack ? Attackers.Count : Defenders.Count, Needed = TeamSize }; - - return opt; } - } } -} \ No newline at end of file +} diff --git a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMakerState.cs b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMakerState.cs index 20eea4a597..908500af26 100644 --- a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMakerState.cs +++ b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMakerState.cs @@ -1,22 +1,38 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; namespace ZeroKWeb { + public enum PwPhase + { + AttackCollect = 0, + DefendCollect = 1 + } + public class PlanetWarsMatchMakerState { /// - /// Possible attack options + /// Possible attack options / planets to vote on /// public List AttackOptions { get; set; } public DateTime AttackerSideChangeTime { get; set; } public int AttackerSideCounter { get; set; } - public PlanetWarsMatchMaker.AttackOption Challenge { get; set; } - public DateTime? ChallengeTime { get; set; } + public PwPhase Phase { get; set; } + public DateTime PhaseStartTime { get; set; } + + /// + /// Formed attack squads after squad formation runs. Each is an AttackOption with Attackers filled. + /// + public List FormedSquads { get; set; } = new List(); + + /// + /// Defender volunteers per planet during DefendCollect phase. Key = PlanetID, Value = list of player names. + /// + public Dictionary> DefenderVotes { get; set; } = new Dictionary>(); public Dictionary RunningBattles { get; set; } = new Dictionary(); public PlanetWarsMatchMakerState() { } } -} \ No newline at end of file +} diff --git a/ZkLobbyServer/SpringieInterface/PlanetWarsTurnHandler.cs b/ZkLobbyServer/SpringieInterface/PlanetWarsTurnHandler.cs index b24adcd1e4..03572f3b49 100644 --- a/ZkLobbyServer/SpringieInterface/PlanetWarsTurnHandler.cs +++ b/ZkLobbyServer/SpringieInterface/PlanetWarsTurnHandler.cs @@ -12,16 +12,18 @@ public static class PlanetWarsTurnHandler { /// - /// Process planet wars turn + /// Process planet wars turn - legacy wrapper calling both per-battle and per-turn processing /// - /// - /// - /// - /// 0 = attacker wins, 1 = defender wins - /// - /// - /// public static void EndTurn(string mapName, List extraData, ZkDataContext db, int? winNum, List players, StringBuilder text, SpringBattle sb, List attackers, IPlanetwarsEventCreator eventCreator, ZkLobbyServer.ZkLobbyServer server) + { + ProcessBattleResult(mapName, extraData, db, winNum, players, text, sb, attackers, eventCreator, server); + ProcessGalaxyTick(db, text, eventCreator, server, mapName, sb); + } + + /// + /// Per-battle processing: influence, metal, structures, attack points + /// + public static void ProcessBattleResult(string mapName, List extraData, ZkDataContext db, int? winNum, List players, StringBuilder text, SpringBattle sb, List attackers, IPlanetwarsEventCreator eventCreator, ZkLobbyServer.ZkLobbyServer server) { if (extraData == null) extraData = new List(); Galaxy gal = db.Galaxies.Single(x => x.IsDefault); @@ -57,8 +59,6 @@ public static void EndTurn(string mapName, List extraData, ZkDataContext string influenceReport = ""; // distribute influence - // save influence gains - // give influence to main attackers double planetIpDefs = planet.GetEffectiveIpDefense(); double baseInfluence = GlobalConst.BaseInfluencePerBattle; @@ -100,7 +100,7 @@ public static void EndTurn(string mapName, List extraData, ZkDataContext if (influence < 0) influence = 0; influence = Math.Floor(influence * 100) / 100; - // main winner influence + // main winner influence PlanetFaction entry = planet.PlanetFactions.FirstOrDefault(x => x.Faction == attacker); if (entry == null) { @@ -112,7 +112,6 @@ public static void EndTurn(string mapName, List extraData, ZkDataContext // clamping of influence - // gained over 100, sole owner if (entry.Influence >= GlobalConst.PlanetWarsMaximumIP) { entry.Influence = GlobalConst.PlanetWarsMaximumIP; @@ -145,8 +144,6 @@ public static void EndTurn(string mapName, List extraData, ZkDataContext } - - // distribute metal var attackersTotalMetal = CalculateFactionMetalGain(planet, hqStructure, attacker, GlobalConst.PlanetWarsAttackerMetal, eventCreator, db, text, sb); var attackerMetal = Math.Floor(attackersTotalMetal / attackers.Count); @@ -222,7 +219,7 @@ public static void EndTurn(string mapName, List extraData, ZkDataContext acc.PwAttackPoints += ap; } - // paranoia! + // event logging try { var mainEvent = eventCreator.CreateEvent("{0} attacked {1} {2} in {3} and {4}. {5}", @@ -301,13 +298,37 @@ public static void EndTurn(string mapName, List extraData, ZkDataContext else planet.PlanetStructures.Remove(s); } - var ev = eventCreator.CreateEvent("All non-evacuated structures have been disabled on {0} planet {1}. {2}", planet.Faction, planet, sb); - db.Events.InsertOnSubmit(ev); - text.AppendLine(ev.PlainText); + var ev2 = eventCreator.CreateEvent("All non-evacuated structures have been disabled on {0} planet {1}. {2}", planet.Faction, planet, sb); + db.Events.InsertOnSubmit(ev2); + text.AppendLine(ev2.PlainText); } db.SaveChanges(); + // planet ownership can change from battle influence — must check immediately + int? oldOwner = planet.OwnerAccountID; + SetPlanetOwners(eventCreator, db, sb); + + // re-fetch planet after ownership update + planet = db.Galaxies.Single(x => x.IsDefault).Planets.Single(x => x.Resource.InternalName == mapName); + if (planet.OwnerAccountID != oldOwner && planet.OwnerAccountID != null) + { + text.AppendFormat("Congratulations!! Planet {0} was conquered by {1} !! {3}/PlanetWars/Planet/{2}\n", + planet.Name, + planet.Account.Name, + planet.PlanetID, + GlobalConst.BaseSiteUrl); + } + } + + /// + /// Per-turn galaxy-wide processing: influence spread, decay, production, VP, treaties + /// Called once per turn from the matchmaker after launching battles. + /// + public static void ProcessGalaxyTick(ZkDataContext db, StringBuilder text, IPlanetwarsEventCreator eventCreator, ZkLobbyServer.ZkLobbyServer server, string mapName = null, SpringBattle sb = null) + { + Galaxy gal = db.Galaxies.Single(x => x.IsDefault); + gal.DecayInfluence(); gal.SpreadInfluence(); @@ -361,11 +382,10 @@ public static void EndTurn(string mapName, List extraData, ZkDataContext foreach (var fac in db.Factions.Where(x => !x.IsDeleted)) fac.ConvertExcessEnergyToMetal(); - int? oldOwner = planet.OwnerAccountID; gal.Turn++; db.SaveChanges(); - db = new ZkDataContext(); // is this needed - attempt to fix setplanetownersbeing buggy + db = new ZkDataContext(); // attempt to fix setplanetowners being buggy SetPlanetOwners(eventCreator, db, sb != null ? db.SpringBattles.Find(sb.SpringBattleID) : null); gal = db.Galaxies.Single(x => x.IsDefault); @@ -379,22 +399,10 @@ public static void EndTurn(string mapName, List extraData, ZkDataContext db.SaveChanges(); } - - planet = gal.Planets.Single(x => x.Resource.InternalName == mapName); - if (planet.OwnerAccountID != oldOwner && planet.OwnerAccountID != null) - { - text.AppendFormat("Congratulations!! Planet {0} was conquered by {1} !! {3}/PlanetWars/Planet/{2}\n", - planet.Name, - planet.Account.Name, - planet.PlanetID, - GlobalConst.BaseSiteUrl); - } - server.PublishUserProfilePlanetwarsPlayers(); - + try { - // store history foreach (Planet p in gal.Planets) { @@ -418,27 +426,30 @@ public static void EndTurn(string mapName, List extraData, ZkDataContext } //rotate map - if (GlobalConst.RotatePWMaps) + if (GlobalConst.RotatePWMaps && mapName != null) { db = new ZkDataContext(); gal = db.Galaxies.Single(x => x.IsDefault); - planet = gal.Planets.Single(x => x.Resource.InternalName == mapName); - var mapList = db.Resources.Where(x => x.MapPlanetWarsIcon != null && x.Planets.Where(p => p.GalaxyID == gal.GalaxyID).Count() == 0 && x.MapSupportLevel>=MapSupportLevel.Featured - && x.ResourceID != planet.MapResourceID && x.MapWaterLevel == planet.Resource.MapWaterLevel).ToList(); - if (mapList.Count > 0) - { - int r = new Random().Next(mapList.Count); - int resourceID = mapList[r].ResourceID; - Resource newMap = db.Resources.Single(x => x.ResourceID == resourceID); - text.AppendLine(String.Format("Map cycler - {0} maps found, selected map {1} to replace map {2}", mapList.Count, newMap.InternalName, planet.Resource.InternalName)); - planet.Resource = newMap; - gal.IsDirty = true; - } - else + var planet2 = gal.Planets.SingleOrDefault(x => x.Resource.InternalName == mapName); + if (planet2 != null) { - text.AppendLine("Map cycler - no maps found"); + var mapList = db.Resources.Where(x => x.MapPlanetWarsIcon != null && x.Planets.Where(p => p.GalaxyID == gal.GalaxyID).Count() == 0 && x.MapSupportLevel>=MapSupportLevel.Featured + && x.ResourceID != planet2.MapResourceID && x.MapWaterLevel == planet2.Resource.MapWaterLevel).ToList(); + if (mapList.Count > 0) + { + int r = new Random().Next(mapList.Count); + int resourceID = mapList[r].ResourceID; + Resource newMap = db.Resources.Single(x => x.ResourceID == resourceID); + text.AppendLine(String.Format("Map cycler - {0} maps found, selected map {1} to replace map {2}", mapList.Count, newMap.InternalName, planet2.Resource.InternalName)); + planet2.Resource = newMap; + gal.IsDirty = true; + } + else + { + text.AppendLine("Map cycler - no maps found"); + } + db.SaveChanges(); } - db.SaveChanges(); } } @@ -458,7 +469,7 @@ private static double CalculateFactionMetalGain(Planet planet, StructureType hq, var ev = eventCreator.CreateEvent("{0} metal gain reduced by {1} because it is disconnected from {2}. {3}", forFaction, hq.EffectDisconnectedMetalMalus, - hq.Name, + hq.Name, sb); db.Events.Add(ev); texts.AppendLine(ev.PlainText); @@ -507,8 +518,6 @@ private static List GetEvacuatedStructureTypes(List extraData, ZkDa /// /// Updates shadow influence and new owners /// - /// - /// optional spring batle that caused this change (for event logging) public static void SetPlanetOwners(IPlanetwarsEventCreator eventCreator, ZkDataContext db = null, SpringBattle sb = null) { if (db == null) db = new ZkDataContext(); @@ -529,7 +538,7 @@ public static void SetPlanetOwners(IPlanetwarsEventCreator eventCreator, ZkDataC if (best == null || best.Influence < GlobalConst.InfluenceToCapturePlanet) { - // planet not capture + // planet not capture if (planet.Faction != null) { @@ -572,7 +581,7 @@ public static void SetPlanetOwners(IPlanetwarsEventCreator eventCreator, ZkDataC FirstOrDefault(); } - // best with planets + // best with planets if (candidate == null) { candidate = @@ -587,7 +596,7 @@ public static void SetPlanetOwners(IPlanetwarsEventCreator eventCreator, ZkDataC // change has occured if (newFaction != planet.Faction) { - // disable structures + // disable structures foreach (PlanetStructure structure in planet.PlanetStructures.Where(x => x.StructureType.OwnerChangeDisablesThis)) { structure.ReactivateAfterBuild(); From b489ec7bc459e61ee1037eaee90328be85d5aad8 Mon Sep 17 00:00:00 2001 From: Licho Date: Sun, 12 Apr 2026 14:05:34 +0200 Subject: [PATCH 2/4] tw --- .../SpringieInterface/PlanetWarsMatchMaker.cs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs index 4ddf059316..b1cf64d268 100644 --- a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs +++ b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs @@ -686,18 +686,20 @@ public void AddAttackOption(Planet planet) private static readonly int TurnIntervalMinutes = GlobalConst.PlanetWarsMinutesToAttack + GlobalConst.PlanetWarsMinutesToAccept; /// - /// Returns the next wall-clock boundary for attack phase start (e.g. :00, :15, :30, :45 for 15-min turns). - /// If we're already past the current boundary, snaps to the next one. + /// Returns the current or next wall-clock turn boundary (e.g. :00, :15, :30, :45 for 15-min turns). + /// If exactly on a boundary, returns that time. Otherwise returns the next one. /// private static DateTime GetNextTurnBoundary() { var now = DateTime.UtcNow; - var minutesSinceHour = now.Minute; - var currentSlot = (minutesSinceHour / TurnIntervalMinutes) * TurnIntervalMinutes; - var nextSlot = currentSlot + TurnIntervalMinutes; - var boundary = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, DateTimeKind.Utc) - .AddMinutes(nextSlot); - return boundary; + // truncate to whole minutes to avoid floating point issues + var nowTrunc = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, 0, DateTimeKind.Utc); + var midnight = nowTrunc.Date; + var totalMinutes = (int)(nowTrunc - midnight).TotalMinutes; + var remainder = totalMinutes % TurnIntervalMinutes; + if (remainder == 0) + return nowTrunc; // already on a boundary + return nowTrunc.AddMinutes(TurnIntervalMinutes - remainder); } private void ResetAttackOptions() From fb9b1d4ba33d2461290cc680b50a707fddda600f Mon Sep 17 00:00:00 2001 From: Licho Date: Sun, 12 Apr 2026 14:17:38 +0200 Subject: [PATCH 3/4] simplify --- .../SpringieInterface/PlanetWarsMatchMaker.cs | 73 +++++++++++-------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs index b1cf64d268..e52f9e0457 100644 --- a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs +++ b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs @@ -116,6 +116,10 @@ private async void TimerOnElapsed(object sender, ElapsedEventArgs elapsedEventAr if (MiscVar.PlanetWarsMode != PlanetWarsModes.Running) return; + // clean up stale running battles (e.g. if Spring process crashed) + var staleBattleIds = RunningBattles.Keys.Where(id => !server.Battles.ContainsKey(id)).ToList(); + foreach (var id in staleBattleIds) RunningBattles.Remove(id); + switch (Phase) { case PwPhase.AttackCollect: @@ -188,8 +192,7 @@ private void RunSquadFormation() var user = server.ConnectedUsers.Get(name)?.User; if (user == null) { playerPlanet.Remove(name); continue; } - var rating = RatingSystems.GetRatingSystem(RatingCategory.Planetwars).GetPlayerRating(user.AccountID); - playerWhr[name] = rating.LadderElo; + playerWhr[name] = GetPlayerWhr(name); // PW-Rank: faction role DisplayOrder, lower = higher rank. No role = int.MaxValue var account = db.Accounts.Find(user.AccountID); @@ -298,18 +301,16 @@ private void RunDefenderAssignment() { if (defenderWhr.ContainsKey(name)) continue; if (!server.ConnectedUsers.ContainsKey(name)) continue; - var user = server.ConnectedUsers.Get(name)?.User; - if (user == null) continue; - var rating = RatingSystems.GetRatingSystem(RatingCategory.Planetwars).GetPlayerRating(user.AccountID); - defenderWhr[name] = rating.LadderElo; + defenderWhr[name] = GetPlayerWhr(name); } } // per-planet: assign defenders, overflow to pool var floatingPool = new List(); var assignedDefenders = new Dictionary>(); // planetID -> assigned defender names + var attackedPlanetIds = FormedSquads.Select(s => s.PlanetID).Distinct().ToList(); - foreach (var planetId in FormedSquads.Select(s => s.PlanetID).Distinct()) + foreach (var planetId in attackedPlanetIds) { var totalSlotsNeeded = FormedSquads.Where(s => s.PlanetID == planetId).Sum(s => s.TeamSize); var volunteers = (DefenderVotes.ContainsKey(planetId) ? DefenderVotes[planetId] : new List()) @@ -330,7 +331,7 @@ private void RunDefenderAssignment() // floating pool fills unfilled slots on other planets (WHR order) floatingPool = floatingPool.OrderByDescending(x => defenderWhr.Get(x)).ToList(); - foreach (var planetId in FormedSquads.Select(s => s.PlanetID).Distinct()) + foreach (var planetId in attackedPlanetIds) { var totalSlotsNeeded = FormedSquads.Where(s => s.PlanetID == planetId).Sum(s => s.TeamSize); var assigned = assignedDefenders[planetId]; @@ -344,7 +345,7 @@ private void RunDefenderAssignment() } // slice defenders into squads: sort squads by avg attacker WHR desc, assign best defenders to best attackers - foreach (var planetId in FormedSquads.Select(s => s.PlanetID).Distinct()) + foreach (var planetId in attackedPlanetIds) { var squadsForPlanet = FormedSquads .Where(s => s.PlanetID == planetId) @@ -631,9 +632,15 @@ public PwMatchCommand GenerateLobbyCommand() }); } - // collect all defending factions across all attacked planets - var allDefFactions = FormedSquads - .SelectMany(s => GetDefendingFactions(s).Select(f => f.Shortcut)) + // collect all defending factions across attacked planets (one DB call per distinct planet, not per squad) + var defFactionCache = new Dictionary>(); + foreach (var pid in options.Select(o => o.PlanetID)) + { + if (!defFactionCache.ContainsKey(pid)) + defFactionCache[pid] = GetDefendingFactions(FormedSquads.First(s => s.PlanetID == pid)); + } + var allDefFactions = defFactionCache.Values + .SelectMany(f => f.Select(x => x.Shortcut)) .Distinct() .ToList(); @@ -801,18 +808,20 @@ private void RecordPlanetwarsLoss(AttackOption option) try { - var db = new ZkDataContext(); - var playerIds = option.Attackers.Union(option.Defenders).ToList(); - - PlanetWarsTurnHandler.ProcessBattleResult(option.Map, - null, - db, - 0, - db.Accounts.Where(x => playerIds.Contains(x.Name) && (x.Faction != null)).ToList(), - new StringBuilder(), - null, - db.Accounts.Where(x => option.Attackers.Contains(x.Name) && (x.Faction != null)).ToList(), - server.PlanetWarsEventCreator, server); + using (var db = new ZkDataContext()) + { + var playerIds = option.Attackers.Union(option.Defenders).ToList(); + + PlanetWarsTurnHandler.ProcessBattleResult(option.Map, + null, + db, + 0, + db.Accounts.Where(x => playerIds.Contains(x.Name) && (x.Faction != null)).ToList(), + new StringBuilder(), + null, + db.Accounts.Where(x => option.Attackers.Contains(x.Name) && (x.Faction != null)).ToList(), + server.PlanetWarsEventCreator, server); + } } catch (Exception ex) { @@ -855,14 +864,14 @@ private Task UpdateLobby(string player) private void SaveStateToDb() { - var db = new ZkDataContext(); - var gal = db.Galaxies.First(x => x.IsDefault); - - gal.MatchMakerState = JsonConvert.SerializeObject((PlanetWarsMatchMakerState)this); - - gal.AttackerSideCounter = AttackerSideCounter; - gal.AttackerSideChangeTime = AttackerSideChangeTime; - db.SaveChanges(); + using (var db = new ZkDataContext()) + { + var gal = db.Galaxies.First(x => x.IsDefault); + gal.MatchMakerState = JsonConvert.SerializeObject((PlanetWarsMatchMakerState)this); + gal.AttackerSideCounter = AttackerSideCounter; + gal.AttackerSideChangeTime = AttackerSideChangeTime; + db.SaveChanges(); + } } private static PwStatus GeneratePwStatus() From ff6323611887cfc6cc61b2ff15eaca308ef6b5c5 Mon Sep 17 00:00:00 2001 From: Licho Date: Sun, 12 Apr 2026 14:20:00 +0200 Subject: [PATCH 4/4] fix --- Fixer/Program.cs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/Fixer/Program.cs b/Fixer/Program.cs index 70ab686d4a..a76099d181 100644 --- a/Fixer/Program.cs +++ b/Fixer/Program.cs @@ -431,17 +431,22 @@ private static void TestPwMatchMaker() { var server = new global::ZkLobbyServer.ZkLobbyServer("", new PlanetwarsEventCreator()); var mm = server.PlanetWarsMatchMaker; - mm.ChallengeTime = DateTime.Now; mm.AttackerSideCounter = 1; - //mm.ResetAttackOptions(); - mm.GenerateLobbyCommand(); - mm.Challenge = mm.AttackOptions[3]; - mm.Challenge.OwnerFactionID = 2; - mm.Challenge.PlanetID = 4375; - mm.GetDefendingFactions(mm.Challenge); mm.GenerateLobbyCommand(); - //mm.Challenge = + // simulate defend phase with a formed squad + if (mm.AttackOptions.Count > 3) + { + var opt = mm.AttackOptions[3]; + opt.OwnerFactionID = 2; + opt.PlanetID = 4375; + mm.FormedSquads.Add(opt); + mm.Phase = PwPhase.DefendCollect; + mm.PhaseStartTime = DateTime.UtcNow; + mm.GetDefendingFactions(opt); + mm.GenerateLobbyCommand(); + } + Global.Server.PlanetWarsMatchMaker.GenerateLobbyCommand(); }