From 348e5bb54c4a38e448f4661abb56f6bc12b7cdcd Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 24 Apr 2026 11:58:59 +0200 Subject: [PATCH 1/3] experimental nuke attacker, breaks merge of battles/defenses --- Fixer/Program.cs | 1 - Shared/LobbyClient/Protocol/Messages.cs | 28 + .../Controllers/PlanetwarsController.cs | 14 +- Zero-K.info/Views/Planetwars/Planet.cshtml | 2 +- .../Views/Planetwars/PwMatchMaker.cshtml | 27 +- ZkLobbyServer/ConnectedUser.cs | 5 + .../SpringieInterface/PlanetWarsMatchMaker.cs | 782 +++++++++++------- .../PlanetWarsMatchMakerState.cs | 11 +- ZkLobbyServer/ZkLobbyServer.cs | 4 +- 9 files changed, 533 insertions(+), 341 deletions(-) diff --git a/Fixer/Program.cs b/Fixer/Program.cs index a76099d181..03a10430d3 100644 --- a/Fixer/Program.cs +++ b/Fixer/Program.cs @@ -431,7 +431,6 @@ private static void TestPwMatchMaker() { var server = new global::ZkLobbyServer.ZkLobbyServer("", new PlanetwarsEventCreator()); var mm = server.PlanetWarsMatchMaker; - mm.AttackerSideCounter = 1; mm.GenerateLobbyCommand(); // simulate defend phase with a formed squad diff --git a/Shared/LobbyClient/Protocol/Messages.cs b/Shared/LobbyClient/Protocol/Messages.cs index a9153458e6..1938c8c6a4 100644 --- a/Shared/LobbyClient/Protocol/Messages.cs +++ b/Shared/LobbyClient/Protocol/Messages.cs @@ -705,6 +705,17 @@ public class VoteOption public bool CanSelectForBattle { get; set; } public bool PlayerIsAttacker { get; set; } public bool PlayerIsDefender { get; set; } + /// + /// Faction shortcut of the attacker. Together with forms the (planet, attacker) + /// key that identifies this attack slot. Clients must echo it back in . + /// + public string AttackerFaction { get; set; } + /// Average PW-WHR of the projected attacker squad (top-TeamSize volunteers). 0 when none. + public int AttackerAvgWhr { get; set; } + /// Average PW-WHR of the projected defender squad. Null in AttackCollect phase or when no volunteers. + public int? DefenderAvgWhr { get; set; } + /// Attacker win chance 0-100 derived from WHR delta. Null when either side is empty. + public int? WinChance { get; set; } } } @@ -712,12 +723,27 @@ public class VoteOption public class PwJoinPlanet { public int PlanetID { get; set; } + /// + /// Which attack slot the player is interacting with. Required — a planet can be attacked by multiple + /// factions simultaneously, each a separate slot. For an attacker this should equal the user's own + /// faction; for a defender it identifies which incoming attack to defend against. + /// + public string AttackerFaction { get; set; } + } + + /// + /// Client → server: cancel the player's current attack or defense commitment for the cycle. + /// + [Message(Origin.Client)] + public class PwCancel + { } [Message(Origin.Server)] public class PwRequestJoinPlanet { public int PlanetID { get; set; } + public string AttackerFaction { get; set; } } @@ -725,12 +751,14 @@ public class PwRequestJoinPlanet public class PwJoinPlanetSuccess { public int PlanetID { get; set; } + public string AttackerFaction { get; set; } } [Message(Origin.Server)] public class PwAttackingPlanet { public int PlanetID { get; set; } + public string AttackerFaction { get; set; } } [Message(Origin.Client)] diff --git a/Zero-K.info/Controllers/PlanetwarsController.cs b/Zero-K.info/Controllers/PlanetwarsController.cs index 3f9d770225..b2b1b9dcd7 100644 --- a/Zero-K.info/Controllers/PlanetwarsController.cs +++ b/Zero-K.info/Controllers/PlanetwarsController.cs @@ -797,10 +797,11 @@ public ActionResult MatchMakerAttack(int planetID) { var db = new ZkDataContext(); var planet = db.Planets.Single(x => x.PlanetID == planetID); - if (Global.IsAccountAuthorized && Global.Account.CanPlayerPlanetWars() && planet.CanMatchMakerPlay(db.CurrentAccount().Faction)) + var account = db.CurrentAccount(); + if (Global.IsAccountAuthorized && Global.Account.CanPlayerPlanetWars() && account?.FactionID != null && planet.CanMatchMakerPlay(account.Faction)) { - Global.Server.PlanetWarsMatchMaker.AddAttackOption(planet); - Global.Server.RequestJoinPlanet(Global.Account.Name, planet.PlanetID); + Global.Server.PlanetWarsMatchMaker.AddAttackOption(planet, account.FactionID.Value); + Global.Server.RequestJoinPlanet(Global.Account.Name, planet.PlanetID, account.Faction.Shortcut); } return RedirectToAction("Planet", new { id = planetID }); } @@ -812,16 +813,17 @@ public ActionResult MatchMaker() var pwm = Global.Server.PlanetWarsMatchMaker; if (pwm != null) { - var state = Global.Server.PlanetWarsMatchMaker.GenerateLobbyCommand(); + // admin view gets a per-viewer command so per-option flags render correctly + var state = Global.Server.PlanetWarsMatchMaker.GenerateLobbyCommand(Global.Account?.Name, Global.Account?.Faction?.Shortcut); if (state != null) return View("PwMatchMaker", state); } return Content("Match maker offline"); } [Auth] - public ActionResult MatchMakerJoin(int planetID) + public ActionResult MatchMakerJoin(int planetID, string attackerFaction) { - Global.Server.RequestJoinPlanet(Global.Account.Name, planetID); + Global.Server.RequestJoinPlanet(Global.Account.Name, planetID, attackerFaction); return MatchMaker(); } diff --git a/Zero-K.info/Views/Planetwars/Planet.cshtml b/Zero-K.info/Views/Planetwars/Planet.cshtml index 6e2a5b4848..d9e390319b 100644 --- a/Zero-K.info/Views/Planetwars/Planet.cshtml +++ b/Zero-K.info/Views/Planetwars/Planet.cshtml @@ -38,7 +38,7 @@ -@if (Global.IsAccountAuthorized && db.CurrentAccount().CanPlayerPlanetWars() && (Global.Server.PlanetWarsMatchMaker != null && Global.Server?.PlanetWarsMatchMaker?.AttackingFaction?.FactionID == Global.FactionID) && Model.CanMatchMakerPlay(db.CurrentAccount().Faction)) +@if (Global.IsAccountAuthorized && db.CurrentAccount().CanPlayerPlanetWars() && Global.Server.PlanetWarsMatchMaker != null && Global.Server.PlanetWarsMatchMaker.Phase == ZeroKWeb.PwPhase.AttackCollect && Model.OwnerFactionID != Global.FactionID && Model.CanMatchMakerPlay(db.CurrentAccount().Faction)) { ATTACK PLANET } diff --git a/Zero-K.info/Views/Planetwars/PwMatchMaker.cshtml b/Zero-K.info/Views/Planetwars/PwMatchMaker.cshtml index 85ce5ba16c..b33da51bed 100644 --- a/Zero-K.info/Views/Planetwars/PwMatchMaker.cshtml +++ b/Zero-K.info/Views/Planetwars/PwMatchMaker.cshtml @@ -1,23 +1,21 @@ -@using LobbyClient +@using LobbyClient @using PlasmaShared @using ZeroKWeb @using ZkData @model LobbyClient.PwMatchCommand @{ PwMatchCommand pw = Model; - string text = ""; + string text; var db = new ZkDataContext(); if (pw.Mode == PwMatchCommand.ModeType.Attack) { - text = string.Format("{0} picks a planet to attack", pw.AttackerFaction); + text = "Pick a planet to attack"; } else { - var planetNames = string.Join(", ", pw.Options.Select(o => o.PlanetName)); - text = string.Format("{0} attacks {2}, {1} defends", pw.AttackerFaction, string.Join(",", pw.DefenderFactions), planetNames); + var planetNames = string.Join(", ", pw.Options.Select(o => o.PlanetName + " (" + (o.AttackerFaction ?? "?") + ")")); + text = string.Format("Defend options: {0}", planetNames); } - - bool canClick = (pw.Mode == PwMatchCommand.ModeType.Attack && pw.AttackerFaction == Global.Account.Faction.Shortcut) || (pw.Mode == PwMatchCommand.ModeType.Defend && pw.DefenderFactions.Contains(Global.Account.Faction.Shortcut)); }
@@ -30,14 +28,23 @@ foreach (PwMatchCommand.VoteOption opt in pw.Options) { - @if (canClick) + @if (opt.CanSelectForBattle) { - @Ajax.ActionLink("Join", "MatchMakerJoin", new { planetID = opt.PlanetID }, new AjaxOptions { UpdateTargetId = "matchMaker", InsertionMode = InsertionMode.Replace }) + @Ajax.ActionLink("Join", "MatchMakerJoin", new { planetID = opt.PlanetID, attackerFaction = opt.AttackerFaction }, new AjaxOptions { UpdateTargetId = "matchMaker", InsertionMode = InsertionMode.Replace }) } @Html.PrintPlanet(db.Planets.First(x => x.PlanetID == opt.PlanetID)) + [@opt.AttackerFaction] [@opt.Count/@opt.Needed] + @if (opt.WinChance != null) + { + (WHR A:@opt.AttackerAvgWhr D:@opt.DefenderAvgWhr, @opt.WinChance% attacker) + } + else if (opt.AttackerAvgWhr > 0) + { + (WHR @opt.AttackerAvgWhr) + }      } } -
\ No newline at end of file + diff --git a/ZkLobbyServer/ConnectedUser.cs b/ZkLobbyServer/ConnectedUser.cs index 7bb2750f2e..d858be41ab 100644 --- a/ZkLobbyServer/ConnectedUser.cs +++ b/ZkLobbyServer/ConnectedUser.cs @@ -112,6 +112,11 @@ public async Task Process(PwJoinPlanet args) await server.PlanetWarsMatchMaker.OnJoinPlanet(this, args); } + public async Task Process(PwCancel args) + { + await server.PlanetWarsMatchMaker.OnCancel(this); + } + public async Task Process(KickFromBattle batKick) { diff --git a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs index 7d568e7e5f..9e8e15e546 100644 --- a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs +++ b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs @@ -9,35 +9,32 @@ using Newtonsoft.Json; using PlasmaShared; using Ratings; +using Z.EntityFramework.Plus; using ZkData; using ZkLobbyServer; namespace ZeroKWeb { /// - /// Handles arranging and starting of PW games + /// Handles arranging and starting of PW games. + /// Parallel-turn model: every faction has its own list of attack options per cycle, + /// and each (PlanetID, AttackerFactionID) pair is an independent matchmaking slot with + /// its own attacker volunteers, defender volunteers, eligibility, and battle. /// public class PlanetWarsMatchMaker : PlanetWarsMatchMakerState { private readonly List factions; private ZkLobbyServer.ZkLobbyServer server; - private DateTime? defendersFullTime; // set when total defenders >= total attacker slots + private DateTime? defendersFullTime; // set when every formed squad has enough defender volunteers private Timer timer; - /// - /// Faction that should attack this turn - /// - [JsonIgnore] - public Faction AttackingFaction { get { return factions[AttackerSideCounter % factions.Count]; } } - public PlanetWarsMatchMaker(ZkLobbyServer.ZkLobbyServer server) { this.server = server; AttackOptions = new List(); FormedSquads = new List(); - DefenderVotes = new Dictionary>(); RunningBattles = new Dictionary(); var db = new ZkDataContext(); @@ -58,13 +55,10 @@ public PlanetWarsMatchMaker(ZkLobbyServer.ZkLobbyServer server) } if (dbState != null) { - AttackerSideCounter = dbState.AttackerSideCounter; 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 ?? new Dictionary(); // sanity: if PhaseStartTime is in the future or too old, reset to now @@ -73,8 +67,6 @@ public PlanetWarsMatchMaker(ZkLobbyServer.ZkLobbyServer server) } else { - AttackerSideCounter = gal.AttackerSideCounter; - AttackerSideChangeTime = gal.AttackerSideChangeTime ?? DateTime.UtcNow; Phase = PwPhase.AttackCollect; PhaseStartTime = DateTime.UtcNow; } @@ -131,15 +123,13 @@ private async void TimerOnElapsed(object sender, ElapsedEventArgs elapsedEventAr RunSquadFormation(); if (FormedSquads.Any()) { - // transition to defend Phase = PwPhase.DefendCollect; PhaseStartTime = DateTime.UtcNow; UpdateLobby(); } else { - // nobody attacked, skip to next faction - AttackerSideCounter++; + // no attacks from any faction this cycle: restart cycle ResetAttackOptions(); } } @@ -150,12 +140,22 @@ private async void TimerOnElapsed(object sender, ElapsedEventArgs elapsedEventAr if (DateTime.UtcNow > GetEffectiveDefendDeadline()) { + // Guarantee state-machine progress: if any step throws (RunDefenderAssignment opens + // DB contexts, LaunchAllBattles interacts with Spring, etc.), we still reset and move + // to the next cycle. Otherwise a faulting tick would leave Phase stuck in DefendCollect + // past the deadline forever, re-throwing every second. defendersFullTime = null; - RunDefenderAssignment(); - await LaunchAllBattles(); - RunGalaxyTick(); - await ApplyTurnEndChargeBump(); - AttackerSideCounter++; + try + { + RunDefenderAssignment(); + await LaunchAllBattles(); + RunGalaxyTick(); + await ApplyTurnEndChargeBump(); + } + catch (Exception ex) + { + Trace.TraceError("PlanetWars cycle-end error: {0}", ex); + } ResetAttackOptions(); } break; @@ -178,33 +178,57 @@ private void RunSquadFormation() { FormedSquads.Clear(); - // collect all attackers still connected, grouped by planet - var playerPlanet = new Dictionary(); // player -> their chosen option + // drop disconnected volunteers per option foreach (var opt in AttackOptions) - { opt.Attackers = opt.Attackers.Where(x => server.ConnectedUsers.ContainsKey(x)).ToList(); - foreach (var name in opt.Attackers) - playerPlanet[name] = opt; + + // group options by attacker faction — each faction runs its own piercing pass + foreach (var factionGroup in AttackOptions.GroupBy(o => o.AttackerFactionID)) + { + if (factionGroup.Key == null) continue; + FormSquadsForFaction(factionGroup.Key.Value, factionGroup.ToList()); } - if (!playerPlanet.Any()) return; + AttackOptions.Clear(); + + // notify attackers + foreach (var squad in FormedSquads) + server.Broadcast(squad.Attackers, new PwAttackingPlanet() + { + PlanetID = squad.PlanetID, + AttackerFaction = GetFactionShortcut(squad.AttackerFactionID), + }); + } - // look up PW-WHR and PW-Rank for each player + private string GetFactionShortcut(int? factionId) + { + return factions.FirstOrDefault(f => f.FactionID == factionId)?.Shortcut; + } + + private void FormSquadsForFaction(int attackerFactionId, List factionOptions) + { + var playerOption = new Dictionary(); // player -> their chosen option + foreach (var opt in factionOptions) + foreach (var name in opt.Attackers) + playerOption[name] = opt; + + if (!playerOption.Any()) return; + + // look up PW-WHR and PW-Rank for each player (role is faction-specific) var playerWhr = new Dictionary(); - var playerRoleOrder = new Dictionary(); // lower = higher faction rank + var playerRoleOrder = new Dictionary(); using (var db = new ZkDataContext()) { - foreach (var name in playerPlanet.Keys.ToList()) + foreach (var name in playerOption.Keys.ToList()) { var user = server.ConnectedUsers.Get(name)?.User; - if (user == null) { playerPlanet.Remove(name); continue; } + if (user == null) { playerOption.Remove(name); continue; } playerWhr[name] = GetPlayerWhr(name); - // 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) + .Where(r => r.RoleType != null && !r.RoleType.IsClanOnly && r.RoleType.RestrictFactionID == attackerFactionId) .Select(r => r.RoleType.DisplayOrder) .OrderBy(x => x) .Cast() @@ -213,14 +237,14 @@ private void RunSquadFormation() } } - var pool = new HashSet(playerPlanet.Keys); + var pool = new HashSet(playerOption.Keys); - // Pass 1: while any planet has >= TeamSize players, form squads from top WHR + // Pass 1: form squads from any planet with >= TeamSize volunteers, top-WHR first bool formed; do { formed = false; - foreach (var opt in AttackOptions) + foreach (var opt in factionOptions) { var available = opt.Attackers.Where(pool.Contains).OrderByDescending(x => playerWhr.Get(x)).ToList(); while (available.Count >= opt.TeamSize) @@ -233,20 +257,18 @@ private void RunSquadFormation() formed = true; } } - } while (formed); // repeat in case removing players from one planet frees up nothing, but be safe + } while (formed); - // Pass 2: piercing — top PW-Rank player pulls others to their planet + // Pass 2: piercing — top PW-Rank player pulls others to their chosen 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 leaderOption = playerOption[leader]; + if (pool.Count < leaderOption.TeamSize) break; var fillers = pool .Where(x => x != leader) @@ -254,8 +276,7 @@ private void RunSquadFormation() .Take(leaderOption.TeamSize - 1) .ToList(); - if (fillers.Count < leaderOption.TeamSize - 1) - break; // not enough + if (fillers.Count < leaderOption.TeamSize - 1) break; var squad = CreateSquadFromOption(leaderOption); squad.Attackers = new List { leader }; @@ -266,12 +287,11 @@ private void RunSquadFormation() foreach (var p in fillers) pool.Remove(p); } - // Pass 3: absorb leftovers into an existing squad on their original planet, - // so all attackers join when a planet had more people than TeamSize. + // Pass 3: absorb leftovers into an existing squad on their original planet foreach (var name in pool.ToList()) { - var originalPlanetId = playerPlanet[name].PlanetID; - var squad = FormedSquads.FirstOrDefault(s => s.PlanetID == originalPlanetId); + var originalOption = playerOption[name]; + var squad = FormedSquads.FirstOrDefault(s => s.PlanetID == originalOption.PlanetID && s.AttackerFactionID == attackerFactionId); if (squad != null) { squad.Attackers.Add(name); @@ -279,17 +299,6 @@ private void RunSquadFormation() pool.Remove(name); } } - - 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 }); } private AttackOption CreateSquadFromOption(AttackOption source) @@ -300,12 +309,14 @@ private AttackOption CreateSquadFromOption(AttackOption source) Map = source.Map, Name = source.Name, OwnerFactionID = source.OwnerFactionID, + AttackerFactionID = source.AttackerFactionID, TeamSize = source.TeamSize, PlanetImage = source.PlanetImage, IconSize = source.IconSize, StructureImages = source.StructureImages, Attackers = new List(), - Defenders = new List() + Defenders = new List(), + DefenderVotes = new List() }; } @@ -314,11 +325,11 @@ private AttackOption CreateSquadFromOption(AttackOption source) private void RunDefenderAssignment() { - // look up defender WHR + // collect all defender WHRs upfront var defenderWhr = new Dictionary(); - foreach (var kv in DefenderVotes) + foreach (var squad in FormedSquads) { - foreach (var name in kv.Value) + foreach (var name in squad.DefenderVotes) { if (defenderWhr.ContainsKey(name)) continue; if (!server.ConnectedUsers.ContainsKey(name)) continue; @@ -326,37 +337,33 @@ private void RunDefenderAssignment() } } - // per-planet: assign defenders, overflow to pool + // each squad gets its direct volunteers first (top-WHR); overflow goes into a floating 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 attackedPlanetIds) + foreach (var squad in FormedSquads) { - var totalSlotsNeeded = FormedSquads.Where(s => s.PlanetID == planetId).Sum(s => s.TeamSize); - var volunteers = (DefenderVotes.ContainsKey(planetId) ? DefenderVotes[planetId] : new List()) + var volunteers = squad.DefenderVotes .Where(x => server.ConnectedUsers.ContainsKey(x) && defenderWhr.ContainsKey(x)) .OrderByDescending(x => defenderWhr[x]) .ToList(); - if (volunteers.Count > totalSlotsNeeded) + if (volunteers.Count > squad.TeamSize) { - assignedDefenders[planetId] = volunteers.Take(totalSlotsNeeded).ToList(); - floatingPool.AddRange(volunteers.Skip(totalSlotsNeeded)); + squad.Defenders = volunteers.Take(squad.TeamSize).ToList(); + floatingPool.AddRange(volunteers.Skip(squad.TeamSize)); } else { - assignedDefenders[planetId] = volunteers; + squad.Defenders = volunteers; } } - // floating pool fills unfilled slots on other planets (WHR order, respecting faction eligibility) - floatingPool = floatingPool.OrderByDescending(x => defenderWhr.Get(x)).ToList(); + // floating pool fills deficit on other squads where the defender's faction is eligible + floatingPool = floatingPool.OrderByDescending(x => defenderWhr.Get(x)).Distinct().ToList(); - // cache defending factions per planet and defender faction IDs - var planetDefendingFactions = new Dictionary>(); - foreach (var pid in attackedPlanetIds) - planetDefendingFactions[pid] = GetDefendingFactions(FormedSquads.First(s => s.PlanetID == pid)); + // cache per-squad defending factions and floating-pool faction IDs + var squadDefendingFactions = new Dictionary>(); + foreach (var squad in FormedSquads) + squadDefendingFactions[squad] = GetDefendingFactions(squad).Select(f => f.FactionID).ToHashSet(); var defenderFactionId = new Dictionary(); using (var db = new ZkDataContext()) @@ -368,44 +375,24 @@ private void RunDefenderAssignment() } } - foreach (var planetId in attackedPlanetIds) - { - 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 allowedFactionIds = planetDefendingFactions[planetId].Select(f => f.FactionID).ToHashSet(); - var eligible = floatingPool.Where(x => defenderFactionId.ContainsKey(x) && defenderFactionId[x].HasValue && allowedFactionIds.Contains(defenderFactionId[x].Value)).ToList(); - var toAdd = eligible.Take(deficit).ToList(); - assigned.AddRange(toAdd); - foreach (var p in toAdd) floatingPool.Remove(p); - } - } + // fill deficits: prioritize squads by attacker strength (strongest attacker squads get top floaters first) + var squadsByAttackerStrength = FormedSquads + .OrderByDescending(s => s.Attackers.Any() ? s.Attackers.Average(a => GetPlayerWhr(a)) : 0.0) + .ToList(); - // slice defenders into squads: sort squads by avg attacker WHR desc, assign best defenders to best attackers - foreach (var planetId in attackedPlanetIds) + foreach (var squad in squadsByAttackerStrength) { - var squadsForPlanet = FormedSquads - .Where(s => s.PlanetID == planetId) - .OrderByDescending(s => s.Attackers.Average(a => GetPlayerWhr(a))) // sort by attacker strength - .ToList(); + var deficit = squad.TeamSize - squad.Defenders.Count; + if (deficit <= 0 || floatingPool.Count == 0) continue; - var defenders = assignedDefenders.ContainsKey(planetId) - ? assignedDefenders[planetId].OrderByDescending(x => defenderWhr.Get(x)).ToList() - : new List(); + var allowedFactions = squadDefendingFactions[squad]; + var eligible = floatingPool + .Where(x => defenderFactionId.ContainsKey(x) && defenderFactionId[x].HasValue && allowedFactions.Contains(defenderFactionId[x].Value)) + .Take(deficit) + .ToList(); - int idx = 0; - foreach (var squad in squadsForPlanet) - { - var count = Math.Min(squad.TeamSize, defenders.Count - idx); - if (count > 0) - { - squad.Defenders = defenders.Skip(idx).Take(count).ToList(); - idx += count; - } - // else: no defenders at all for this squad (concede) - } + squad.Defenders.AddRange(eligible); + foreach (var p in eligible) floatingPool.Remove(p); } } @@ -416,45 +403,63 @@ private double GetPlayerWhr(string name) return RatingSystems.GetRatingSystem(RatingCategory.Planetwars).GetPlayerRating(user.AccountID).LadderElo; } + /// + /// Average PW-WHR of the projected top-N squad out of a name pool, trimmed to at most + /// players. Returns 0 if pool is empty. + /// + private int AvgTopNWhr(IEnumerable names, int slots) + { + if (slots <= 0) return 0; + var whrs = names.Select(GetPlayerWhr).Where(w => w > 0).OrderByDescending(w => w).Take(slots).ToList(); + if (whrs.Count == 0) return 0; + return (int)Math.Round(whrs.Average()); + } + + /// + /// Standard Elo-logistic expected score, in percent 0-100. + /// + private static int? ComputeWinChance(int attackerAvg, int? defenderAvg) + { + if (attackerAvg <= 0 || defenderAvg == null || defenderAvg <= 0) return null; + var chance = 1.0 / (1.0 + Math.Pow(10.0, (defenderAvg.Value - attackerAvg) / 400.0)); + return (int)Math.Round(chance * 100); + } + // ===================== LAUNCH BATTLES ===================== private async Task LaunchAllBattles() { + // charges are spent only for attackers whose squad reached a confirmed outcome — either + // the Spring battle successfully started, or it was a concede. Battles that fail to start + // (StartGame returned false, or setup threw) refund the commitment. var attackerNamesToChargeSpend = new List(); - // merge squads on the same planet into one battle per planet - foreach (var planetId in FormedSquads.Select(s => s.PlanetID).Distinct().ToList()) + // one battle per squad — no merging across attacker factions + foreach (var squad in FormedSquads.ToList()) { - var squads = FormedSquads.Where(s => s.PlanetID == planetId).ToList(); - var first = squads.First(); - - // merge all squads into one AttackOption - var merged = CreateSquadFromOption(first); - foreach (var squad in squads) - { - merged.Attackers.AddRange(squad.Attackers.Where(x => server.ConnectedUsers.ContainsKey(x))); - merged.Defenders.AddRange(squad.Defenders.Where(x => server.ConnectedUsers.ContainsKey(x))); - } + squad.Attackers = squad.Attackers.Where(x => server.ConnectedUsers.ContainsKey(x)).ToList(); + squad.Defenders = squad.Defenders.Where(x => server.ConnectedUsers.ContainsKey(x)).ToList(); - if (merged.Attackers.Count > 0) attackerNamesToChargeSpend.AddRange(merged.Attackers); + if (squad.Attackers.Count == 0) continue; - if (merged.Defenders.Count > 0 && merged.Attackers.Count > 0) + if (squad.Defenders.Count > 0) { - // battle (may be uneven) try { - merged.TeamSize = Math.Max(merged.Attackers.Count, merged.Defenders.Count); - var battle = new PlanetWarsServerBattle(server, merged); + squad.TeamSize = Math.Max(squad.Attackers.Count, squad.Defenders.Count); + var battle = new PlanetWarsServerBattle(server, squad); await server.AddBattle(battle); - RunningBattles[battle.BattleID] = merged; + RunningBattles[battle.BattleID] = squad; - foreach (var usr in merged.Attackers.Union(merged.Defenders)) + foreach (var usr in squad.Attackers.Union(squad.Defenders)) await server.ForceJoinBattle(usr, battle); if (await battle.StartGame()) { - var text = $"Battle for planet {merged.Name} starts on zk://@join_player:{merged.Attackers.FirstOrDefault()} Roster: {string.Join(",", merged.Attackers)} vs {string.Join(",", merged.Defenders)}"; + attackerNamesToChargeSpend.AddRange(squad.Attackers); + var attackerFactionShortcut = GetFactionShortcut(squad.AttackerFactionID) ?? "?"; + var text = $"Battle for planet {squad.Name} ({attackerFactionShortcut} attacks) 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 @@ -468,16 +473,15 @@ private async Task LaunchAllBattles() Trace.TraceError("PlanetWars LaunchBattle error: {0}", ex); } } - else if (merged.Attackers.Count > 0) + else { - // concede - zero defenders - RecordPlanetwarsLoss(merged); + // concede — zero defenders. Attackers still "attacked", so charge applies. + attackerNamesToChargeSpend.AddRange(squad.Attackers); + RecordPlanetwarsLoss(squad); } - // else: no attackers left, skip entirely } FormedSquads.Clear(); - DefenderVotes.Clear(); if (attackerNamesToChargeSpend.Count > 0) await SpendAttackCharges(attackerNamesToChargeSpend); } @@ -530,44 +534,57 @@ public async Task OnJoinPlanet(ConnectedUser conus, PwJoinPlanet args) { if (MiscVar.PlanetWarsMode == PlanetWarsModes.Running) { - if (conus.User.CanUserPlanetWars()) await JoinPlanet(conus.Name, args.PlanetID); + if (conus.User.CanUserPlanetWars() && args.PlanetID > 0) + await JoinPlanet(conus.Name, args.PlanetID, args.AttackerFaction); + } + } + + public async Task OnCancel(ConnectedUser conus) + { + if (MiscVar.PlanetWarsMode == PlanetWarsModes.Running) + { + if (conus.User.CanUserPlanetWars()) await CancelPlanet(conus.Name); } } - private async Task JoinPlanet(string name, int planetId) + private async Task JoinPlanet(string userName, int planetId, string attackerFactionShortcut) { try { - var user = server.ConnectedUsers.Get(name)?.User; + var user = server.ConnectedUsers.Get(userName)?.User; 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); + if (Phase == PwPhase.AttackCollect) + await JoinPlanetAttack(userName, planetId, attackerFactionShortcut); + else if (Phase == PwPhase.DefendCollect) + await JoinPlanetDefense(userName, planetId, attackerFactionShortcut); } catch (Exception ex) { - Trace.TraceError("PlanetWars {0} {1} {2} : {3}", nameof(JoinPlanet), name, planetId, ex); + Trace.TraceError("PlanetWars {0} {1} {2} : {3}", nameof(JoinPlanet), userName, planetId, ex); } } - private async Task JoinPlanetAttack(int targetPlanetId, string userName) + private async Task JoinPlanetAttack(string userName, int targetPlanetId, string attackerFactionShortcut) { - 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; + // AttackerFaction is mandatory and must match the user's own faction — attackers can only attack + // for themselves. A mismatch or missing value indicates a client bug or tampering; reject silently. + if (string.IsNullOrEmpty(attackerFactionShortcut) || attackerFactionShortcut != user.Faction) return; + using (var db = new ZkDataContext()) { var account = db.Accounts.Find(user.AccountID); - if (account == null || account.FactionID != AttackingFaction.FactionID || !account.CanPlayerPlanetWars()) return; + if (account == null || account.FactionID == null || !account.CanPlayerPlanetWars()) return; + + var attackOption = AttackOptions.Find(x => x.PlanetID == targetPlanetId && x.AttackerFactionID == account.FactionID); + if (attackOption == null) return; var maxCharges = DynamicConfig.Instance.PwAttackChargesMax; if (maxCharges > 0 && account.PwAttackCharges <= 0) @@ -576,25 +593,26 @@ private async Task JoinPlanetAttack(int targetPlanetId, string userName) return; } - // remove from other options - foreach (var aop in AttackOptions.Where(x => x.PlanetID != targetPlanetId)) + // remove from other attack options (same faction only — other factions' options are independent) + foreach (var aop in AttackOptions.Where(x => x.AttackerFactionID == account.FactionID && 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 conus.SendCommand(new PwJoinPlanetSuccess() + { + PlanetID = targetPlanetId, + AttackerFaction = GetFactionShortcut(attackOption.AttackerFactionID), + }); await UpdateLobby(); } } } - private async Task JoinPlanetDefense(int targetPlanetId, string userName) + private async Task JoinPlanetDefense(string userName, int targetPlanetId, string attackerFactionShortcut) { - if (!DefenderVotes.ContainsKey(targetPlanetId)) return; - var conus = server.ConnectedUsers.Get(userName); var user = conus?.User; if (user == null) return; @@ -604,32 +622,74 @@ private async Task JoinPlanetDefense(int targetPlanetId, string userName) var account = db.Accounts.Find(user.AccountID); if (account == null || !account.CanPlayerPlanetWars()) return; - // check this user's faction can defend this specific planet - var squadsOnPlanet = FormedSquads.Where(s => s.PlanetID == targetPlanetId).ToList(); - if (!squadsOnPlanet.Any()) return; - var defendingFactions = GetDefendingFactions(squadsOnPlanet.First()); + if (string.IsNullOrEmpty(attackerFactionShortcut)) return; + var attackerFaction = factions.FirstOrDefault(f => f.Shortcut == attackerFactionShortcut); + if (attackerFaction == null) return; + var squad = FormedSquads.FirstOrDefault(s => s.PlanetID == targetPlanetId && s.AttackerFactionID == attackerFaction.FactionID); + if (squad == null) return; + + // attack vs defend are mutually exclusive per cycle. A player already locked into a squad's attack + // cannot also defend — otherwise LaunchAllBattles would force-join them into two Spring battles. + if (FormedSquads.Any(s => s.Attackers.Contains(userName))) + { + await server.GhostChanSay(user.Faction, $"{userName} cannot defend — already committed as attacker this cycle"); + return; + } + + // player's faction must be in the squad's defending factions + var defendingFactions = GetDefendingFactions(squad); if (!defendingFactions.Any(f => f.FactionID == account.FactionID)) { - await server.GhostChanSay(user.Faction, $"{userName} cannot defend {squadsOnPlanet.First().Name} (not owner or allied)"); + await server.GhostChanSay(user.Faction, $"{userName} cannot defend {squad.Name} (not owner or allied)"); return; } - // remove from other planets - foreach (var kv in DefenderVotes) - kv.Value.RemoveAll(x => x == userName); + // remove from all other defender lists (locked to one defense per cycle) + foreach (var s in FormedSquads) s.DefenderVotes.RemoveAll(x => x == userName); - // add to this planet - if (!DefenderVotes[targetPlanetId].Contains(userName)) + if (!squad.DefenderVotes.Contains(userName)) { - DefenderVotes[targetPlanetId].Add(userName); + squad.DefenderVotes.Add(userName); UpdateDefendersFullTime(); - await server.GhostChanSay(user.Faction, $"{userName} joins defense of {squadsOnPlanet.First().Name}"); - await conus.SendCommand(new PwJoinPlanetSuccess() { PlanetID = targetPlanetId }); + await server.GhostChanSay(user.Faction, $"{userName} joins defense of {squad.Name}"); + await conus.SendCommand(new PwJoinPlanetSuccess() + { + PlanetID = targetPlanetId, + AttackerFaction = GetFactionShortcut(squad.AttackerFactionID), + }); await UpdateLobby(); } } } + /// + /// Clear the player's attack or defense commitment for the current cycle. + /// Works in both phases. + /// + private async Task CancelPlanet(string userName) + { + bool changed = false; + + if (Phase == PwPhase.AttackCollect) + { + foreach (var opt in AttackOptions) + changed |= opt.Attackers.RemoveAll(x => x == userName) > 0; + } + else if (Phase == PwPhase.DefendCollect) + { + foreach (var s in FormedSquads) + changed |= s.DefenderVotes.RemoveAll(x => x == userName) > 0; + if (changed) UpdateDefendersFullTime(); + } + + if (changed) + { + var conus = server.ConnectedUsers.Get(userName); + if (conus?.User != null) await server.GhostChanSay(conus.User.Faction, $"{userName} cancelled their pick"); + await UpdateLobby(); + } + } + // ===================== CONNECTION EVENTS ===================== @@ -642,7 +702,7 @@ public async Task OnLoginAccepted(ConnectedUser connectedUser) var u = connectedUser.User; if (u.CanUserPlanetWars()) { - await UpdateLobby(u.Name); + await connectedUser.SendCommand(GenerateLobbyCommand(u.Name, u.Faction)); await SendPwAttackChargesForUser(u.Name); } } @@ -662,14 +722,11 @@ public async Task OnUserDisconnected(string name) } 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.DefenderVotes.RemoveAll(x => x == name) > 0; changed |= squad.Attackers.RemoveAll(x => x == name) > 0; - + } if (changed) UpdateDefendersFullTime(); } @@ -684,121 +741,213 @@ public async Task OnUserDisconnected(string name) // ===================== LOBBY COMMANDS ===================== + /// + /// Per-option data that does not depend on the viewer. Built once per lobby-update fan-out and reused + /// across viewers — the hot path (UpdateLobby) would otherwise recompute WHR averages, open DB contexts + /// inside GetDefendingFactions, and re-encode keys for every connected PW user. + /// + private sealed class OptionSnapshot + { + public int PlanetId; + public int? AttackerFactionId; + public string AttackerFactionShortcut; + public string PlanetName; + public string Map; + public int IconSize; + public List StructureImages; + public string PlanetImage; + public int Count; + public int Needed; + public int AttackerAvgWhr; + public int? DefenderAvgWhr; + public int? WinChance; + public HashSet AttackerNames; + public HashSet DefenderNames; + public HashSet DefenderFactionIds; // DefendCollect only + } + + private List ComputeOptionSnapshots(PwPhase phase) + { + var result = new List(); + if (phase == PwPhase.AttackCollect) + { + foreach (var opt in AttackOptions) + { + result.Add(new OptionSnapshot + { + PlanetId = opt.PlanetID, + AttackerFactionId = opt.AttackerFactionID, + AttackerFactionShortcut = GetFactionShortcut(opt.AttackerFactionID), + PlanetName = opt.Name, + Map = opt.Map, + IconSize = opt.IconSize, + StructureImages = opt.StructureImages, + PlanetImage = opt.PlanetImage, + Count = opt.Attackers.Count, + Needed = opt.TeamSize, + AttackerAvgWhr = AvgTopNWhr(opt.Attackers, opt.TeamSize), + DefenderAvgWhr = null, + WinChance = null, + AttackerNames = new HashSet(opt.Attackers), + DefenderNames = new HashSet(), + DefenderFactionIds = null, + }); + } + } + else if (phase == PwPhase.DefendCollect) + { + foreach (var squad in FormedSquads) + { + var atkAvg = AvgTopNWhr(squad.Attackers, squad.TeamSize); + int? defAvg = squad.DefenderVotes.Count > 0 ? (int?)AvgTopNWhr(squad.DefenderVotes, squad.TeamSize) : null; + result.Add(new OptionSnapshot + { + PlanetId = squad.PlanetID, + AttackerFactionId = squad.AttackerFactionID, + AttackerFactionShortcut = GetFactionShortcut(squad.AttackerFactionID), + PlanetName = squad.Name, + Map = squad.Map, + IconSize = squad.IconSize, + StructureImages = squad.StructureImages, + PlanetImage = squad.PlanetImage, + Count = squad.DefenderVotes.Count, + Needed = squad.TeamSize, + AttackerAvgWhr = atkAvg, + DefenderAvgWhr = defAvg, + WinChance = ComputeWinChance(atkAvg, defAvg), + AttackerNames = new HashSet(squad.Attackers), + DefenderNames = new HashSet(squad.DefenderVotes), + DefenderFactionIds = GetDefendingFactions(squad).Select(f => f.FactionID).ToHashSet(), + }); + } + } + return result; + } + public PwMatchCommand GenerateLobbyCommand(string playerName = null, string playerFaction = null) { - PwMatchCommand command = null; + if (MiscVar.PlanetWarsMode != PlanetWarsModes.Running) + return new PwMatchCommand(PwMatchCommand.ModeType.Clear); + return StampLobbyCommand(ComputeOptionSnapshots(Phase), Phase, playerName, playerFaction); + } + + private PwMatchCommand StampLobbyCommand(List snapshots, PwPhase phase, string playerName, string playerFaction) + { try { - if (MiscVar.PlanetWarsMode != PlanetWarsModes.Running) - return new PwMatchCommand(PwMatchCommand.ModeType.Clear); - int? playerFactionId = null; if (playerFaction != null) - { - var fac = factions.FirstOrDefault(f => f.Shortcut == playerFaction); - playerFactionId = fac?.FactionID; - } + playerFactionId = factions.FirstOrDefault(f => f.Shortcut == playerFaction)?.FactionID; - if (Phase == PwPhase.AttackCollect) + if (phase == PwPhase.AttackCollect) { - var canAttack = playerFactionId != null && playerFactionId == AttackingFaction.FactionID; - var options = AttackOptions.Select(x => + // All factions' options are shown to every viewer (parity with pre-parallel-turn UX, where + // everyone could see what the current attacker was planning). CanSelectForBattle gates the + // click: a player can only join options for their own faction. + var options = snapshots.Select(s => new PwMatchCommand.VoteOption { - var v = x.ToVoteOption(PwMatchCommand.ModeType.Attack); - v.CanSelectForBattle = canAttack; - v.PlayerIsAttacker = playerName != null && x.Attackers.Contains(playerName); - return v; + PlanetID = s.PlanetId, + PlanetName = s.PlanetName, + Map = s.Map, + IconSize = s.IconSize, + StructureImages = s.StructureImages, + PlanetImage = s.PlanetImage, + Count = s.Count, + Needed = s.Needed, + CanSelectForBattle = playerFactionId != null && playerFactionId == s.AttackerFactionId, + PlayerIsAttacker = playerName != null && s.AttackerNames.Contains(playerName), + PlayerIsDefender = false, + AttackerFaction = s.AttackerFactionShortcut, + AttackerAvgWhr = s.AttackerAvgWhr, + DefenderAvgWhr = null, + WinChance = null, }).ToList(); - command = new PwMatchCommand(PwMatchCommand.ModeType.Attack) + var deadline = GetAttackDeadline(); + return new PwMatchCommand(PwMatchCommand.ModeType.Attack) { Options = options, - Deadline = GetAttackDeadline(), - DeadlineSeconds = (int)GetAttackDeadline().Subtract(DateTime.UtcNow).TotalSeconds, - AttackerFaction = AttackingFaction.Shortcut + Deadline = deadline, + DeadlineSeconds = (int)deadline.Subtract(DateTime.UtcNow).TotalSeconds, + AttackerFaction = playerFaction, }; } - else if (Phase == PwPhase.DefendCollect) + else // DefendCollect { - // build defending factions cache per planet - var defFactionCache = new Dictionary>(); - foreach (var planetId in FormedSquads.Select(s => s.PlanetID).Distinct()) + var allDefenderShortcuts = new HashSet(); + var options = new List(snapshots.Count); + foreach (var s in snapshots) { - if (!defFactionCache.ContainsKey(planetId)) - defFactionCache[planetId] = GetDefendingFactions(FormedSquads.First(s => s.PlanetID == planetId)); - } - - // aggregate per planet — send all planets, flag which ones the viewer can act on - 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; - - var playerIsAttacker = playerName != null && squads.Any(s => s.Attackers.Contains(playerName)); - var playerIsDefender = playerName != null && DefenderVotes.ContainsKey(planetId) && DefenderVotes[planetId].Contains(playerName); - var canDefend = playerFactionId != null && defFactionCache[planetId].Any(f => f.FactionID == playerFactionId); - + var playerIsAttacker = playerName != null && s.AttackerNames.Contains(playerName); + var canDefend = playerFactionId != null && s.DefenderFactionIds != null && s.DefenderFactionIds.Contains(playerFactionId.Value); 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, + PlanetID = s.PlanetId, + PlanetName = s.PlanetName, + Map = s.Map, + IconSize = s.IconSize, + StructureImages = s.StructureImages, + PlanetImage = s.PlanetImage, + Count = s.Count, + Needed = s.Needed, CanSelectForBattle = canDefend && !playerIsAttacker, PlayerIsAttacker = playerIsAttacker, - PlayerIsDefender = playerIsDefender + PlayerIsDefender = playerName != null && s.DefenderNames.Contains(playerName), + AttackerFaction = s.AttackerFactionShortcut, + AttackerAvgWhr = s.AttackerAvgWhr, + DefenderAvgWhr = s.DefenderAvgWhr, + WinChance = s.WinChance, }); + if (s.DefenderFactionIds != null) + foreach (var fid in s.DefenderFactionIds) + { + var sc = GetFactionShortcut(fid); + if (sc != null) allDefenderShortcuts.Add(sc); + } } - var allDefFactions = defFactionCache.Values - .SelectMany(f => f.Select(x => x.Shortcut)) - .Distinct() - .ToList(); - - var effectiveDeadline = GetEffectiveDefendDeadline(); - command = new PwMatchCommand(PwMatchCommand.ModeType.Defend) + var deadline = GetEffectiveDefendDeadline(); + return new PwMatchCommand(PwMatchCommand.ModeType.Defend) { Options = options, - Deadline = effectiveDeadline, - DeadlineSeconds = (int)effectiveDeadline.Subtract(DateTime.UtcNow).TotalSeconds, - AttackerFaction = AttackingFaction.Shortcut, - DefenderFactions = allDefFactions + Deadline = deadline, + DeadlineSeconds = (int)deadline.Subtract(DateTime.UtcNow).TotalSeconds, + AttackerFaction = null, + DefenderFactions = allDefenderShortcuts.ToList(), }; } } catch (Exception ex) { - Trace.TraceError("PlanetWars {0}: {1}", nameof(GenerateLobbyCommand), ex); + Trace.TraceError("PlanetWars {0}: {1}", nameof(StampLobbyCommand), ex); + return null; } - return command; } // ===================== ATTACK OPTIONS ===================== /// - /// Invoked from web page + /// Invoked from the web page — adds a planet as an attack option for the specified attacker faction. + /// Each (PlanetID, AttackerFactionID) is an independent slot. /// - public void AddAttackOption(Planet planet) + public void AddAttackOption(Planet planet, int attackerFactionId) { try { if (MiscVar.PlanetWarsMode != PlanetWarsModes.Running) return; if (Phase != PwPhase.AttackCollect) return; + if (planet.OwnerFactionID == attackerFactionId) return; + if (AttackOptions.Any(x => x.PlanetID == planet.PlanetID && x.AttackerFactionID == attackerFactionId)) return; - if (!AttackOptions.Any(x => x.PlanetID == planet.PlanetID) && - (planet.OwnerFactionID != AttackingFaction.FactionID)) + using (var db = new ZkDataContext()) { - InternalAddOption(planet); - UpdateLobby(); + var attackerFaction = db.Factions.Find(attackerFactionId); + if (attackerFaction == null || !planet.CanMatchMakerPlay(attackerFaction)) return; } + + InternalAddOption(planet, attackerFactionId); + UpdateLobby(); } catch (Exception ex) { @@ -810,54 +959,62 @@ private void ResetAttackOptions() { AttackOptions.Clear(); FormedSquads.Clear(); - DefenderVotes.Clear(); Phase = PwPhase.AttackCollect; PhaseStartTime = DateTime.UtcNow; - AttackerSideChangeTime = DateTime.UtcNow; // TODO re-enable to prevent attacking planets with running battles // var contestedPlanetIds = RunningBattles.Values.Select(x => x.PlanetID).ToHashSet(); var contestedPlanetIds = new HashSet(); + var perFactionCount = DynamicConfig.Instance.PwAttackOptionCount; using (var db = new ZkDataContext()) { var gal = db.Galaxies.First(x => x.IsDefault); - var cnt = DynamicConfig.Instance.PwAttackOptionCount; - 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(); + var allPlanets = gal.Planets.ToList(); - foreach (var planet in planets) + foreach (var attackerFaction in factions) { - if (planet.CanMatchMakerPlay(attacker) && !contestedPlanetIds.Contains(planet.PlanetID)) + var attacker = db.Factions.Find(attackerFaction.FactionID); + if (attacker == null) continue; + + var sorted = allPlanets + .Where(x => x.OwnerFactionID != attackerFaction.FactionID) + .OrderByDescending(x => x.PlanetFactions.Where(y => y.FactionID == attackerFaction.FactionID).Sum(y => y.Dropships)) + .ThenByDescending(x => x.PlanetFactions.Where(y => y.FactionID == attackerFaction.FactionID).Sum(y => y.Influence)) + .ToList(); + + int cnt = perFactionCount; + foreach (var planet in sorted) { - InternalAddOption(planet); + if (cnt == 0) break; + if (!planet.CanMatchMakerPlay(attacker)) continue; + if (contestedPlanetIds.Contains(planet.PlanetID)) continue; + InternalAddOption(planet, attackerFaction.FactionID); 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); + // ensure at least one TeamSize=2 option (easy-to-fill squad) + if (!AttackOptions.Any(y => y.AttackerFactionID == attackerFaction.FactionID && y.TeamSize == 2)) + { + var planet = sorted.FirstOrDefault(x => x.TeamSize == 2 && x.CanMatchMakerPlay(attacker) && !contestedPlanetIds.Contains(x.PlanetID)); + if (planet != null) InternalAddOption(planet, attackerFaction.FactionID); + } } } UpdateLobby(); - server.GhostChanSay(AttackingFaction.Shortcut, "It's your turn! Select a planet to attack"); + foreach (var fac in factions) + server.GhostChanSay(fac.Shortcut, "New PlanetWars cycle — select a planet to attack or defend"); } - private void InternalAddOption(Planet planet) + private void InternalAddOption(Planet planet, int attackerFactionId) { AttackOptions.Add(new AttackOption { PlanetID = planet.PlanetID, Map = planet.Resource.InternalName, OwnerFactionID = planet.OwnerFactionID, + AttackerFactionID = attackerFactionId, Name = planet.Name, TeamSize = planet.TeamSize, PlanetImage = planet.Resource?.MapPlanetWarsIcon, @@ -869,31 +1026,45 @@ private void InternalAddOption(Planet planet) // ===================== HELPERS ===================== + /// + /// Factions allowed to defend the given squad (i.e. versus the squad's attacker faction). + /// Owner always defends; allies with EffectBalanceSameSide treaty vs. THIS specific attacker also defend. + /// public List GetDefendingFactions(AttackOption target) { if (target.OwnerFactionID != null) { var ret = new List(); - ret.Add(factions.Find(x => x.FactionID == target.OwnerFactionID)); + var owner = factions.Find(x => x.FactionID == target.OwnerFactionID); + if (owner != null) ret.Add(owner); 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 (planet != null) { - if (of.GaveTreatyRight(planet, x => x.EffectBalanceSameSide == true)) - ret.Add(factions.First(x => x.FactionID == of.FactionID)); + foreach (var of in db.Factions.Where(x => !x.IsDeleted && x.FactionID != target.OwnerFactionID && x.FactionID != target.AttackerFactionID)) + { + if (of.GaveTreatyRight(planet, x => x.EffectBalanceSameSide == true)) + { + var match = factions.FirstOrDefault(x => x.FactionID == of.FactionID); + if (match != null) ret.Add(match); + } + } } } return ret; } - return factions.Where(x => x != AttackingFaction).ToList(); + // no owner — anyone but the attacker may defend + return factions.Where(x => x.FactionID != target.AttackerFactionID).ToList(); } private void RecordPlanetwarsLoss(AttackOption option) { - var message = $"{AttackingFaction.Name} won {option.Name} because nobody tried to defend"; + var attackerFaction = factions.FirstOrDefault(f => f.FactionID == option.AttackerFactionID); + var attackerName = attackerFaction?.Name ?? "Attacker"; + var message = $"{attackerName} won {option.Name} because nobody tried to defend"; foreach (var fac in factions) server.GhostChanSay(fac.Shortcut, message); try @@ -933,7 +1104,7 @@ private DateTime GetDefendDeadline() } /// - /// Effective defend deadline accounting for the 30s early cutoff when enough defenders join. + /// Effective defend deadline accounting for the 30s early cutoff when all squads are fully defended. /// private DateTime GetEffectiveDefendDeadline() { @@ -947,21 +1118,14 @@ private DateTime GetEffectiveDefendDeadline() } /// - /// Check if every attacked planet has enough direct defender volunteers and set/clear the 30s countdown. - /// Each planet must independently have volunteers >= its slots — overflow across factions is not counted. + /// Each squad must independently have volunteers >= its slots. /// private void UpdateDefendersFullTime() { - var allFull = FormedSquads.Any(); // at least one squad exists - foreach (var planetId in FormedSquads.Select(s => s.PlanetID).Distinct()) + var allFull = FormedSquads.Any(); + foreach (var squad in FormedSquads) { - var slotsNeeded = FormedSquads.Where(s => s.PlanetID == planetId).Sum(s => s.TeamSize); - var volunteered = DefenderVotes.ContainsKey(planetId) ? DefenderVotes[planetId].Count : 0; - if (volunteered < slotsNeeded) - { - allFull = false; - break; - } + if (squad.DefenderVotes.Count < squad.TeamSize) { allFull = false; break; } } if (allFull) @@ -1050,17 +1214,20 @@ private async Task ApplyTurnEndChargeBump() private async Task UpdateLobby() { - // per-player: flags (CanSelectForBattle / PlayerIsAttacker) depend on the viewer - foreach (var conus in server.ConnectedUsers.Values.Where(x => x.User.CanUserPlanetWars())) - await conus.SendCommand(GenerateLobbyCommand(conus.Name, conus.User.Faction)); - SaveStateToDb(); - } + var users = server.ConnectedUsers.Values.Where(x => x.User.CanUserPlanetWars()).ToList(); + if (MiscVar.PlanetWarsMode != PlanetWarsModes.Running) + { + var clear = new PwMatchCommand(PwMatchCommand.ModeType.Clear); + await Task.WhenAll(users.Select(u => u.SendCommand(clear))); + SaveStateToDb(); + return; + } - private Task UpdateLobby(string player) - { - var conus = server.ConnectedUsers.Get(player); - if (conus == null) return Task.CompletedTask; - return conus.SendCommand(GenerateLobbyCommand(conus.Name, conus.User.Faction)); + // compute viewer-invariant data once, stamp per-viewer flags in parallel send fan-out + var snapshots = ComputeOptionSnapshots(Phase); + var phase = Phase; + await Task.WhenAll(users.Select(u => u.SendCommand(StampLobbyCommand(snapshots, phase, u.Name, u.User.Faction)))); + SaveStateToDb(); } private void SaveStateToDb() @@ -1069,8 +1236,6 @@ private void SaveStateToDb() { var gal = db.Galaxies.First(x => x.IsDefault); gal.MatchMakerState = JsonConvert.SerializeObject((PlanetWarsMatchMakerState)this); - gal.AttackerSideCounter = AttackerSideCounter; - gal.AttackerSideChangeTime = AttackerSideChangeTime; db.SaveChanges(); } } @@ -1092,10 +1257,15 @@ private static PwStatus GeneratePwStatus() public class AttackOption { public List Attackers { get; set; } + /// Sliced defender roster (populated by at end of DefendCollect). public List Defenders { get; set; } + /// Defender volunteers pre-slicing. Sliced into by . + public List DefenderVotes { get; set; } public string Map { get; set; } public string Name { get; set; } public int? OwnerFactionID { get; set; } + /// Faction that will be attacking on this option — each (PlanetID, AttackerFactionID) is an independent slot. + public int? AttackerFactionID { get; set; } public int PlanetID { get; set; } public int TeamSize { get; set; } public List StructureImages { get; set; } = new List(); @@ -1106,21 +1276,7 @@ public AttackOption() { Attackers = new List(); Defenders = new List(); - } - - public PwMatchCommand.VoteOption ToVoteOption(PwMatchCommand.ModeType mode) - { - return new PwMatchCommand.VoteOption - { - PlanetID = PlanetID, - PlanetName = Name, - Map = Map, - IconSize = IconSize, - StructureImages = StructureImages, - PlanetImage = PlanetImage, - Count = mode == PwMatchCommand.ModeType.Attack ? Attackers.Count : Defenders.Count, - Needed = TeamSize - }; + DefenderVotes = new List(); } } } diff --git a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMakerState.cs b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMakerState.cs index 908500af26..1a6beb4ae6 100644 --- a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMakerState.cs +++ b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMakerState.cs @@ -13,25 +13,20 @@ public enum PwPhase public class PlanetWarsMatchMakerState { /// - /// Possible attack options / planets to vote on + /// Possible attack options. Keyed by (AttackerFactionID, PlanetID) — each faction has its own set of + /// options with its own attacker and defender pools. /// public List AttackOptions { get; set; } - public DateTime AttackerSideChangeTime { get; set; } - public int AttackerSideCounter { 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. + /// In parallel-turn mode each squad carries its own AttackerFactionID and independent defender pool. /// 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() { } } diff --git a/ZkLobbyServer/ZkLobbyServer.cs b/ZkLobbyServer/ZkLobbyServer.cs index bfc5146ab4..456b176c06 100644 --- a/ZkLobbyServer/ZkLobbyServer.cs +++ b/ZkLobbyServer/ZkLobbyServer.cs @@ -322,10 +322,10 @@ public Task GhostChanSay(string channelName, string text, bool isEmote = true, b }); } - public async Task RequestJoinPlanet(string name, int planetID) + public async Task RequestJoinPlanet(string name, int planetId, string attackerFaction) { var conus = ConnectedUsers.Get(name); - if (conus != null) await conus.SendCommand(new PwRequestJoinPlanet() { PlanetID = planetID }); + if (conus != null) await conus.SendCommand(new PwRequestJoinPlanet() { PlanetID = planetId, AttackerFaction = attackerFaction }); } public Task GhostPm(string name, string text) From 63258e795b6444d34f518452d2e7160c3bec1e20 Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 24 Apr 2026 16:34:09 +0200 Subject: [PATCH 2/3] fix overflow of defense --- .../SpringieInterface/PlanetWarsMatchMaker.cs | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs index aca9872826..d1423e9b93 100644 --- a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs +++ b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs @@ -381,24 +381,30 @@ private void RunDefenderAssignment() } } - // fill deficits: prioritize squads by attacker strength (strongest attacker squads get top floaters first) + // Round-robin deficit fill: cover as many attacked planets as possible before any squad gets a + // second floater. Each round walks squads in attacker-strength order so the top-WHR floater lands + // on the highest-stakes battle first; remaining rounds spread the rest across still-deficit squads. var squadsByAttackerStrength = FormedSquads .OrderByDescending(s => s.Attackers.Any() ? s.Attackers.Average(a => GetPlayerWhr(a)) : 0.0) .ToList(); - foreach (var squad in squadsByAttackerStrength) + while (floatingPool.Count > 0) { - var deficit = squad.TeamSize - squad.Defenders.Count; - if (deficit <= 0 || floatingPool.Count == 0) continue; + bool progressed = false; + foreach (var squad in squadsByAttackerStrength) + { + if (squad.Defenders.Count >= squad.TeamSize) continue; - var allowedFactions = squadDefendingFactions[squad]; - var eligible = floatingPool - .Where(x => defenderFactionId.ContainsKey(x) && defenderFactionId[x].HasValue && allowedFactions.Contains(defenderFactionId[x].Value)) - .Take(deficit) - .ToList(); + var allowedFactions = squadDefendingFactions[squad]; + var pick = floatingPool.FirstOrDefault(x => + defenderFactionId.ContainsKey(x) && defenderFactionId[x].HasValue && allowedFactions.Contains(defenderFactionId[x].Value)); + if (pick == null) continue; - squad.Defenders.AddRange(eligible); - foreach (var p in eligible) floatingPool.Remove(p); + squad.Defenders.Add(pick); + floatingPool.Remove(pick); + progressed = true; + } + if (!progressed) break; } } From 32b02ddf8e36ed4fd1f1c8a9cfa1651ba9fb0d51 Mon Sep 17 00:00:00 2001 From: Licho Date: Fri, 24 Apr 2026 16:39:23 +0200 Subject: [PATCH 3/3] optimize --- .../SpringieInterface/PlanetWarsMatchMaker.cs | 90 ++++++++++--------- 1 file changed, 50 insertions(+), 40 deletions(-) diff --git a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs index d1423e9b93..ac14c95801 100644 --- a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs +++ b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs @@ -9,7 +9,6 @@ using Newtonsoft.Json; using PlasmaShared; using Ratings; -using Z.EntityFramework.Plus; using ZkData; using ZkLobbyServer; @@ -778,14 +777,28 @@ private sealed class OptionSnapshot public HashSet DefenderFactionIds; // DefendCollect only } - private List ComputeOptionSnapshots(PwPhase phase) + /// + /// Viewer-invariant data for the whole lobby fan-out: the per-option snapshots plus the aggregate + /// attacker/defender faction shortcut lists that go into the command header. Computed once per + /// UpdateLobby tick. + /// + private sealed class LobbySnapshot { - var result = new List(); + public List Options; + public List AttackerFactionShortcuts; + public List DefenderFactionShortcuts; + } + + private LobbySnapshot ComputeLobbySnapshot(PwPhase phase) + { + var options = new List(); + var defenderShortcuts = new HashSet(); + if (phase == PwPhase.AttackCollect) { foreach (var opt in AttackOptions) { - result.Add(new OptionSnapshot + options.Add(new OptionSnapshot { PlanetId = opt.PlanetID, AttackerFactionId = opt.AttackerFactionID, @@ -812,7 +825,13 @@ private List ComputeOptionSnapshots(PwPhase phase) { var atkAvg = AvgTopNWhr(squad.Attackers, squad.TeamSize); int? defAvg = squad.DefenderVotes.Count > 0 ? (int?)AvgTopNWhr(squad.DefenderVotes, squad.TeamSize) : null; - result.Add(new OptionSnapshot + var defenderFactionIds = GetDefendingFactions(squad).Select(f => f.FactionID).ToHashSet(); + foreach (var fid in defenderFactionIds) + { + var sc = GetFactionShortcut(fid); + if (sc != null) defenderShortcuts.Add(sc); + } + options.Add(new OptionSnapshot { PlanetId = squad.PlanetID, AttackerFactionId = squad.AttackerFactionID, @@ -829,21 +848,31 @@ private List ComputeOptionSnapshots(PwPhase phase) WinChance = ComputeWinChance(atkAvg, defAvg), AttackerNames = new HashSet(squad.Attackers), DefenderNames = new HashSet(squad.DefenderVotes), - DefenderFactionIds = GetDefendingFactions(squad).Select(f => f.FactionID).ToHashSet(), + DefenderFactionIds = defenderFactionIds, }); } } - return result; + + return new LobbySnapshot + { + Options = options, + AttackerFactionShortcuts = options + .Select(s => s.AttackerFactionShortcut) + .Where(x => !string.IsNullOrEmpty(x)) + .Distinct() + .ToList(), + DefenderFactionShortcuts = defenderShortcuts.ToList(), + }; } public PwMatchCommand GenerateLobbyCommand(string playerName = null, string playerFaction = null) { if (MiscVar.PlanetWarsMode != PlanetWarsModes.Running) return new PwMatchCommand(PwMatchCommand.ModeType.Clear); - return StampLobbyCommand(ComputeOptionSnapshots(Phase), Phase, playerName, playerFaction); + return StampLobbyCommand(ComputeLobbySnapshot(Phase), Phase, playerName, playerFaction); } - private PwMatchCommand StampLobbyCommand(List snapshots, PwPhase phase, string playerName, string playerFaction) + private PwMatchCommand StampLobbyCommand(LobbySnapshot snapshot, PwPhase phase, string playerName, string playerFaction) { try { @@ -851,20 +880,12 @@ private PwMatchCommand StampLobbyCommand(List snapshots, PwPhase if (playerFaction != null) playerFactionId = factions.FirstOrDefault(f => f.Shortcut == playerFaction)?.FactionID; - // Distinct attacker factions across all options — populated from the shared snapshot so it - // matches the per-option AttackerFaction data the client sees in both phases. - var attackerFactionShortcuts = snapshots - .Select(s => s.AttackerFactionShortcut) - .Where(x => !string.IsNullOrEmpty(x)) - .Distinct() - .ToList(); - if (phase == PwPhase.AttackCollect) { // All factions' options are shown to every viewer (parity with pre-parallel-turn UX, where // everyone could see what the current attacker was planning). CanSelectForBattle gates the // click: a player can only join options for their own faction. - var options = snapshots.Select(s => new PwMatchCommand.VoteOption + var options = snapshot.Options.Select(s => new PwMatchCommand.VoteOption { PlanetID = s.PlanetId, PlanetName = s.PlanetName, @@ -889,18 +910,16 @@ private PwMatchCommand StampLobbyCommand(List snapshots, PwPhase Options = options, Deadline = deadline, DeadlineSeconds = (int)deadline.Subtract(DateTime.UtcNow).TotalSeconds, - AttackerFactions = attackerFactionShortcuts, + AttackerFactions = snapshot.AttackerFactionShortcuts, }; } else // DefendCollect { - var allDefenderShortcuts = new HashSet(); - var options = new List(snapshots.Count); - foreach (var s in snapshots) + var options = snapshot.Options.Select(s => { var playerIsAttacker = playerName != null && s.AttackerNames.Contains(playerName); var canDefend = playerFactionId != null && s.DefenderFactionIds != null && s.DefenderFactionIds.Contains(playerFactionId.Value); - options.Add(new PwMatchCommand.VoteOption + return new PwMatchCommand.VoteOption { PlanetID = s.PlanetId, PlanetName = s.PlanetName, @@ -917,14 +936,8 @@ private PwMatchCommand StampLobbyCommand(List snapshots, PwPhase AttackerAvgWhr = s.AttackerAvgWhr, DefenderAvgWhr = s.DefenderAvgWhr, WinChance = s.WinChance, - }); - if (s.DefenderFactionIds != null) - foreach (var fid in s.DefenderFactionIds) - { - var sc = GetFactionShortcut(fid); - if (sc != null) allDefenderShortcuts.Add(sc); - } - } + }; + }).ToList(); var deadline = GetEffectiveDefendDeadline(); return new PwMatchCommand(PwMatchCommand.ModeType.Defend) @@ -932,8 +945,8 @@ private PwMatchCommand StampLobbyCommand(List snapshots, PwPhase Options = options, Deadline = deadline, DeadlineSeconds = (int)deadline.Subtract(DateTime.UtcNow).TotalSeconds, - AttackerFactions = attackerFactionShortcuts, - DefenderFactions = allDefenderShortcuts.ToList(), + AttackerFactions = snapshot.AttackerFactionShortcuts, + DefenderFactions = snapshot.DefenderFactionShortcuts, }; } } @@ -960,11 +973,8 @@ public void AddAttackOption(Planet planet, int attackerFactionId) if (planet.OwnerFactionID == attackerFactionId) return; if (AttackOptions.Any(x => x.PlanetID == planet.PlanetID && x.AttackerFactionID == attackerFactionId)) return; - using (var db = new ZkDataContext()) - { - var attackerFaction = db.Factions.Find(attackerFactionId); - if (attackerFaction == null || !planet.CanMatchMakerPlay(attackerFaction)) return; - } + var attackerFaction = factions.FirstOrDefault(f => f.FactionID == attackerFactionId); + if (attackerFaction == null || !planet.CanMatchMakerPlay(attackerFaction)) return; InternalAddOption(planet, attackerFactionId); UpdateLobby(); @@ -1244,9 +1254,9 @@ private async Task UpdateLobby() } // compute viewer-invariant data once, stamp per-viewer flags in parallel send fan-out - var snapshots = ComputeOptionSnapshots(Phase); + var snapshot = ComputeLobbySnapshot(Phase); var phase = Phase; - await Task.WhenAll(users.Select(u => u.SendCommand(StampLobbyCommand(snapshots, phase, u.Name, u.User.Faction)))); + await Task.WhenAll(users.Select(u => u.SendCommand(StampLobbyCommand(snapshot, phase, u.Name, u.User.Faction)))); SaveStateToDb(); }