@@ -129,6 +129,13 @@ private async void TimerOnElapsed(object sender, ElapsedEventArgs elapsedEventAr
129129 RunSquadFormation ( ) ;
130130 if ( FormedSquads . Any ( ) )
131131 {
132+ // Charge spend happens here, the moment squads are formed — every attacker in
133+ // a FormedSquad has irrevocably committed (see disconnect locking above), so
134+ // there is no path that gives the charge back. LaunchAllBattles no longer
135+ // touches charges.
136+ var attackerNames = FormedSquads . SelectMany ( s => s . Attackers ) . Distinct ( ) . ToList ( ) ;
137+ if ( attackerNames . Count > 0 ) await SpendAttackCharges ( attackerNames ) ;
138+
132139 Phase = PwPhase . DefendCollect ;
133140 PhaseStartTime = DateTime . UtcNow ;
134141 UpdateLobby ( ) ;
@@ -447,15 +454,15 @@ private int AvgTopNWhr(IEnumerable<string> names, int slots)
447454
448455 private async Task LaunchAllBattles ( )
449456 {
450- // charges are spent only for attackers whose squad reached a confirmed outcome — either
451- // the Spring battle successfully started, or it was a concede. Battles that fail to start
452- // (StartGame returned false, or setup threw) refund the commitment.
453- var attackerNamesToChargeSpend = new List < string > ( ) ;
457+ // Charges are spent at end of AttackCollect (squad formation), not here. By the time we launch,
458+ // the commitment is final regardless of whether StartGame succeeds or the squad concedes.
454459
455460 // one battle per squad — no merging across attacker factions (each (planet, attacker-faction) is its own slot)
456461 foreach ( var squad in FormedSquads . ToList ( ) )
457462 {
458- squad . Attackers = squad . Attackers . Where ( x => server . ConnectedUsers . ContainsKey ( x ) ) . ToList ( ) ;
463+ // Attackers are locked at squad formation; we do NOT drop disconnected ones — they keep their
464+ // slot in the Spring script and can reconnect into the running battle. Defenders are looser —
465+ // a defender who disconnected after RunDefenderAssignment is dropped here.
459466 squad . Defenders = squad . Defenders . Where ( x => server . ConnectedUsers . ContainsKey ( x ) ) . ToList ( ) ;
460467
461468 if ( squad . Attackers . Count == 0 ) continue ;
@@ -474,7 +481,6 @@ private async Task LaunchAllBattles()
474481
475482 if ( await battle . StartGame ( ) )
476483 {
477- attackerNamesToChargeSpend . AddRange ( squad . Attackers ) ;
478484 var attackerFactionShortcut = GetFactionShortcut ( squad . AttackerFactionID ) ?? "?" ;
479485 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 ) } ";
480486 foreach ( var fac in factions ) await server . GhostChanSay ( fac . Shortcut , text ) ;
@@ -492,15 +498,12 @@ private async Task LaunchAllBattles()
492498 }
493499 else
494500 {
495- // concede — zero defenders. Attackers still "attacked", so charge applies.
496- attackerNamesToChargeSpend . AddRange ( squad . Attackers ) ;
501+ // concede — zero defenders. Charge was already spent at end of AttackCollect.
497502 RecordPlanetwarsLoss ( squad ) ;
498503 }
499504 }
500505
501506 FormedSquads . Clear ( ) ;
502-
503- if ( attackerNamesToChargeSpend . Count > 0 ) await SpendAttackCharges ( attackerNamesToChargeSpend ) ;
504507 }
505508
506509 private async Task SpendAttackCharges ( List < string > playerNames )
@@ -738,11 +741,12 @@ public async Task OnUserDisconnected(string name)
738741 }
739742 else if ( Phase == PwPhase . DefendCollect )
740743 {
744+ // Attackers are locked at squad formation: a disconnect must NOT release them, otherwise
745+ // they could reconnect and switch into a defense slot (or simply dodge their charge spend).
746+ // They stay in squad.Attackers and are carried into the launched Spring battle so they can
747+ // reconnect into the running game.
741748 foreach ( var squad in FormedSquads )
742- {
743749 changed |= squad . DefenderVotes . RemoveAll ( x => x == name ) > 0 ;
744- changed |= squad . Attackers . RemoveAll ( x => x == name ) > 0 ;
745- }
746750 if ( changed ) UpdateDefendersFullTime ( ) ;
747751 }
748752
@@ -767,6 +771,7 @@ private sealed class OptionSnapshot
767771 public int PlanetId ;
768772 public int ? AttackerFactionId ;
769773 public string AttackerFactionShortcut ;
774+ public string OwnerFactionShortcut ;
770775 public string PlanetName ;
771776 public string Map ;
772777 public int IconSize ;
@@ -808,6 +813,7 @@ private LobbySnapshot ComputeLobbySnapshot(PwPhase phase)
808813 PlanetId = opt . PlanetID ,
809814 AttackerFactionId = opt . AttackerFactionID ,
810815 AttackerFactionShortcut = GetFactionShortcut ( opt . AttackerFactionID ) ,
816+ OwnerFactionShortcut = GetFactionShortcut ( opt . OwnerFactionID ) ,
811817 PlanetName = opt . Name ,
812818 Map = opt . Map ,
813819 IconSize = opt . IconSize ,
@@ -841,6 +847,7 @@ private LobbySnapshot ComputeLobbySnapshot(PwPhase phase)
841847 PlanetId = squad . PlanetID ,
842848 AttackerFactionId = squad . AttackerFactionID ,
843849 AttackerFactionShortcut = GetFactionShortcut ( squad . AttackerFactionID ) ,
850+ OwnerFactionShortcut = GetFactionShortcut ( squad . OwnerFactionID ) ,
844851 PlanetName = squad . Name ,
845852 Map = squad . Map ,
846853 IconSize = squad . IconSize ,
@@ -904,6 +911,7 @@ private PwMatchCommand StampLobbyCommand(LobbySnapshot snapshot, PwPhase phase,
904911 PlayerIsAttacker = playerName != null && s . AttackerNames . Contains ( playerName ) ,
905912 PlayerIsDefender = false ,
906913 AttackerFaction = s . AttackerFactionShortcut ,
914+ OwnerFaction = s . OwnerFactionShortcut ,
907915 AttackerAvgWhr = s . AttackerAvgWhr ,
908916 DefenderAvgWhr = null ,
909917 WinChance = null ,
@@ -938,6 +946,7 @@ private PwMatchCommand StampLobbyCommand(LobbySnapshot snapshot, PwPhase phase,
938946 PlayerIsAttacker = playerIsAttacker ,
939947 PlayerIsDefender = playerName != null && s . DefenderNames . Contains ( playerName ) ,
940948 AttackerFaction = s . AttackerFactionShortcut ,
949+ OwnerFaction = s . OwnerFactionShortcut ,
941950 AttackerAvgWhr = s . AttackerAvgWhr ,
942951 DefenderAvgWhr = s . DefenderAvgWhr ,
943952 WinChance = s . WinChance ,
@@ -1184,8 +1193,12 @@ public void RemoveFromRunningBattles(int battleID)
11841193 public static PwAttackCharges BuildPwAttackCharges ( Account account )
11851194 {
11861195 var max = DynamicConfig . Instance . PwAttackChargesMax ;
1196+ // Passive recharge stops at PwAttackChargesPassiveLimit; charges above that come only from active
1197+ // grants (e.g. defense rewards). Show NextRechargeTime only while the player is still under the
1198+ // passive cap — once they're at or above it, no passive tick is coming.
1199+ var passiveLimit = Math . Min ( DynamicConfig . Instance . PwAttackChargesPassiveLimit , max ) ;
11871200 DateTime ? nextRechargeTime = null ;
1188- if ( max > 0 && account . PwAttackCharges < max && account . PwLastChargeChange != null )
1201+ if ( max > 0 && account . PwAttackCharges < passiveLimit && account . PwLastChargeChange != null )
11891202 nextRechargeTime = account . PwLastChargeChange . Value . AddMinutes ( DynamicConfig . Instance . PwAttackChargesCooldownMinutes ) . CeilingToMinute ( ) ;
11901203 return new PwAttackCharges
11911204 {
@@ -1219,6 +1232,12 @@ private async Task ProcessChargeRecharge()
12191232 {
12201233 var max = DynamicConfig . Instance . PwAttackChargesMax ;
12211234 if ( max <= 0 ) return ;
1235+ // Passive recharge tops out at PwAttackChargesPassiveLimit (clamped to max). Active grants
1236+ // (defense rewards) can push players above this and they keep what they have, but no idle
1237+ // tick will. Default of 1 means a player who logged off at 0 wakes up tomorrow with one
1238+ // charge — enough to attack OR to incentivise defending to stockpile more.
1239+ var passiveLimit = Math . Min ( DynamicConfig . Instance . PwAttackChargesPassiveLimit , max ) ;
1240+ if ( passiveLimit <= 0 ) return ;
12221241 var cooldownMinutes = DynamicConfig . Instance . PwAttackChargesCooldownMinutes ;
12231242 // +35s offset: displayed nextRechargeTime is rounded up to a full minute. Bumping the
12241243 // eligibility window forward absorbs ≤1min jitter between the recharge check and the
@@ -1230,11 +1249,11 @@ private async Task ProcessChargeRecharge()
12301249 {
12311250 bumped = db . Accounts . Where ( a =>
12321251 a . FactionID != null &&
1233- a . PwAttackCharges < max &&
1252+ a . PwAttackCharges < passiveLimit &&
12341253 a . PwLastChargeChange != null &&
12351254 a . PwLastChargeChange <= threshold ) . ToList ( ) ;
12361255
1237- foreach ( var acc in bumped ) acc . GrantPwAttackCharge ( max ) ;
1256+ foreach ( var acc in bumped ) acc . GrantPwAttackCharge ( passiveLimit ) ;
12381257
12391258 if ( bumped . Count > 0 ) db . SaveChanges ( ) ;
12401259 }
@@ -1285,6 +1304,7 @@ private static PwStatus GeneratePwStatus()
12851304 PlanetWarsNextModeTime = MiscVar . PlanetWarsNextModeTime ,
12861305 AttackerPhaseMinutes = GlobalConst . PlanetWarsMinutesToAttack ,
12871306 DefenderPhaseMinutes = GlobalConst . PlanetWarsMinutesToAccept ,
1307+ MaxAttackCharges = DynamicConfig . Instance . PwAttackChargesMax ,
12881308 } ;
12891309 }
12901310
0 commit comments