();
}
public class VoteOption
@@ -705,6 +710,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 +728,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 +756,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 3f9d77022..b2b1b9dcd 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 6e2a5b484..d9e390319 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 85ce5ba16..b33da51be 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/ZeroKLobby/Notifications/PwBar.cs b/ZeroKLobby/Notifications/PwBar.cs
index c346b9df1..d3ea2167b 100644
--- a/ZeroKLobby/Notifications/PwBar.cs
+++ b/ZeroKLobby/Notifications/PwBar.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Linq;
using System.Windows.Forms;
using LobbyClient;
@@ -59,9 +60,11 @@ void UpdateGui()
deadline = DateTime.Now.AddSeconds(pw.DeadlineSeconds);
timerLabel.Text = PlasmaShared.Utils.PrintTimeRemaining(pw.DeadlineSeconds);
+ var attackerFactionsText = string.Join(",", pw.AttackerFactions ?? new List());
+
if (pw.Mode == PwMatchCommand.ModeType.Attack)
{
- headerLabel.Text = string.Format("{0} picks a planet to attack", pw.AttackerFaction);
+ headerLabel.Text = string.Format("{0} picks a planet to attack", attackerFactionsText);
foreach (Button c in pnl.Controls.OfType().ToList()) pnl.Controls.Remove(c);
@@ -72,7 +75,7 @@ void UpdateGui()
var but = new Button { Text = string.Format("{0} [{1}/{2}]", opt.PlanetName, opt.Count, opt.Needed), AutoSize = true };
Program.ToolTip.SetMap(but, opt.Map);
- if (pw.AttackerFaction == tas.MyUser.Faction) // NOTE this is for cases where nightwatch self faction info is delayed
+ if (pw.AttackerFactions != null && pw.AttackerFactions.Contains(tas.MyUser.Faction))
{
AddButtonClick(opt, but);
}
@@ -83,7 +86,7 @@ void UpdateGui()
else if (pw.Mode == PwMatchCommand.ModeType.Defend)
{
headerLabel.Text = string.Format("{0} attacks, {1} defends",
- pw.AttackerFaction,
+ attackerFactionsText,
string.Join(",", pw.DefenderFactions));
foreach (Button c in pnl.Controls.OfType().ToList()) pnl.Controls.Remove(c);
diff --git a/ZkLobbyServer/ConnectedUser.cs b/ZkLobbyServer/ConnectedUser.cs
index 7bb2750f2..d858be41a 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 a95c25e5a..ac14c9580 100644
--- a/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs
+++ b/ZkLobbyServer/SpringieInterface/PlanetWarsMatchMaker.cs
@@ -15,29 +15,25 @@
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 +54,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 +66,6 @@ public PlanetWarsMatchMaker(ZkLobbyServer.ZkLobbyServer server)
}
else
{
- AttackerSideCounter = gal.AttackerSideCounter;
- AttackerSideChangeTime = gal.AttackerSideChangeTime ?? DateTime.UtcNow;
Phase = PwPhase.AttackCollect;
PhaseStartTime = DateTime.UtcNow;
}
@@ -132,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();
}
}
@@ -151,11 +140,21 @@ 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();
- AttackerSideCounter++;
+ try
+ {
+ RunDefenderAssignment();
+ await LaunchAllBattles();
+ RunGalaxyTick();
+ }
+ catch (Exception ex)
+ {
+ Trace.TraceError("PlanetWars cycle-end error: {0}", ex);
+ }
ResetAttackOptions();
}
break;
@@ -185,32 +184,57 @@ private void RunSquadFormation()
{
FormedSquads.Clear();
- 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),
+ });
+ }
+
+ 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
+ // 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()
@@ -220,34 +244,34 @@ private void RunSquadFormation()
}
// Phase 1: self-sufficient planets form one squad with all their attackers
- foreach (var opt in AttackOptions)
+ foreach (var opt in factionOptions)
{
- var available = opt.Attackers.Where(playerPlanet.ContainsKey).ToList();
+ var available = opt.Attackers.Where(playerOption.ContainsKey).ToList();
if (available.Count >= opt.TeamSize)
{
var squad = CreateSquadFromOption(opt);
squad.Attackers = available;
squad.TeamSize = available.Count;
FormedSquads.Add(squad);
- foreach (var p in available) playerPlanet.Remove(p);
+ foreach (var p in available) playerOption.Remove(p);
}
}
// Phase 2: piercing — pick the top-ranked straggler whose planet's TeamSize
// the pool can still satisfy; skip leaders whose planet is too big.
- while (playerPlanet.Count > 0)
+ while (playerOption.Count > 0)
{
- var leader = playerPlanet.Keys
- .Where(x => playerPlanet[x].TeamSize <= playerPlanet.Count)
+ var leader = playerOption.Keys
+ .Where(x => playerOption[x].TeamSize <= playerOption.Count)
.OrderBy(x => playerRoleOrder.GetOrDefault(x, int.MaxValue))
.ThenByDescending(x => playerWhr.Get(x))
.FirstOrDefault();
if (leader == null) break; // no straggler's planet fits the remaining pool
- var leaderOption = playerPlanet[leader];
+ var leaderOption = playerOption[leader];
- var fillers = playerPlanet.Keys
+ var fillers = playerOption.Keys
.Where(x => x != leader)
.OrderByDescending(x => playerWhr.Get(x))
.Take(leaderOption.TeamSize - 1)
@@ -259,34 +283,27 @@ private void RunSquadFormation()
squad.TeamSize = squad.Attackers.Count;
FormedSquads.Add(squad);
- playerPlanet.Remove(leader);
- foreach (var p in fillers) playerPlanet.Remove(p);
+ playerOption.Remove(leader);
+ foreach (var p in fillers) playerOption.Remove(p);
}
- // Phase 3: remaining stragglers all merge into the strongest existing squad,
- // i.e. the one whose best member has the top PW-Rank (tiebreak by WHR).
- if (playerPlanet.Count > 0 && FormedSquads.Count > 0)
+ // Phase 3: remaining stragglers all merge into the strongest existing squad
+ // in THIS faction's pass (each faction's squads are independent).
+ if (playerOption.Count > 0)
{
var strongest = FormedSquads
+ .Where(s => s.AttackerFactionID == attackerFactionId)
.OrderBy(s => s.Attackers.Min(a => playerRoleOrder.GetOrDefault(a, int.MaxValue)))
.ThenByDescending(s => s.Attackers.Max(a => playerWhr.Get(a)))
- .First();
+ .FirstOrDefault();
- strongest.Attackers.AddRange(playerPlanet.Keys);
- strongest.TeamSize = strongest.Attackers.Count;
- playerPlanet.Clear();
+ if (strongest != null)
+ {
+ strongest.Attackers.AddRange(playerOption.Keys);
+ strongest.TeamSize = strongest.Attackers.Count;
+ playerOption.Clear();
+ }
}
-
- 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)
@@ -297,12 +314,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()
};
}
@@ -311,11 +330,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;
@@ -323,11 +342,11 @@ private void RunDefenderAssignment()
}
}
- // per-squad: assign top-WHR volunteers; overflow spills to floating pool
+ // each squad gets its direct volunteers first (top-WHR); overflow goes into a floating pool
var floatingPool = new List();
foreach (var squad in FormedSquads)
{
- var volunteers = (DefenderVotes.ContainsKey(squad.PlanetID) ? DefenderVotes[squad.PlanetID] : new List())
+ var volunteers = squad.DefenderVotes
.Where(x => server.ConnectedUsers.ContainsKey(x) && defenderWhr.ContainsKey(x))
.OrderByDescending(x => defenderWhr[x])
.ToList();
@@ -343,8 +362,13 @@ private void RunDefenderAssignment()
}
}
- // floating pool fills deficits on other squads (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 per-squad defending factions (GetDefendingFactions opens its own DB context, expensive inside a loop)
+ 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())
@@ -356,17 +380,30 @@ private void RunDefenderAssignment()
}
}
- foreach (var squad in FormedSquads)
+ // 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();
+
+ while (floatingPool.Count > 0)
{
- var deficit = squad.TeamSize - squad.Defenders.Count;
- if (deficit > 0 && floatingPool.Count > 0)
+ bool progressed = false;
+ foreach (var squad in squadsByAttackerStrength)
{
- var allowedFactionIds = GetDefendingFactions(squad).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();
- squad.Defenders.AddRange(toAdd);
- foreach (var p in toAdd) floatingPool.Remove(p);
+ if (squad.Defenders.Count >= squad.TeamSize) continue;
+
+ 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.Add(pick);
+ floatingPool.Remove(pick);
+ progressed = true;
}
+ if (!progressed) break;
}
}
@@ -377,24 +414,48 @@ 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();
- foreach (var squad in FormedSquads)
+ // one battle per squad — no merging across attacker factions (each (planet, attacker-faction) is its own slot)
+ foreach (var squad in FormedSquads.ToList())
{
- // drop anyone who disconnected between squad formation and launch
squad.Attackers = squad.Attackers.Where(x => server.ConnectedUsers.ContainsKey(x)).ToList();
squad.Defenders = squad.Defenders.Where(x => server.ConnectedUsers.ContainsKey(x)).ToList();
- if (squad.Attackers.Count > 0) attackerNamesToChargeSpend.AddRange(squad.Attackers);
+ if (squad.Attackers.Count == 0) continue;
- if (squad.Defenders.Count > 0 && squad.Attackers.Count > 0)
+ if (squad.Defenders.Count > 0)
{
- // battle (may be uneven)
try
{
squad.TeamSize = Math.Max(squad.Attackers.Count, squad.Defenders.Count);
@@ -407,7 +468,9 @@ private async Task LaunchAllBattles()
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)}";
+ 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
@@ -421,16 +484,15 @@ private async Task LaunchAllBattles()
Trace.TraceError("PlanetWars LaunchBattle error: {0}", ex);
}
}
- else if (squad.Attackers.Count > 0)
+ else
{
- // concede - zero defenders
+ // 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);
}
@@ -483,44 +545,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);
}
}
- private async Task JoinPlanet(string name, int planetId)
+ public async Task OnCancel(ConnectedUser conus)
+ {
+ if (MiscVar.PlanetWarsMode == PlanetWarsModes.Running)
+ {
+ if (conus.User.CanUserPlanetWars()) await CancelPlanet(conus.Name);
+ }
+ }
+
+ 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)
@@ -529,25 +604,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;
@@ -557,32 +633,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 =====================
@@ -595,7 +713,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);
}
}
@@ -615,14 +733,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();
}
@@ -637,121 +752,232 @@ 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
+ }
+
+ ///
+ /// 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
+ {
+ 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)
+ {
+ options.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;
+ 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,
+ 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 = defenderFactionIds,
+ });
+ }
+ }
+
+ 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)
{
- PwMatchCommand command = null;
+ if (MiscVar.PlanetWarsMode != PlanetWarsModes.Running)
+ return new PwMatchCommand(PwMatchCommand.ModeType.Clear);
+ return StampLobbyCommand(ComputeLobbySnapshot(Phase), Phase, playerName, playerFaction);
+ }
+
+ private PwMatchCommand StampLobbyCommand(LobbySnapshot snapshot, 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 = snapshot.Options.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,
+ AttackerFactions = snapshot.AttackerFactionShortcuts,
};
}
- 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())
- {
- 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 options = snapshot.Options.Select(s =>
{
- 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);
-
- options.Add(new PwMatchCommand.VoteOption
+ var playerIsAttacker = playerName != null && s.AttackerNames.Contains(playerName);
+ var canDefend = playerFactionId != null && s.DefenderFactionIds != null && s.DefenderFactionIds.Contains(playerFactionId.Value);
+ return 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
- });
- }
-
- var allDefFactions = defFactionCache.Values
- .SelectMany(f => f.Select(x => x.Shortcut))
- .Distinct()
- .ToList();
+ PlayerIsDefender = playerName != null && s.DefenderNames.Contains(playerName),
+ AttackerFaction = s.AttackerFactionShortcut,
+ AttackerAvgWhr = s.AttackerAvgWhr,
+ DefenderAvgWhr = s.DefenderAvgWhr,
+ WinChance = s.WinChance,
+ };
+ }).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,
+ AttackerFactions = snapshot.AttackerFactionShortcuts,
+ DefenderFactions = snapshot.DefenderFactionShortcuts,
};
}
}
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))
- {
- InternalAddOption(planet);
- UpdateLobby();
- }
+ var attackerFaction = factions.FirstOrDefault(f => f.FactionID == attackerFactionId);
+ if (attackerFaction == null || !planet.CanMatchMakerPlay(attackerFaction)) return;
+
+ InternalAddOption(planet, attackerFactionId);
+ UpdateLobby();
}
catch (Exception ex)
{
@@ -763,54 +989,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,
@@ -822,31 +1056,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
@@ -886,7 +1134,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()
{
@@ -900,21 +1148,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)
@@ -1003,17 +1244,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 snapshot = ComputeLobbySnapshot(Phase);
+ var phase = Phase;
+ await Task.WhenAll(users.Select(u => u.SendCommand(StampLobbyCommand(snapshot, phase, u.Name, u.User.Faction))));
+ SaveStateToDb();
}
private void SaveStateToDb()
@@ -1022,8 +1266,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();
}
}
@@ -1045,10 +1287,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();
@@ -1059,21 +1306,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 908500af2..1a6beb4ae 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 bfc5146ab..456b176c0 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)