Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2b842c3
include per attacker ip bonus in base ip for ingame
Licho1 Apr 12, 2026
f911514
send full pw match list to all with per-viewer CanSelectForBattle/Pla…
Licho1 Apr 22, 2026
20d72b4
add pw configs for attack charges + refresh and attack options for PW…
Licho1 Apr 22, 2026
64ff99c
add playerisdefender
Licho1 Apr 22, 2026
97568eb
victory points to win 100->50
Licho1 Apr 22, 2026
63468cb
allow overflow of attack plaeyrs past squad size
Licho1 Apr 22, 2026
25dc753
add EffectBlocksInvasion and EffectBlocksBombers to planetwars struct…
Licho1 Apr 23, 2026
5439f0d
added PwBomberMinimumIpFloor (5) and PwBomberSelfIpRate (0.5)
Licho1 Apr 23, 2026
cb3be6f
add per-player attack charges to planetwars
Licho1 Apr 23, 2026
348e5bb
experimental nuke attacker, breaks merge of battles/defenses
Licho1 Apr 24, 2026
729104b
Merge pull request #3039 from ZeroK-RTS/pw-match-viewer-flags
Licho1 Apr 24, 2026
3e08280
Merge branch 'master' into pw-attack-charges
Licho1 Apr 24, 2026
306fc33
change logic to be best on last charges change instead of last attack
Licho1 Apr 24, 2026
7e2b8af
add attack charges to planetwars
Licho1 Apr 24, 2026
8efe8b7
Merge branch 'master' into nuke-attacker
Licho1 Apr 24, 2026
a042cfb
apply PW charges bump on attack phase start even if there was no PW g…
Licho1 Apr 24, 2026
958ed30
simplify squad logic (no size limit for squad)
Licho1 Apr 24, 2026
1655a5f
simplify code
Licho1 Apr 24, 2026
63258e7
fix overflow of defense
Licho1 Apr 24, 2026
32b02dd
optimize
Licho1 Apr 24, 2026
4698591
remove attacker faction from pw, add cancel
Licho1 Apr 24, 2026
b66412c
send PW phase minutes in PwStatus
Licho1 Apr 24, 2026
48f299e
timer based PW charges
Licho1 Apr 25, 2026
34c42db
temp change pw timers to 2 and 2 minutes
Licho1 Apr 26, 2026
a79e2cf
PW changes - attacker charges always spent, separate passive recharge…
Licho1 Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Fixer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 53 additions & 1 deletion Shared/LobbyClient/Protocol/Messages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,11 @@ public enum ModeType
Defend = 2
}

public string AttackerFaction { get; set; }
/// <summary>
/// Distinct attacker faction shortcuts across all <see cref="Options"/>. In parallel-turn PW every
/// faction can be attacking simultaneously; each option also carries its own <see cref="VoteOption.AttackerFaction"/>.
/// </summary>
public List<string> AttackerFactions { get; set; }

public DateTime Deadline { get; set; }

Expand All @@ -690,6 +694,7 @@ public PwMatchCommand(ModeType mode)
Mode = mode;
Options = new List<VoteOption>();
DefenderFactions = new List<string>();
AttackerFactions = new List<string>();
}

public class VoteOption
Expand All @@ -702,32 +707,66 @@ public class VoteOption
public List<string> StructureImages { get; set; }
public int IconSize { get; set; }
public string PlanetName { get; set; }
public bool CanSelectForBattle { get; set; }
public bool PlayerIsAttacker { get; set; }
public bool PlayerIsDefender { get; set; }
/// <summary>
/// Faction shortcut of the attacker. Together with <see cref="PlanetID"/> forms the (planet, attacker)
/// key that identifies this attack slot. Clients must echo it back in <see cref="PwJoinPlanet"/>.
/// </summary>
public string AttackerFaction { get; set; }
/// <summary>Faction shortcut of the planet's current owner, or null for neutral planets. Lets the
/// lobby surface "you own this", "ally owns this", "neutral" hints during attack/defense pick.</summary>
public string OwnerFaction { get; set; }
/// <summary>Average PW-WHR of the projected attacker squad (top-TeamSize volunteers). 0 when none.</summary>
public int AttackerAvgWhr { get; set; }
/// <summary>Average PW-WHR of the projected defender squad. Null in AttackCollect phase or when no volunteers.</summary>
public int? DefenderAvgWhr { get; set; }
/// <summary>Attacker win chance 0-100 derived from WHR delta. Null when either side is empty.</summary>
public int? WinChance { get; set; }
}
}

[Message(Origin.Client)]
public class PwJoinPlanet
{
public int PlanetID { get; set; }
/// <summary>
/// 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.
/// </summary>
public string AttackerFaction { get; set; }
}

/// <summary>
/// Client → server: cancel the player's current attack or defense commitment for the cycle.
/// </summary>
[Message(Origin.Client)]
public class PwCancel
{
}

[Message(Origin.Server)]
public class PwRequestJoinPlanet
{
public int PlanetID { get; set; }
public string AttackerFaction { get; set; }
}


