Skip to content

Commit e0a1bc0

Browse files
Merge pull request #18 from javaisbetterthanpython/issue-8-pair-sitouts
[#8] Fixed pair sit-out logic
2 parents 469c534 + a633310 commit e0a1bc0

2 files changed

Lines changed: 283 additions & 28 deletions

File tree

src/matching/heuristics.ts

Lines changed: 246 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
513714
const 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.

test/heuristics.spec.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,43 @@ describe("calculateHeuristics()", () => {
281281

282282
test("performance after everyone has played together", async () => {});
283283

284+
test("fixed pair members sit out together", async () => {
285+
const players = ["a", "b", "c", "d", "e", "f"];
286+
const fixedPairs: [string, string][] = [["a", "b"]];
287+
const rounds: Round[] = [];
288+
289+
for (let i = 0; i < 20; i++) {
290+
const [nextRound] = await getNextRound(
291+
rounds,
292+
players,
293+
1,
294+
undefined,
295+
undefined,
296+
fixedPairs
297+
);
298+
rounds.push(nextRound);
299+
const aSits = nextRound.sitOuts.includes("a");
300+
const bSits = nextRound.sitOuts.includes("b");
301+
expect(aSits).toBe(bSits);
302+
}
303+
});
304+
305+
test("volunteer sit-out pulls fixed pair partner", async () => {
306+
const players = sampleNames.slice(0, 6);
307+
const fixedPairs: [string, string][] = [[players[0], players[1]]];
308+
const round = await getNextBestRound(
309+
[],
310+
players,
311+
1,
312+
[players[0]],
313+
fixedPairs
314+
);
315+
316+
expect(round.sitOuts).toContain(players[0]);
317+
expect(round.sitOuts).toContain(players[1]);
318+
expect(round.sitOuts).toHaveLength(2);
319+
});
320+
284321
test("fixed pair players always team together", async () => {
285322
const players = ["a", "b", "c", "d", "e", "f"];
286323
const fixedPairs: [string, string][] = [["a", "b"]];

0 commit comments

Comments
 (0)