@@ -120,6 +120,7 @@ private async void TimerOnElapsed(object sender, ElapsedEventArgs elapsedEventAr
120120 case PwPhase . AttackCollect :
121121 if ( DateTime . UtcNow > GetAttackDeadline ( ) )
122122 {
123+ await ApplyTurnEndChargeBump ( ) ;
123124 RunSquadFormation ( ) ;
124125 if ( FormedSquads . Any ( ) )
125126 {
@@ -150,7 +151,6 @@ private async void TimerOnElapsed(object sender, ElapsedEventArgs elapsedEventAr
150151 RunDefenderAssignment ( ) ;
151152 await LaunchAllBattles ( ) ;
152153 RunGalaxyTick ( ) ;
153- await ApplyTurnEndChargeBump ( ) ;
154154 }
155155 catch ( Exception ex )
156156 {
@@ -172,7 +172,14 @@ private async void TimerOnElapsed(object sender, ElapsedEventArgs elapsedEventAr
172172 }
173173
174174
175- // ===================== SQUAD FORMATION (PIERCING) =====================
175+ // ===================== SQUAD FORMATION =====================
176+ // Phase 1: planets with >= TeamSize attackers form one squad with ALL their attackers.
177+ // Phase 2: repeatedly complete a squad for the highest-ranked straggler whose planet
178+ // the pool can still fill, pulling other stragglers to him as fillers. Skip
179+ // leaders whose planet is too big for the pool; stop when none qualify.
180+ // Phase 3: any remaining stragglers join the strongest existing squad (drawn by its
181+ // highest-ranked member). If no squad was formed at all, they are dropped.
182+ // Result: at most one squad per planet.
176183
177184 private void RunSquadFormation ( )
178185 {
@@ -237,66 +244,65 @@ private void FormSquadsForFaction(int attackerFactionId, List<AttackOption> fact
237244 }
238245 }
239246
240- var pool = new HashSet < string > ( playerOption . Keys ) ;
241-
242- // Pass 1: form squads from any planet with >= TeamSize volunteers, top-WHR first
243- bool formed ;
244- do
247+ // Phase 1: self-sufficient planets form one squad with all their attackers
248+ foreach ( var opt in factionOptions )
245249 {
246- formed = false ;
247- foreach ( var opt in factionOptions )
250+ var available = opt . Attackers . Where ( playerOption . ContainsKey ) . ToList ( ) ;
251+ if ( available . Count >= opt . TeamSize )
248252 {
249- var available = opt . Attackers . Where ( pool . Contains ) . OrderByDescending ( x => playerWhr . Get ( x ) ) . ToList ( ) ;
250- while ( available . Count >= opt . TeamSize )
251- {
252- var squad = CreateSquadFromOption ( opt ) ;
253- squad . Attackers = available . Take ( opt . TeamSize ) . ToList ( ) ;
254- FormedSquads . Add ( squad ) ;
255- foreach ( var p in squad . Attackers ) pool . Remove ( p ) ;
256- available = available . Skip ( opt . TeamSize ) . ToList ( ) ;
257- formed = true ;
258- }
253+ var squad = CreateSquadFromOption ( opt ) ;
254+ squad . Attackers = available ;
255+ squad . TeamSize = available . Count ;
256+ FormedSquads . Add ( squad ) ;
257+ foreach ( var p in available ) playerOption . Remove ( p ) ;
259258 }
260- } while ( formed ) ;
259+ }
261260
262- // Pass 2: piercing — top PW-Rank player pulls others to their chosen planet
263- while ( pool . Count > 0 )
261+ // Phase 2: piercing — pick the top-ranked straggler whose planet's TeamSize
262+ // the pool can still satisfy; skip leaders whose planet is too big.
263+ while ( playerOption . Count > 0 )
264264 {
265- var leader = pool
265+ var leader = playerOption . Keys
266+ . Where ( x => playerOption [ x ] . TeamSize <= playerOption . Count )
266267 . OrderBy ( x => playerRoleOrder . GetOrDefault ( x , int . MaxValue ) )
267268 . ThenByDescending ( x => playerWhr . Get ( x ) )
268- . First ( ) ;
269+ . FirstOrDefault ( ) ;
270+
271+ if ( leader == null ) break ; // no straggler's planet fits the remaining pool
269272
270273 var leaderOption = playerOption [ leader ] ;
271- if ( pool . Count < leaderOption . TeamSize ) break ;
272274
273- var fillers = pool
275+ var fillers = playerOption . Keys
274276 . Where ( x => x != leader )
275277 . OrderByDescending ( x => playerWhr . Get ( x ) )
276278 . Take ( leaderOption . TeamSize - 1 )
277279 . ToList ( ) ;
278280
279- if ( fillers . Count < leaderOption . TeamSize - 1 ) break ;
280-
281281 var squad = CreateSquadFromOption ( leaderOption ) ;
282- squad . Attackers = new List < string > { leader } ;
282+ squad . Attackers . Add ( leader ) ;
283283 squad . Attackers . AddRange ( fillers ) ;
284+ squad . TeamSize = squad . Attackers . Count ;
284285 FormedSquads . Add ( squad ) ;
285286
286- pool . Remove ( leader ) ;
287- foreach ( var p in fillers ) pool . Remove ( p ) ;
287+ playerOption . Remove ( leader ) ;
288+ foreach ( var p in fillers ) playerOption . Remove ( p ) ;
288289 }
289290
290- // Pass 3: absorb leftovers into an existing squad on their original planet
291- foreach ( var name in pool . ToList ( ) )
291+ // Phase 3: remaining stragglers all merge into the strongest existing squad
292+ // in THIS faction's pass (each faction's squads are independent).
293+ if ( playerOption . Count > 0 )
292294 {
293- var originalOption = playerOption [ name ] ;
294- var squad = FormedSquads . FirstOrDefault ( s => s . PlanetID == originalOption . PlanetID && s . AttackerFactionID == attackerFactionId ) ;
295- if ( squad != null )
295+ var strongest = FormedSquads
296+ . Where ( s => s . AttackerFactionID == attackerFactionId )
297+ . OrderBy ( s => s . Attackers . Min ( a => playerRoleOrder . GetOrDefault ( a , int . MaxValue ) ) )
298+ . ThenByDescending ( s => s . Attackers . Max ( a => playerWhr . Get ( a ) ) )
299+ . FirstOrDefault ( ) ;
300+
301+ if ( strongest != null )
296302 {
297- squad . Attackers . Add ( name ) ;
298- squad . TeamSize = squad . Attackers . Count ;
299- pool . Remove ( name ) ;
303+ strongest . Attackers . AddRange ( playerOption . Keys ) ;
304+ strongest . TeamSize = strongest . Attackers . Count ;
305+ playerOption . Clear ( ) ;
300306 }
301307 }
302308 }
@@ -360,7 +366,7 @@ private void RunDefenderAssignment()
360366 // floating pool fills deficit on other squads where the defender's faction is eligible
361367 floatingPool = floatingPool . OrderByDescending ( x => defenderWhr . Get ( x ) ) . Distinct ( ) . ToList ( ) ;
362368
363- // cache per-squad defending factions and floating-pool faction IDs
369+ // cache per-squad defending factions (GetDefendingFactions opens its own DB context, expensive inside a loop)
364370 var squadDefendingFactions = new Dictionary < AttackOption , HashSet < int > > ( ) ;
365371 foreach ( var squad in FormedSquads )
366372 squadDefendingFactions [ squad ] = GetDefendingFactions ( squad ) . Select ( f => f . FactionID ) . ToHashSet ( ) ;
@@ -435,7 +441,7 @@ private async Task LaunchAllBattles()
435441 // (StartGame returned false, or setup threw) refund the commitment.
436442 var attackerNamesToChargeSpend = new List < string > ( ) ;
437443
438- // one battle per squad — no merging across attacker factions
444+ // one battle per squad — no merging across attacker factions (each (planet, attacker-faction) is its own slot)
439445 foreach ( var squad in FormedSquads . ToList ( ) )
440446 {
441447 squad . Attackers = squad . Attackers . Where ( x => server . ConnectedUsers . ContainsKey ( x ) ) . ToList ( ) ;
@@ -839,6 +845,14 @@ private PwMatchCommand StampLobbyCommand(List<OptionSnapshot> snapshots, PwPhase
839845 if ( playerFaction != null )
840846 playerFactionId = factions . FirstOrDefault ( f => f . Shortcut == playerFaction ) ? . FactionID ;
841847
848+ // Distinct attacker factions across all options — populated from the shared snapshot so it
849+ // matches the per-option AttackerFaction data the client sees in both phases.
850+ var attackerFactionShortcuts = snapshots
851+ . Select ( s => s . AttackerFactionShortcut )
852+ . Where ( x => ! string . IsNullOrEmpty ( x ) )
853+ . Distinct ( )
854+ . ToList ( ) ;
855+
842856 if ( phase == PwPhase . AttackCollect )
843857 {
844858 // All factions' options are shown to every viewer (parity with pre-parallel-turn UX, where
@@ -869,7 +883,7 @@ private PwMatchCommand StampLobbyCommand(List<OptionSnapshot> snapshots, PwPhase
869883 Options = options ,
870884 Deadline = deadline ,
871885 DeadlineSeconds = ( int ) deadline . Subtract ( DateTime . UtcNow ) . TotalSeconds ,
872- AttackerFaction = playerFaction ,
886+ AttackerFactions = attackerFactionShortcuts ,
873887 } ;
874888 }
875889 else // DefendCollect
@@ -912,7 +926,7 @@ private PwMatchCommand StampLobbyCommand(List<OptionSnapshot> snapshots, PwPhase
912926 Options = options ,
913927 Deadline = deadline ,
914928 DeadlineSeconds = ( int ) deadline . Subtract ( DateTime . UtcNow ) . TotalSeconds ,
915- AttackerFaction = null ,
929+ AttackerFactions = attackerFactionShortcuts ,
916930 DefenderFactions = allDefenderShortcuts . ToList ( ) ,
917931 } ;
918932 }
0 commit comments