[Message(Origin.Server)]
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)]
Expand All @@ -744,6 +783,19 @@ public class PwStatus
public PlanetWarsModes? PlanetWarsNextMode { get; set; }
public DateTime? PlanetWarsNextModeTime { get; set; }
public int MinLevel { get; set; }
public int AttackerPhaseMinutes { get; set; }
public int DefenderPhaseMinutes { get; set; }
/// <summary>Server's configured max attack charges. Lobby renders X/Max in the charges UI;
/// before this field existed Chobby hardcoded 2 as the fallback default.</summary>
public int MaxAttackCharges { get; set; }
}

[Message(Origin.Server)]
public class PwAttackCharges
{
public int Current { get; set; }
/// <summary>UTC time at which the next charge will be granted, rounded up to a full minute. Null when at max or charges are disabled.</summary>
public DateTime? NextRechargeTime { get; set; }
}


Expand Down
6 changes: 3 additions & 3 deletions Shared/PlasmaShared/GlobalConst.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ static void SetMode(ModeType newMode)
public const double PlanetMetalPerTurn = 1;
public const double PlanetWarsEnergyToMetalRatio = 0.0;
public const double PlanetWarsMaximumIP = 100.0; //maximum IP on each planet
public const int PlanetWarsVictoryPointsToWin = 100;
public const int PlanetWarsVictoryPointsToWin = 50;
public const int VictoryPointDecay = 1;
public const int BaseInfluencePerBattle = 32;
public const int InfluencePerAttacker = 1;
Expand Down Expand Up @@ -211,8 +211,8 @@ static void SetMode(ModeType newMode)
public const int PostVoteHideThreshold = -6;
public const bool OnlyAdminsSeePostVoters = false;
public const int PlanetWarsMinutesToAttackIfNoOption = 2;
public const int PlanetWarsMinutesToAttack = 5;
public const int PlanetWarsMinutesToAccept = 10;
public const int PlanetWarsMinutesToAttack = 2;
public const int PlanetWarsMinutesToAccept = 2;
public const int PlanetWarsDropshipsStayForMinutes = 2*60;
public const int PlanetWarsMaxTeamsize = 4;
public const double PlanetWarsDefenderWinKillCcMultiplier = 0.2;
Expand Down
6 changes: 6 additions & 0 deletions Shared/PlasmaShared/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ public static void SafeDispose(this IDisposable o)
if (o != null) o.Dispose();
}

public static DateTime CeilingToMinute(this DateTime d)
{
var floored = new DateTime(d.Year, d.Month, d.Day, d.Hour, d.Minute, 0, d.Kind);
return d == floored ? d : floored.AddMinutes(1);
}

public static IEnumerable<Indexed<T>> ToIndexedList<T>(this IEnumerable<T> enumeration)
{
return enumeration.Select((x, i) => new Indexed<T>(x, i));
Expand Down
9 changes: 9 additions & 0 deletions Zero-K.info/Controllers/PlanetwarsAdminController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,15 @@ public ActionResult StartGalaxy(int galaxyID)
gal.Ended = null;
gal.EndMessage = null;
db.SaveChanges();

// Seed everyone at the passive recharge limit, not the absolute max — defending as the very
// first PW action of the new galaxy then has a tangible reward (stockpiling above passive cap).
var initialCharges = Math.Min(DynamicConfig.Instance.PwAttackChargesPassiveLimit, DynamicConfig.Instance.PwAttackChargesMax);
db.Accounts.Where(x => x.FactionID != null).Update(x => new Account()
{
PwAttackCharges = initialCharges,
PwLastChargeChange = null,
});
}

return RedirectToAction("Index");
Expand Down
36 changes: 21 additions & 15 deletions Zero-K.info/Controllers/PlanetwarsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,23 @@ public ActionResult BombPlanet(int planetID, int count, bool? useWarp)
double ipKillAmmount = ipKillCount * GlobalConst.BomberKillIpAmount;
if (ipKillAmmount > 0)
{
var influenceDecayMin = planet.PlanetStructures.Where(x => x.IsActive && x.StructureType.EffectPreventInfluenceDecayBelow != null).Select(x => x.StructureType.EffectPreventInfluenceDecayBelow).OrderByDescending(x => x).FirstOrDefault() ?? 0;
var structureFloor = planet.PlanetStructures.Where(x => x.IsActive && x.StructureType.EffectPreventInfluenceDecayBelow != null).Select(x => x.StructureType.EffectPreventInfluenceDecayBelow).OrderByDescending(x => x).FirstOrDefault() ?? 0;
var globalFloor = DynamicConfig.Instance.PwBomberMinimumIpFloor;
var selfRate = DynamicConfig.Instance.PwBomberSelfIpRate;


foreach (PlanetFaction pf in planet.PlanetFactions.Where(x => x.FactionID != acc.FactionID))
foreach (PlanetFaction pf in planet.PlanetFactions)
{
pf.Influence -= ipKillAmmount;
if (pf.Influence < 0) pf.Influence = 0;

// prevent bombing below influence decaymin for owner - set by active structures
if (pf.FactionID == planet.OwnerFactionID && pf.Influence < influenceDecayMin) pf.Influence = influenceDecayMin;
}
bool isOwn = pf.FactionID == acc.FactionID;
double damage = isOwn ? ipKillAmmount * selfRate : ipKillAmmount;
if (damage <= 0) continue;

// owner gets max of global floor and any structure-provided floor; everyone else gets global only
double floor = (pf.FactionID == planet.OwnerFactionID) ? Math.Max(globalFloor, structureFloor) : globalFloor;

// floor only prevents pushing below it — a faction already below the floor is not raised by bombing
double effectiveFloor = Math.Min(pf.Influence, floor);
pf.Influence = Math.Max(pf.Influence - damage, Math.Max(effectiveFloor, 0));
}
}

