@@ -507,71 +507,288 @@ const pickFromListBiasBeginning = <T>(
507507 return { picked, remaining } ;
508508} ;
509509
510+ type SitOutUnit = { members : PlayerId [ ] } ;
511+
512+ const getFixedPairPartnerMap = ( fixedPairs : Team [ ] ) : Map < PlayerId , PlayerId > => {
513+ const map = new Map < PlayerId , PlayerId > ( ) ;
514+ for ( const [ a , b ] of fixedPairs ) {
515+ map . set ( a , b ) ;
516+ map . set ( b , a ) ;
517+ }
518+ return map ;
519+ } ;
520+
521+ const getActiveFixedTeams = (
522+ roundPlayers : PlayerId [ ] ,
523+ fixedPairs : Team [ ]
524+ ) : Team [ ] => {
525+ const active = new Set ( roundPlayers ) ;
526+ const assigned = new Set < PlayerId > ( ) ;
527+ const teams : Team [ ] = [ ] ;
528+
529+ for ( const [ a , b ] of fixedPairs ) {
530+ if (
531+ active . has ( a ) &&
532+ active . has ( b ) &&
533+ ! assigned . has ( a ) &&
534+ ! assigned . has ( b )
535+ ) {
536+ teams . push ( [ a , b ] ) ;
537+ assigned . add ( a ) ;
538+ assigned . add ( b ) ;
539+ }
540+ }
541+
542+ return teams ;
543+ } ;
544+
545+ const getUnpairedPlayers = (
546+ roundPlayers : PlayerId [ ] ,
547+ fixedTeams : Team [ ]
548+ ) : PlayerId [ ] => {
549+ const paired = new Set ( fixedTeams . flat ( ) ) ;
550+ return roundPlayers . filter ( ( player ) => ! paired . has ( player ) ) ;
551+ } ;
552+
553+ const expandVolunteersWithFixedPartners = (
554+ volunteers : PlayerId [ ] ,
555+ partnerMap : Map < PlayerId , PlayerId >
556+ ) : PlayerId [ ] => {
557+ const expanded = new Set ( volunteers ) ;
558+ for ( const volunteer of volunteers ) {
559+ const partner = partnerMap . get ( volunteer ) ;
560+ if ( partner ) {
561+ expanded . add ( partner ) ;
562+ }
563+ }
564+ return [ ...expanded ] ;
565+ } ;
566+
567+ const buildSitOutUnits = (
568+ players : PlayerId [ ] ,
569+ fixedPairs : Team [ ]
570+ ) : SitOutUnit [ ] => {
571+ const playerSet = new Set ( players ) ;
572+ const paired = new Set < PlayerId > ( ) ;
573+ const units : SitOutUnit [ ] = [ ] ;
574+
575+ for ( const [ a , b ] of fixedPairs ) {
576+ if ( playerSet . has ( a ) && playerSet . has ( b ) ) {
577+ units . push ( { members : [ a , b ] } ) ;
578+ paired . add ( a ) ;
579+ paired . add ( b ) ;
580+ }
581+ }
582+
583+ for ( const player of players ) {
584+ if ( ! paired . has ( player ) ) {
585+ units . push ( { members : [ player ] } ) ;
586+ }
587+ }
588+
589+ return units ;
590+ } ;
591+
592+ const canSumToTarget = ( sizes : number [ ] , target : number ) : boolean => {
593+ if ( target < 0 ) return false ;
594+ if ( target === 0 ) return true ;
595+ const reachable = new Array ( target + 1 ) . fill ( false ) ;
596+ reachable [ 0 ] = true ;
597+ for ( const size of sizes ) {
598+ for ( let sum = target ; sum >= size ; sum -- ) {
599+ reachable [ sum ] = reachable [ sum ] || reachable [ sum - size ] ;
600+ }
601+ }
602+ return reachable [ target ] ;
603+ } ;
604+
605+ const adjustSitOutCountForUnits = (
606+ target : number ,
607+ units : SitOutUnit [ ]
608+ ) : number => {
609+ const totalPlayers = units . reduce ( ( sum , unit ) => sum + unit . members . length , 0 ) ;
610+ const sizes = units . map ( ( unit ) => unit . members . length ) ;
611+
612+ if ( target > totalPlayers ) return totalPlayers ;
613+ if ( canSumToTarget ( sizes , target ) ) return target ;
614+
615+ for ( let adjusted = target + 1 ; adjusted <= totalPlayers ; adjusted ++ ) {
616+ if ( canSumToTarget ( sizes , adjusted ) ) return adjusted ;
617+ }
618+ return totalPlayers ;
619+ } ;
620+
621+ const unitsToPlayers = ( units : SitOutUnit [ ] ) : PlayerId [ ] =>
622+ units . flatMap ( ( unit ) => unit . members ) ;
623+
624+ const getUnitRoundsSinceSitOut = (
625+ unit : SitOutUnit ,
626+ heuristics : PlayerHeuristicsDictionary
627+ ) : number =>
628+ Math . max ( ...unit . members . map ( ( player ) => heuristics [ player ] . roundsSinceSitOut ) ) ;
629+
630+ const getUnitSitOutCount = (
631+ unit : SitOutUnit ,
632+ heuristics : PlayerHeuristicsDictionary
633+ ) : number =>
634+ Math . min ( ...unit . members . map ( ( player ) => heuristics [ player ] . sitOutCount ) ) ;
635+
636+ const pickUnitsDeterministic = (
637+ units : SitOutUnit [ ] ,
638+ targetPlayerCount : number
639+ ) : { picked : SitOutUnit [ ] ; remaining : SitOutUnit [ ] } => {
640+ const remaining = [ ...units ] ;
641+ const picked : SitOutUnit [ ] = [ ] ;
642+ let needed = targetPlayerCount ;
643+
644+ for ( let index = 0 ; index < remaining . length && needed > 0 ; index ++ ) {
645+ const unit = remaining [ index ] ;
646+ const unitSize = unit . members . length ;
647+ const restSizes = remaining
648+ . slice ( index + 1 )
649+ . map ( ( candidate ) => candidate . members . length ) ;
650+ if ( unitSize <= needed && canSumToTarget ( restSizes , needed - unitSize ) ) {
651+ picked . push ( unit ) ;
652+ remaining . splice ( index , 1 ) ;
653+ needed -= unitSize ;
654+ index -= 1 ;
655+ }
656+ }
657+
658+ return { picked, remaining } ;
659+ } ;
660+
661+ const pickUnitsForSitOuts = (
662+ units : SitOutUnit [ ] ,
663+ targetPlayerCount : number
664+ ) : { picked : SitOutUnit [ ] ; remaining : SitOutUnit [ ] } => {
665+ if ( targetPlayerCount === 0 ) return { picked : [ ] , remaining : units } ;
666+
667+ const totalPlayers = unitsToPlayers ( units ) . length ;
668+ if ( targetPlayerCount >= totalPlayers ) {
669+ return { picked : units , remaining : [ ] } ;
670+ }
671+
672+ const remaining = [ ...units ] ;
673+ const picked : SitOutUnit [ ] = [ ] ;
674+ let playersPicked = 0 ;
675+ let index = 0 ;
676+ let attempts = 0 ;
677+ const baseChance = 0.6 ;
678+ const maxAttempts = remaining . length * 50 ;
679+
680+ while ( playersPicked < targetPlayerCount && remaining . length > 0 ) {
681+ attempts += 1 ;
682+ if ( attempts > maxAttempts ) {
683+ return pickUnitsDeterministic ( units , targetPlayerCount ) ;
684+ }
685+
686+ const unit = remaining [ index ] ;
687+ const unitSize = unit . members . length ;
688+ const newTotal = playersPicked + unitSize ;
689+ const restSizes = remaining
690+ . filter ( ( _ , unitIndex ) => unitIndex !== index )
691+ . map ( ( candidate ) => candidate . members . length ) ;
692+ const canPick =
693+ newTotal <= targetPlayerCount &&
694+ canSumToTarget ( restSizes , targetPlayerCount - newTotal ) ;
695+ const rand = Math . random ( ) ;
696+ const chanceForIndex =
697+ ( ( remaining . length - index ) / remaining . length ) * baseChance ;
698+
699+ if ( rand < chanceForIndex && canPick ) {
700+ picked . push ( remaining . splice ( index , 1 ) [ 0 ] ) ;
701+ playersPicked += unitSize ;
702+ index = remaining . length ? index % remaining . length : 0 ;
703+ } else {
704+ index = ( index + 1 ) % remaining . length ;
705+ }
706+ }
707+
708+ return { picked, remaining } ;
709+ } ;
710+
510711/**
511712 * Choose which players sit out.
512713 */
513714const getSitOuts = (
514715 heuristics : PlayerHeuristicsDictionary ,
515716 allPlayers : PlayerId [ ] ,
516717 courts : number ,
517- volunteers : PlayerId [ ] = [ ]
718+ volunteers : PlayerId [ ] = [ ] ,
719+ fixedPairs : Team [ ] = [ ]
518720) => {
721+ const partnerMap = getFixedPairPartnerMap ( fixedPairs ) ;
722+ const expandedVolunteers = expandVolunteersWithFixedPartners (
723+ volunteers ,
724+ partnerMap
725+ ) ;
726+
519727 // Remove volunteer sitouts from possible players.
520- const players = allPlayers . filter ( ( player ) =>
521- volunteers . every ( ( volunteer ) => volunteer !== player )
728+ const players = allPlayers . filter (
729+ ( player ) => ! expandedVolunteers . includes ( player )
522730 ) ;
523731 const capacity = courts * 4 ;
524- const sitouts =
732+ const rawSitouts =
525733 players . length > capacity
526734 ? players . length - courts * 4
527735 : players . length % 4 ;
528736
737+ const units = buildSitOutUnits ( players , fixedPairs ) ;
738+ const sitouts = adjustSitOutCountForUnits ( rawSitouts , units ) ;
739+
529740 // Shuffle because at the beginning everyone's rounds since sit out is the same.
530- const inOrderOfSitout = shuffle ( players ) . sort (
531- ( a , b ) => heuristics [ b ] . roundsSinceSitOut - heuristics [ a ] . roundsSinceSitOut
741+ const inOrderOfSitout = shuffle ( units ) . sort (
742+ ( a , b ) =>
743+ getUnitRoundsSinceSitOut ( b , heuristics ) -
744+ getUnitRoundsSinceSitOut ( a , heuristics )
532745 ) ;
533746
534747 // Get everyone who has sat out the least number of times.
535- const leastSitOuts = players . reduce ( ( least , player ) => {
536- return Math . min ( heuristics [ player ] . sitOutCount , least ) ;
748+ const leastSitOuts = units . reduce ( ( least , unit ) => {
749+ return Math . min ( getUnitSitOutCount ( unit , heuristics ) , least ) ;
537750 } , Infinity ) ;
538751
539752 // Two groups: those who are yet to sit out this round, and those who have.
540753 const { eligibleToSitOut, alreadySatOut } = inOrderOfSitout . reduce (
541754 (
542- result : { eligibleToSitOut : PlayerId [ ] ; alreadySatOut : PlayerId [ ] } ,
543- player
755+ result : { eligibleToSitOut : SitOutUnit [ ] ; alreadySatOut : SitOutUnit [ ] } ,
756+ unit
544757 ) => {
545- if ( heuristics [ player ] . sitOutCount === leastSitOuts ) {
546- result . eligibleToSitOut . push ( player ) ;
758+ if ( getUnitSitOutCount ( unit , heuristics ) === leastSitOuts ) {
759+ result . eligibleToSitOut . push ( unit ) ;
547760 } else {
548- result . alreadySatOut . push ( player ) ;
761+ result . alreadySatOut . push ( unit ) ;
549762 }
550763 return result ;
551764 } ,
552765 { eligibleToSitOut : [ ] , alreadySatOut : [ ] }
553766 ) ;
554767
555- // If the number of sitouts exhausts the remaining sitouts, then collect them all.
556- const mandatorySitouts =
557- sitouts >= eligibleToSitOut . length ? eligibleToSitOut : [ ] ;
558-
559- // We will pick whatever is left from the main group.
560- const sitoutsLeft = sitouts - mandatorySitouts . length ;
768+ const eligiblePlayerCount = unitsToPlayers ( eligibleToSitOut ) . length ;
769+ const mandatoryUnits =
770+ sitouts >= eligiblePlayerCount ? eligibleToSitOut : [ ] ;
771+ const mandatoryPlayerCount = unitsToPlayers ( mandatoryUnits ) . length ;
772+ const sitoutsLeft = sitouts - mandatoryPlayerCount ;
561773
562774 // Pick from the eligibles if there are more eligibles than sitouts needed, otherwise fill up
563775 // the missing sitouts from the next round.
564- const { picked, remaining } = pickFromListBiasBeginning (
565- mandatorySitouts . length ? alreadySatOut : eligibleToSitOut ,
776+ const { picked : pickedUnits , remaining : remainingUnits } = pickUnitsForSitOuts (
777+ mandatoryUnits . length ? alreadySatOut : eligibleToSitOut ,
566778 sitoutsLeft
567779 ) ;
568780
569- return [
570- // Sitouts: mandatory (if applicable) and picked.
571- [ ...volunteers , ...mandatorySitouts , ...picked ] . sort ( ) ,
572- // Players: remaining from picked, plus all those who have already sat out if we didn't pick from that group.
573- shuffle ( [ ...remaining , ...( mandatorySitouts . length ? [ ] : alreadySatOut ) ] ) ,
574- ] ;
781+ const sitOutPlayers = [
782+ ...expandedVolunteers ,
783+ ...unitsToPlayers ( [ ...mandatoryUnits , ...pickedUnits ] ) ,
784+ ] . sort ( ) ;
785+
786+ const roundPlayerUnits = shuffle ( [
787+ ...remainingUnits ,
788+ ...( mandatoryUnits . length ? [ ] : alreadySatOut ) ,
789+ ] ) ;
790+
791+ return [ sitOutPlayers , unitsToPlayers ( roundPlayerUnits ) ] ;
575792} ;
576793
577794/**
@@ -605,7 +822,8 @@ async function getNextRound(
605822 heuristics ,
606823 players ,
607824 courts ,
608- volunteerSitouts
825+ volunteerSitouts ,
826+ fixedPairs
609827 ) ;
610828
611829 const sitOuts = sitoutPlayers . sort ( ) ; // Sort by ID for stable order.
0 commit comments