var args = new List<object>
Expand Down Expand Up @@ -793,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 });
}
Expand All @@ -808,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();
}

Expand Down
2 changes: 1 addition & 1 deletion Zero-K.info/Views/Planetwars/Planet.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
</tr>
</table>

@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))
{
<a href="@Url.Action("MatchMakerAttack", "Planetwars", new { planetID = Model.PlanetID })">ATTACK PLANET</a>
}
Expand Down
27 changes: 17 additions & 10 deletions Zero-K.info/Views/Planetwars/PwMatchMaker.cshtml
Original file line number Diff line number Diff line change
@@ -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));
}

<div id="matchMaker">
Expand All @@ -30,14 +28,23 @@
foreach (PwMatchCommand.VoteOption opt in pw.Options)
{
<span style="border: 1px solid cyan;">
@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))
<span>[@opt.AttackerFaction]</span>
<span>[@opt.Count/@opt.Needed]</span>
@if (opt.WinChance != null)
{
<span>(WHR A:@opt.AttackerAvgWhr D:@opt.DefenderAvgWhr, @opt.WinChance% attacker)</span>
}
else if (opt.AttackerAvgWhr > 0)
{
<span>(WHR @opt.AttackerAvgWhr)</span>
}
</span>
<text>&nbsp; &nbsp;&nbsp;</text>
}
}
</div>
</div>
9 changes: 6 additions & 3 deletions ZeroKLobby/Notifications/PwBar.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Forms;
using LobbyClient;
Expand Down Expand Up @@ -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<string>());

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<Button>().ToList()) pnl.Controls.Remove(c);

Expand All @@ -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);
}
Expand All @@ -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<Button>().ToList()) pnl.Controls.Remove(c);
Expand Down
17 changes: 17 additions & 0 deletions ZkData/Ef/Account.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ public static double AdjustEloWeight(double currentWeight, double sumWeight, int
public double PwWarpProduced { get; set; }
public double PwWarpUsed { get; set; }
public double PwAttackPoints { get; set; }
public int PwAttackCharges { get; set; }
public DateTime? PwLastChargeChange { get; set; }
public bool HasVpnException { get; set; }
public bool HasKudos { get; set; }
public int ForumTotalUpvotes { get; set; }
Expand Down Expand Up @@ -530,6 +532,21 @@ public void ResetQuotas()



public void SpendPwAttackCharge()
{
if (PwAttackCharges > 0) PwAttackCharges--;
PwLastChargeChange = DateTime.UtcNow;
}

public void GrantPwAttackCharge(int maxCharges)
{
if (maxCharges <= 0) return;
if (PwAttackCharges >= maxCharges) return;
PwAttackCharges++;
PwLastChargeChange = DateTime.UtcNow;
}


public void SpendBombers(double count)
{
PwBombersUsed += count;
Expand Down
19 changes: 19 additions & 0 deletions ZkData/Ef/DynamicConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,26 @@ public class DynamicConfig

[Description("Map vote always tries to include some of the most popular maps (precentile <0.2), this value controls how big fraction of offers is most popular maps.")]
public double MapVoteFractionOfPopularMaps { get; set; } = 0.5;


[Description("PlanetWars: number of attack options shown to the attacking faction each turn.")]
public int PwAttackOptionCount { get; set; } = 6;

[Description("PlanetWars: maximum attack charges a player can hold. 0 disables the charge system.")]
public int PwAttackChargesMax { get; set; } = 2;

[Description("PlanetWars: minutes a player must be idle (no spend/gain) before their next +1 passive charge, up to max. Every charge gain or loss resets this clock, so the average regen rate is at most 1 charge per this many minutes.")]
public int PwAttackChargesCooldownMinutes { get; set; } = 60;

[Description("PlanetWars: cap on charges obtainable through passive idle recharge. Players may exceed this via active grants (e.g. defense rewards) and keep what they have, but the recharge tick will not push them above this value. Should be <= PwAttackChargesMax.")]
public int PwAttackChargesPassiveLimit { get; set; } = 1;

[Description("PlanetWars: fraction of enemy bomber IP damage also applied to the bomber's own faction. 0 disables self-damage.")]
public double PwBomberSelfIpRate { get; set; } = 0.5;

[Description("PlanetWars: minimum IP (0-100) below which bombers cannot push any faction. 0 disables the floor.")]
public double PwBomberMinimumIpFloor { get; set; } = 5.0;


public static DynamicConfig Instance;

Expand Down
Loading
Loading