diff --git a/docs/ISSUE_QUEUE.md b/docs/ISSUE_QUEUE.md
index 3849075..b7cd5b7 100644
--- a/docs/ISSUE_QUEUE.md
+++ b/docs/ISSUE_QUEUE.md
@@ -4,7 +4,7 @@ Updated by the orchestrator. Status: `open` | `in-progress` | `in-review` | `don
| Priority | Issue | Title | Status | Depends on | PR |
|----------|-------|-------|--------|------------|-----|
-| 1 | [#30](https://github.com/javaisbetterthanpython/jumbled-doubles-enhanced/issues/30) | Richer session mix + instant next round | open | — | — |
+| 1 | [#30](https://github.com/javaisbetterthanpython/jumbled-doubles-enhanced/issues/30) | Richer session mix + instant next round | in-review | — | [#40](https://github.com/javaisbetterthanpython/jumbled-doubles-enhanced/pull/40) |
| 2 | [#31](https://github.com/javaisbetterthanpython/jumbled-doubles-enhanced/issues/31) | Edit player names (in-game + setup) | done | — | [#37](https://github.com/javaisbetterthanpython/jumbled-doubles-enhanced/pull/37) |
| 3 | [#32](https://github.com/javaisbetterthanpython/jumbled-doubles-enhanced/issues/32) | Recent matchup spacing — formal guarantee | open | #30 | — |
| 4 | [#33](https://github.com/javaisbetterthanpython/jumbled-doubles-enhanced/issues/33) | Skill groups — foundation (data model + UI) | open | — | — |
diff --git a/pages/new.tsx b/pages/new.tsx
index 17cda87..7424510 100644
--- a/pages/new.tsx
+++ b/pages/new.tsx
@@ -15,6 +15,7 @@ import { PairLinkIcon } from "../src/PlayerBadge";
import {
newGame,
useShufflerDispatch,
+ useShufflerPregen,
useShufflerState,
useShufflerWorker,
} from "../src/useShuffler";
@@ -70,6 +71,7 @@ function renameInPairs(
function NewGame() {
const router = useRouter();
const state = useShufflerState();
+ const pregen = useShufflerPregen();
const { playersById } = state;
const dispatch = useShufflerDispatch();
const worker = useShufflerWorker();
@@ -173,7 +175,7 @@ function NewGame() {
courts: courtCount,
courtNames: customizeCourtNames ? courtNames : [],
fixedPairs,
- });
+ }, pregen);
router.push("/rounds");
};
diff --git a/pages/rounds.tsx b/pages/rounds.tsx
index 4d5fac0..4ee99cf 100644
--- a/pages/rounds.tsx
+++ b/pages/rounds.tsx
@@ -21,7 +21,9 @@ import {
editCourts,
editPlayers,
newRound,
+ usePregenerateNextRound,
useShufflerDispatch,
+ useShufflerPregen,
useShufflerState,
useShufflerWorker,
} from "../src/useShuffler";
@@ -30,6 +32,8 @@ export default function Rounds() {
const state = useShufflerState();
const dispatch = useShufflerDispatch();
const worker = useShufflerWorker();
+ const pregen = useShufflerPregen();
+ usePregenerateNextRound();
const [sitoutModal, setSitoutModal] = useState(false);
const [playersModal, setPlayersModal] = useState(false);
@@ -109,7 +113,7 @@ export default function Rounds() {
await newRound(dispatch, state, worker, {
regenerate: true,
volunteerSitouts,
- });
+ }, pregen);
setSitoutModal(false);
}}
/>
@@ -121,7 +125,7 @@ export default function Rounds() {
newPlayers,
fixedPairs,
regenerate,
- });
+ }, pregen);
setPlayersModal(false);
}}
/>
@@ -132,7 +136,7 @@ export default function Rounds() {
await editCourts(dispatch, state, worker, {
regenerate,
courts,
- });
+ }, pregen);
setCourtsModal(false);
}}
/>
@@ -177,6 +181,11 @@ export default function Rounds() {
{state.generating && !round ? (
Jumbling the next round…
) : null}
+ {round?.repeatNote ? (
+
+ {round.repeatNote}
+
+ ) : null}
{/* Sitting out */}{" "}
@@ -278,7 +287,7 @@ export default function Rounds() {
onPress={async () => {
await newRound(dispatch, state, worker, {
volunteerSitouts: [],
- });
+ }, pregen);
}}
className="bg-gradient-to-l from-blue-600 to-pink-600 text-white"
>
diff --git a/src/matching/heuristics.ts b/src/matching/heuristics.ts
index b1eff2c..28a4582 100644
--- a/src/matching/heuristics.ts
+++ b/src/matching/heuristics.ts
@@ -7,12 +7,16 @@ export type MatchIdentifier = string;
export type MatchCounts = { [key: MatchIdentifier]: number };
export type PartnerPairIdentifier = string;
export type PartnerPairCounts = { [key: PartnerPairIdentifier]: number };
+export type OpponentPairIdentifier = string;
+export type OpponentPairCounts = { [key: OpponentPairIdentifier]: number };
export type Match = [Team, Team];
export type Round = {
matches: Array
;
sitOuts: Array;
/** Display names frozen when this round was generated. */
playerNamesById?: Record;
+ /** Shown when session-wide no-repeat could not be fully satisfied. */
+ repeatNote?: string;
};
export type Player = {
name: string;
@@ -40,9 +44,15 @@ export const INFINITY = 9999;
/** Penalty subtracted when two players were partners or opponents in the previous round. */
export const BACK_TO_BACK_MATCHUP_PENALTY = 5000;
+/** Penalty when a partner/opponent pairing repeats for the 2nd time in a session. */
+export const SESSION_REPEAT_PENALTY_2ND = 100_000;
+
+/** Base penalty for each repeat beyond the 2nd in a session (scaled by excess count). */
+export const SESSION_REPEAT_PENALTY_3RD_PLUS = 1_000_000;
+
const GENERATIONS = 4;
const ROUND_LOOKAHEAD = 3;
-const ROUND_ATTEMPTS = 30;
+const ROUND_ATTEMPTS = 60;
/**
* Populate default player scores for each person.
@@ -123,6 +133,19 @@ const getDefaultHeuristics = (
};
};
+const isFixedPartnership = (
+ a: PlayerId,
+ b: PlayerId,
+ fixedPairs: Team[]
+): boolean =>
+ fixedPairs.some(([x, y]) => (x === a && y === b) || (x === b && y === a));
+
+export const getSessionRepeatPenalty = (priorCount: number): number => {
+ if (priorCount <= 0) return 0;
+ if (priorCount === 1) return SESSION_REPEAT_PENALTY_2ND;
+ return SESSION_REPEAT_PENALTY_3RD_PLUS * (priorCount - 1);
+};
+
/**
* How much do I want a particular partner?
*
@@ -131,9 +154,9 @@ const getDefaultHeuristics = (
const getPartnerScore = (
player: PlayerId,
heuristics: PlayerHeuristicsDictionary,
- partner: PlayerId
+ partner: PlayerId,
+ fixedPairs: Team[] = []
) => {
- const {} = heuristics[player].roundsSincePlayedAgainst;
const { min: minSinceWith, [partner]: roundsSinceWith } =
heuristics[player].roundsSincePlayedWith;
const { min: minPlayedCount, [partner]: playedWithCount } =
@@ -149,7 +172,12 @@ const getPartnerScore = (
const backToBackPenalty =
roundsSinceWith === 1 ? BACK_TO_BACK_MATCHUP_PENALTY : 0;
- return playedWithScore - backToBackPenalty;
+ const priorPartnerCount = heuristics[player].playedWithCount[partner] ?? 0;
+ const sessionRepeatPenalty = isFixedPartnership(player, partner, fixedPairs)
+ ? 0
+ : getSessionRepeatPenalty(priorPartnerCount);
+
+ return playedWithScore - backToBackPenalty - sessionRepeatPenalty;
};
const getMatchIdentifier = (match: Match): MatchIdentifier => {
@@ -162,6 +190,31 @@ const getMatchIdentifier = (match: Match): MatchIdentifier => {
const getPartnerPairIdentifier = (team: Team): PartnerPairIdentifier =>
team.slice().sort().join(" ");
+const getOpponentPairIdentifier = (
+ a: PlayerId,
+ b: PlayerId
+): OpponentPairIdentifier => [a, b].sort().join(" ");
+
+const getOpponentPairCounts = (
+ rounds: Round[],
+ previousCounts?: OpponentPairCounts
+): OpponentPairCounts => {
+ const result: OpponentPairCounts = previousCounts
+ ? JSON.parse(JSON.stringify(previousCounts))
+ : {};
+ rounds.forEach((round) => {
+ round.matches.forEach(([teamA, teamB]) => {
+ teamA.forEach((playerA) => {
+ teamB.forEach((playerB) => {
+ const pairId = getOpponentPairIdentifier(playerA, playerB);
+ result[pairId] = (result[pairId] || 0) + 1;
+ });
+ });
+ });
+ });
+ return result;
+};
+
const getPartnerPairCounts = (
rounds: Round[],
previousCounts?: PartnerPairCounts
@@ -241,8 +294,13 @@ const getOpponentScore = (
roundsSinceAgainst - minSinceAgainst,
roundsSinceWith - minSinceWith
);
+ const priorOpponentCount = heuristics[player].playedAgainstCount[target] ?? 0;
+ const sessionRepeatPenalty = getSessionRepeatPenalty(priorOpponentCount);
// Square result to strongly favor high numbers.
- return Math.pow(netRoundsSinceSeen, 2) * frequencyReductionMultiplier;
+ return (
+ Math.pow(netRoundsSinceSeen, 2) * frequencyReductionMultiplier -
+ sessionRepeatPenalty
+ );
};
// Strongly discourage repeated matchups (remove duplicates where teams and players are the same).
@@ -427,12 +485,13 @@ const getHeuristics = (
*/
export const getPartnerPreferences = (
players: PlayerId[],
- heuristics: PlayerHeuristicsDictionary
+ heuristics: PlayerHeuristicsDictionary,
+ fixedPairs: Team[] = []
) => {
return players.reduce((result: Preferences, player) => {
result[player] = players.reduce((acc: Record, partner) => {
if (player === partner) return acc;
- acc[partner] = getPartnerScore(player, heuristics, partner);
+ acc[partner] = getPartnerScore(player, heuristics, partner, fixedPairs);
return acc;
}, {});
return result;
@@ -797,6 +856,7 @@ async function getNextRound(
): Promise<[Round, { bestTeamScore: number; bestMatchesScore: number }]> {
const [uniqueMatchCounts] = getUniqueMatchCounts(rounds);
const partnerPairCounts = getPartnerPairCounts(rounds);
+ const opponentPairCounts = getOpponentPairCounts(rounds);
let bestTeamScore = Infinity;
let bestTeams: { teams: Team[]; sitOuts: PlayerId[] } = {
@@ -840,7 +900,8 @@ async function getNextRound(
if (unpairedPlayers.length >= 2) {
const partnerPreferences: Preferences = getPartnerPreferences(
unpairedPlayers,
- heuristics
+ heuristics,
+ fixedPairs
);
const partnerMaker = new PairMaker(partnerPreferences);
partnerMaker.solve();
@@ -869,9 +930,11 @@ async function getNextRound(
bPlayedWith[a] === bPlayedWith.max && bPlayedWith[a] !== bPlayedWith.min
? 1
: 0;
- const repeatedPartnerCount =
- partnerPairCounts[getPartnerPairIdentifier([a, b])] || 0;
- const partnerPairPenalty = Math.pow(repeatedPartnerCount, 2);
+ const pairId = getPartnerPairIdentifier([a, b]);
+ const repeatedPartnerCount = partnerPairCounts[pairId] || 0;
+ const partnerPairPenalty = isFixedPartnership(a, b, fixedPairs)
+ ? 0
+ : getSessionRepeatPenalty(repeatedPartnerCount);
return result + aScore + bScore + partnerPairPenalty;
}, 0);
@@ -911,18 +974,30 @@ async function getNextRound(
[{ matches, sitOuts: bestTeams.sitOuts }],
uniqueMatchCounts
);
+ let opponentRepeatPenalty = 0;
+ matches.forEach(([teamA, teamB]) => {
+ teamA.forEach((playerA) => {
+ teamB.forEach((playerB) => {
+ const priorCount =
+ opponentPairCounts[getOpponentPairIdentifier(playerA, playerB)] || 0;
+ opponentRepeatPenalty += getSessionRepeatPenalty(priorCount);
+ });
+ });
+ });
+
const averageScore =
Math.pow(newDuplicates + 1, 2) *
- players.reduce((score, player) => {
- const { roundsSincePlayedAgainst } = newHeuristics[player];
- const playerScore = Math.sqrt(
- players.reduce((sum, opponent) => {
- if (opponent === player) return sum;
- return sum + Math.pow(roundsSincePlayedAgainst[opponent], 2);
- }, 0)
- );
- return score + playerScore / players.length;
- }, 0);
+ players.reduce((score, player) => {
+ const { roundsSincePlayedAgainst } = newHeuristics[player];
+ const playerScore = Math.sqrt(
+ players.reduce((sum, opponent) => {
+ if (opponent === player) return sum;
+ return sum + Math.pow(roundsSincePlayedAgainst[opponent], 2);
+ }, 0)
+ );
+ return score + playerScore / players.length;
+ }, 0) +
+ opponentRepeatPenalty;
const backToBackOpponents = countBackToBackOpponentRepeats(
{ matches, sitOuts: bestTeams.sitOuts },
@@ -953,6 +1028,90 @@ async function getNextRound(
];
}
+const countSessionRepeatsInRound = (
+ round: Round,
+ priorRounds: Round[],
+ fixedPairs: Team[] = []
+): { partnerRepeats: number; opponentRepeats: number } => {
+ const partnerPairCounts = getPartnerPairCounts(priorRounds);
+ const opponentPairCounts = getOpponentPairCounts(priorRounds);
+ let partnerRepeats = 0;
+ let opponentRepeats = 0;
+
+ round.matches.forEach((match) => {
+ match.forEach((team) => {
+ const pairId = getPartnerPairIdentifier(team);
+ if (
+ (partnerPairCounts[pairId] || 0) > 0 &&
+ !isFixedPartnership(team[0], team[1], fixedPairs)
+ ) {
+ partnerRepeats += 1;
+ }
+ });
+ const [teamA, teamB] = match;
+ teamA.forEach((playerA) => {
+ teamB.forEach((playerB) => {
+ if (
+ (opponentPairCounts[getOpponentPairIdentifier(playerA, playerB)] ||
+ 0) > 0
+ ) {
+ opponentRepeats += 1;
+ }
+ });
+ });
+ });
+
+ return { partnerRepeats, opponentRepeats };
+};
+
+export const getMaxSessionPairingCount = (
+ round: Round,
+ priorRounds: Round[],
+ fixedPairs: Team[] = []
+): number => {
+ const partnerPairCounts = getPartnerPairCounts(priorRounds);
+ const opponentPairCounts = getOpponentPairCounts(priorRounds);
+ let maxCount = 0;
+
+ round.matches.forEach((match) => {
+ match.forEach((team) => {
+ if (!isFixedPartnership(team[0], team[1], fixedPairs)) {
+ const pairId = getPartnerPairIdentifier(team);
+ maxCount = Math.max(maxCount, (partnerPairCounts[pairId] || 0) + 1);
+ }
+ });
+ const [teamA, teamB] = match;
+ teamA.forEach((playerA) => {
+ teamB.forEach((playerB) => {
+ const pairId = getOpponentPairIdentifier(playerA, playerB);
+ maxCount = Math.max(maxCount, (opponentPairCounts[pairId] || 0) + 1);
+ });
+ });
+ });
+
+ return maxCount;
+};
+
+export const analyzeSessionRepeats = (
+ round: Round,
+ priorRounds: Round[],
+ fixedPairs: Team[] = []
+): { hasRepeats: boolean; repeatNote?: string } => {
+ const { partnerRepeats, opponentRepeats } = countSessionRepeatsInRound(
+ round,
+ priorRounds,
+ fixedPairs
+ );
+ if (partnerRepeats === 0 && opponentRepeats === 0) {
+ return { hasRepeats: false };
+ }
+ return {
+ hasRepeats: true,
+ repeatNote:
+ "Some players are meeting again this round — unavoidable with this group size, fixed pairs, or late joiners.",
+ };
+};
+
const countBackToBackOpponentRepeats = (
round: Round,
heuristics: PlayerHeuristicsDictionary
@@ -983,12 +1142,16 @@ async function getNextBestRound(
const [matchCounts] = getUniqueMatchCounts(rounds);
let bestRoundScore: {
backToBackOpponents: number;
+ sessionRepeats: number;
+ maxPairingCount: number;
opponentScore: number;
partnerScore: number;
duplicates: number;
variance: number;
} = {
backToBackOpponents: Infinity,
+ sessionRepeats: Infinity,
+ maxPairingCount: Infinity,
opponentScore: Infinity,
partnerScore: Infinity,
duplicates: Infinity,
@@ -1000,6 +1163,8 @@ async function getNextBestRound(
let newHeuristics = heuristics;
let newRounds: Round[] = [];
let backToBackOpponents = Infinity;
+ let sessionRepeats = Infinity;
+ let maxPairingCount = Infinity;
let partnerScore = 0;
let opponentScore = Infinity;
let duplicates = 0;
@@ -1036,6 +1201,17 @@ async function getNextBestRound(
candidateRound,
heuristics
);
+ const repeats = countSessionRepeatsInRound(
+ candidateRound,
+ rounds,
+ fixedPairs
+ );
+ sessionRepeats = repeats.partnerRepeats + repeats.opponentRepeats;
+ maxPairingCount = getMaxSessionPairingCount(
+ candidateRound,
+ rounds,
+ fixedPairs
+ );
}
// We care more about the short term team score and duplicates.
partnerScore +=
@@ -1054,12 +1230,16 @@ async function getNextBestRound(
);
variance = getVariance(partnerCountValues);
+ if (bestRoundScore.sessionRepeats < sessionRepeats) continue;
+ if (bestRoundScore.maxPairingCount < maxPairingCount) continue;
if (bestRoundScore.backToBackOpponents < backToBackOpponents) continue;
if (bestRoundScore.duplicates < duplicates) continue;
if (bestRoundScore.partnerScore < partnerScore) continue;
if (bestRoundScore.opponentScore < opponentScore) continue;
// Variance fairness is the final tiebreaker after matchup quality.
if (
+ sessionRepeats < bestRoundScore.sessionRepeats ||
+ maxPairingCount < bestRoundScore.maxPairingCount ||
backToBackOpponents < bestRoundScore.backToBackOpponents ||
duplicates < bestRoundScore.duplicates ||
partnerScore < bestRoundScore.partnerScore ||
@@ -1068,6 +1248,8 @@ async function getNextBestRound(
) {
bestRoundScore = {
backToBackOpponents,
+ sessionRepeats,
+ maxPairingCount,
partnerScore,
opponentScore,
duplicates,
@@ -1086,15 +1268,26 @@ async function getNextBestRound(
heuristics,
fixedPairs
);
- return fallbackRound;
+ return attachRepeatNote(fallbackRound, rounds, fixedPairs);
}
- return selectedRound;
+ return attachRepeatNote(selectedRound, rounds, fixedPairs);
}
+const attachRepeatNote = (
+ round: Round,
+ priorRounds: Round[],
+ fixedPairs: Team[]
+): Round => {
+ const { repeatNote } = analyzeSessionRepeats(round, priorRounds, fixedPairs);
+ return repeatNote ? { ...round, repeatNote } : round;
+};
+
export {
getHeuristics,
getNextRound,
getNextBestRound,
+ getOpponentPairCounts,
+ getOpponentPairIdentifier,
getOpponentScore as opponentScore,
getPartnerPairCounts,
getPartnerPairIdentifier,
diff --git a/src/matching/worker.ts b/src/matching/worker.ts
index d257370..66a832a 100644
--- a/src/matching/worker.ts
+++ b/src/matching/worker.ts
@@ -1,14 +1,22 @@
import { getNextBestRound, PlayerId, Round, Team } from "./heuristics";
-addEventListener(
- "message",
- async (
- event: MessageEvent<
- [Round[], PlayerId[], number, PlayerId[], Team[]?]
- >
- ) => {
- const [rounds, players, courts, volunteerSitouts, fixedPairs = []] =
- event.data;
+type WorkerJob = [
+ Round[],
+ PlayerId[],
+ number,
+ PlayerId[],
+ Team[]?
+];
+
+let processing = false;
+const pending: WorkerJob[] = [];
+
+async function drainQueue(): Promise {
+ if (processing || pending.length === 0) return;
+ processing = true;
+ const [rounds, players, courts, volunteerSitouts, fixedPairs = []] =
+ pending.shift()!;
+ try {
const round = await getNextBestRound(
rounds,
players,
@@ -17,5 +25,13 @@ addEventListener(
fixedPairs
);
postMessage(round);
+ } finally {
+ processing = false;
+ void drainQueue();
}
-);
+}
+
+addEventListener("message", (event: MessageEvent) => {
+ pending.push(event.data);
+ void drainQueue();
+});
diff --git a/src/useShuffler.tsx b/src/useShuffler.tsx
index 3e5d65e..5887a56 100644
--- a/src/useShuffler.tsx
+++ b/src/useShuffler.tsx
@@ -107,6 +107,135 @@ const ShufflerWorkerContext = React.createContext(
undefined
);
+type PregenCache = {
+ key: string | null;
+ round: Round | null;
+ promise: Promise | null;
+ volunteerSitouts: PlayerId[];
+ generationId: number;
+};
+
+const emptyPregenCache = (): PregenCache => ({
+ key: null,
+ round: null,
+ promise: null,
+ volunteerSitouts: [],
+ generationId: 0,
+});
+
+const ShufflerPregenContext = React.createContext<
+ React.MutableRefObject | undefined
+>(undefined);
+
+function buildPregenKey(
+ rounds: Round[],
+ players: PlayerId[],
+ courts: number,
+ fixedPairs: Team[]
+): string {
+ return JSON.stringify({
+ players,
+ courts,
+ fixedPairs,
+ rounds: rounds.map(({ matches, sitOuts }) => ({ matches, sitOuts })),
+ });
+}
+
+const sitoutsEqual = (a: PlayerId[], b: PlayerId[]): boolean =>
+ a.length === b.length && a.every((id, index) => id === b[index]);
+
+function invalidatePregen(
+ pregen: React.MutableRefObject
+): void {
+ pregen.current.generationId += 1;
+ pregen.current.key = null;
+ pregen.current.round = null;
+ pregen.current.promise = null;
+ pregen.current.volunteerSitouts = [];
+}
+
+function startPregenerate(
+ pregen: React.MutableRefObject,
+ worker: Worker,
+ rounds: Round[],
+ players: PlayerId[],
+ courts: number,
+ fixedPairs: Team[],
+ volunteerSitouts: PlayerId[] = []
+): void {
+ const key = buildPregenKey(rounds, players, courts, fixedPairs);
+ const genId = pregen.current.generationId;
+
+ if (
+ pregen.current.key === key &&
+ sitoutsEqual(pregen.current.volunteerSitouts, volunteerSitouts) &&
+ (pregen.current.round !== null || pregen.current.promise !== null)
+ ) {
+ return;
+ }
+
+ pregen.current.key = key;
+ pregen.current.volunteerSitouts = [...volunteerSitouts];
+ pregen.current.round = null;
+
+ pregen.current.promise = generateRound(
+ worker,
+ rounds,
+ players,
+ courts,
+ volunteerSitouts,
+ fixedPairs
+ ).then((round) => {
+ if (
+ genId === pregen.current.generationId &&
+ pregen.current.key === key
+ ) {
+ pregen.current.round = round;
+ }
+ return round;
+ });
+}
+
+async function consumePregen(
+ pregen: React.MutableRefObject,
+ rounds: Round[],
+ players: PlayerId[],
+ courts: number,
+ fixedPairs: Team[],
+ volunteerSitouts: PlayerId[]
+): Promise {
+ const key = buildPregenKey(rounds, players, courts, fixedPairs);
+ if (
+ pregen.current.key !== key ||
+ !sitoutsEqual(pregen.current.volunteerSitouts, volunteerSitouts)
+ ) {
+ return null;
+ }
+
+ if (pregen.current.round) {
+ const round = pregen.current.round;
+ invalidatePregen(pregen);
+ return round;
+ }
+
+ if (pregen.current.promise) {
+ try {
+ const round = await pregen.current.promise;
+ if (
+ pregen.current.key === key &&
+ sitoutsEqual(pregen.current.volunteerSitouts, volunteerSitouts)
+ ) {
+ invalidatePregen(pregen);
+ return round;
+ }
+ } catch {
+ invalidatePregen(pregen);
+ }
+ }
+
+ return null;
+}
+
function createPlayers(names: string[]) {
return names.map((name) => {
return { name, id: uuidv4() };
@@ -307,21 +436,47 @@ async function newRound(
dispatch: Dispatch,
state: State,
worker: Worker | null,
- payload: NewRoundOptions
+ payload: NewRoundOptions,
+ pregen: React.MutableRefObject
) {
if (!worker) return;
- if (state.generating) return;
- dispatch({ type: "start-generation", payload });
+ if (state.generating || roundGenerationInFlight) return;
+ roundGenerationInFlight = true;
const rounds = payload.regenerate ? state.rounds.slice(0, -1) : state.rounds;
-
+ const players = payload.players ?? state.players;
+ const fixedPairs = payload.fixedPairs ?? state.fixedPairs;
+
+ const precomputed = await consumePregen(
+ pregen,
+ rounds,
+ players,
+ state.courts,
+ fixedPairs,
+ payload.volunteerSitouts
+ );
try {
+ if (precomputed?.matches) {
+ dispatch({
+ type: "new-round",
+ payload: {
+ round: precomputed,
+ volunteerSitouts: payload.volunteerSitouts,
+ regenerate: payload.regenerate ?? false,
+ },
+ });
+ return;
+ }
+
+ invalidatePregen(pregen);
+ dispatch({ type: "start-generation", payload });
+
const nextRound = await generateRound(
worker,
rounds,
- state.players,
+ players,
state.courts,
payload.volunteerSitouts,
- state.fixedPairs
+ fixedPairs
);
if (!nextRound?.matches) {
throw new Error("Round generation returned an empty round");
@@ -336,6 +491,8 @@ async function newRound(
});
} catch (error) {
dispatch({ type: "new-round-fail", payload: { error: error as Error } });
+ } finally {
+ roundGenerationInFlight = false;
}
}
@@ -343,10 +500,12 @@ async function newGame(
dispatch: Dispatch,
state: State,
worker: Worker | null,
- payload: NewGameOptions
+ payload: NewGameOptions,
+ pregen: React.MutableRefObject
) {
if (!worker) return;
if (state.generating) return;
+ invalidatePregen(pregen);
const { courts, names, courtNames, fixedPairs: namePairs = [] } = payload;
const players = createPlayers(names).sort((a, b) =>
a.name.localeCompare(b.name)
@@ -382,7 +541,11 @@ async function newGame(
}
}
-async function generateRound(
+// One in-flight worker request at a time (pregen + user clicks share the worker).
+let workerTaskTail: Promise = Promise.resolve();
+let roundGenerationInFlight = false;
+
+function generateRoundUncached(
worker: Worker,
rounds: Round[],
players: PlayerId[],
@@ -392,26 +555,55 @@ async function generateRound(
): Promise {
return new Promise((resolve, reject) => {
const messageCallback = (event: MessageEvent) => {
+ cleanup();
resolve(event.data);
- worker.removeEventListener("message", messageCallback);
};
- worker.addEventListener("message", messageCallback);
-
const errorCallback = (error: ErrorEvent) => {
+ cleanup();
reject(error);
+ };
+ const cleanup = () => {
+ worker.removeEventListener("message", messageCallback);
worker.removeEventListener("error", errorCallback);
};
+ worker.addEventListener("message", messageCallback);
worker.addEventListener("error", errorCallback);
worker.postMessage([rounds, players, courts, volunteerSitouts, fixedPairs]);
});
}
+async function generateRound(
+ worker: Worker,
+ rounds: Round[],
+ players: PlayerId[],
+ courts: number,
+ volunteerSitouts: PlayerId[],
+ fixedPairs: Team[] = []
+): Promise {
+ const task = () =>
+ generateRoundUncached(
+ worker,
+ rounds,
+ players,
+ courts,
+ volunteerSitouts,
+ fixedPairs
+ );
+ const result = workerTaskTail.then(task, task);
+ workerTaskTail = result.then(
+ () => undefined,
+ () => undefined
+ );
+ return result;
+}
+
async function editCourts(
dispatch: Dispatch,
state: State,
worker: Worker | null,
- payload: EditCourts
+ payload: EditCourts,
+ pregen: React.MutableRefObject
) {
if (!worker) return;
if (state.generating) return;
@@ -420,6 +612,29 @@ async function editCourts(
? state.volunteerSitoutsByRound.slice(-1)[0]
: [];
const rounds = regenerate ? state.rounds.slice(0, -1) : state.rounds;
+
+ const precomputed = await consumePregen(
+ pregen,
+ rounds,
+ state.players,
+ courts,
+ state.fixedPairs,
+ volunteerSitouts
+ );
+ if (precomputed?.matches) {
+ dispatch({
+ type: "new-round",
+ payload: {
+ round: precomputed,
+ volunteerSitouts,
+ courts,
+ regenerate,
+ },
+ });
+ return;
+ }
+
+ invalidatePregen(pregen);
dispatch({
type: "start-generation",
payload: { volunteerSitouts, regenerate },
@@ -470,7 +685,8 @@ async function editPlayers(
dispatch: Dispatch,
state: State,
worker: Worker | null,
- payload: EditPlayers
+ payload: EditPlayers,
+ pregen: React.MutableRefObject
) {
if (!worker) return;
if (state.generating) return;
@@ -484,6 +700,27 @@ async function editPlayers(
const playersById = getPlayersById(state.playersById, newPlayers);
const sanitizedPairs = sanitizeFixedPairs(fixedPairs, playerIds);
+ const precomputed = await consumePregen(
+ pregen,
+ rounds,
+ playerIds,
+ state.courts,
+ sanitizedPairs,
+ volunteerSitouts
+ );
+ if (precomputed?.matches) {
+ dispatch({
+ type: "new-round",
+ payload: {
+ round: precomputed,
+ volunteerSitouts,
+ regenerate,
+ },
+ });
+ return;
+ }
+
+ invalidatePregen(pregen);
dispatch({
type: "start-generation",
payload: {
@@ -516,26 +753,68 @@ async function editPlayers(
}
}
+function useShufflerPregen(): React.MutableRefObject {
+ const pregen = React.useContext(ShufflerPregenContext);
+ if (pregen === undefined) {
+ throw new Error("useShufflerPregen must be used within a ShufflerProvider");
+ }
+ return pregen;
+}
+
+function usePregenerateNextRound(): void {
+ const pregen = useShufflerPregen();
+ const state = useShufflerState();
+ const worker = useShufflerWorker();
+
+ React.useEffect(() => {
+ if (!worker || state.generating || !state.cacheLoaded) return;
+ if (!state.rounds.length) return;
+
+ startPregenerate(
+ pregen,
+ worker,
+ state.rounds,
+ state.players,
+ state.courts,
+ state.fixedPairs,
+ []
+ );
+ }, [
+ pregen,
+ worker,
+ state.generating,
+ state.cacheLoaded,
+ state.players,
+ state.courts,
+ state.fixedPairs,
+ state.rounds,
+ ]);
+}
+
function ShufflerProvider({ children }: ShufflerProviderProps) {
const [state, dispatch] = React.useReducer(shufflerReducer, defaultState);
const [worker, setWorker] = React.useState(null);
+ const pregenRef = React.useRef(emptyPregenCache());
React.useEffect(() => {
const worker = new Worker(new URL("./matching/worker.ts", import.meta.url));
setWorker(worker);
return () => {
worker.terminate();
+ invalidatePregen(pregenRef);
setWorker(null);
};
}, []);
return (
-
-
-
- {children}
-
-
-
+
+
+
+
+ {children}
+
+
+
+
);
}
@@ -586,10 +865,15 @@ export {
useShufflerState,
useShufflerDispatch,
useShufflerWorker,
+ useShufflerPregen,
+ usePregenerateNextRound,
useLoadState,
newRound,
newGame,
editCourts,
editPlayers,
renamePlayer,
+ invalidatePregen,
+ consumePregen,
+ startPregenerate,
};
diff --git a/test/heuristics.spec.tsx b/test/heuristics.spec.tsx
index b8e9316..feffd41 100644
--- a/test/heuristics.spec.tsx
+++ b/test/heuristics.spec.tsx
@@ -1,13 +1,18 @@
import {
+ analyzeSessionRepeats,
getHeuristics,
+ getMaxSessionPairingCount,
getNextBestRound,
getNextRound,
+ getOpponentPairIdentifier,
+ getPartnerPairCounts,
getPartnerPairIdentifier,
getPartnerPreferences,
INFINITY,
PlayerHeuristicsDictionary,
PlayerId,
Round,
+ Team,
} from "../src/matching/heuristics";
import { getVariance } from "../src/matching/variance";
import { mean, min, max } from "lodash";
@@ -498,6 +503,162 @@ const assertValidRound = (
});
};
+const maxPartnerRepeatFreeRounds = (
+ playerCount: number,
+ courts: number
+): number => {
+ const capacity = Math.max(courts, 1) * 4;
+ const playable = Math.min(playerCount, capacity);
+ const playableRounded = playable - (playable % 4);
+ if (playableRounded < 4) return 0;
+ return playableRounded - 1;
+};
+
+const countMaxPairingExcess = (
+ rounds: Round[],
+ players: PlayerId[],
+ stat: "playedWithCount" | "playedAgainstCount"
+): number => {
+ const heuristics = getHeuristics(rounds, players);
+ return players.reduce((sum, player) => {
+ return sum + Math.max(0, heuristics[player][stat].max - 1);
+ }, 0);
+};
+
+describe("session-wide no-repeat (#30)", () => {
+ const sessionConfigs = [
+ { playerCount: 8, courts: 2, seeds: [1, 7, 42] },
+ { playerCount: 12, courts: 3, seeds: [2, 11, 99] },
+ { playerCount: 16, courts: 4, seeds: [3, 21, 123] },
+ { playerCount: 20, courts: 5, seeds: [4, 31, 2024] },
+ { playerCount: 28, courts: 6, seeds: [5, 41, 777] },
+ ];
+
+ test.each(sessionConfigs)(
+ "$playerCount players / $courts courts — no partner repeats within full partner cycle",
+ async ({ playerCount, courts, seeds }) => {
+ const roundCount = maxPartnerRepeatFreeRounds(playerCount, courts);
+ expect(roundCount).toBeGreaterThan(0);
+
+ for (const seed of seeds) {
+ const randomSpy = mockSeededRandom(seed);
+ try {
+ const players = sampleNames
+ .concat(
+ Array.from({ length: playerCount - sampleNames.length }, (_, i) =>
+ `P${i}`
+ )
+ )
+ .slice(0, playerCount);
+ const rounds: Round[] = [];
+ for (let i = 0; i < roundCount; i++) {
+ rounds.push(await getNextBestRound(rounds, players, courts));
+ }
+
+ expect(
+ countMaxPairingExcess(rounds, players, "playedWithCount")
+ ).toBe(0);
+ } finally {
+ randomSpy.mockRestore();
+ }
+ }
+ }
+ );
+
+ test.each(sessionConfigs)(
+ "$playerCount players / $courts courts — unavoidable repeats show repeatNote",
+ async ({ playerCount, courts, seeds }) => {
+ for (const seed of seeds) {
+ const randomSpy = mockSeededRandom(seed);
+ try {
+ const players = sampleNames
+ .concat(
+ Array.from({ length: playerCount - sampleNames.length }, (_, i) =>
+ `P${i}`
+ )
+ )
+ .slice(0, playerCount);
+ const rounds: Round[] = [];
+ for (let i = 0; i < 8; i++) {
+ rounds.push(await getNextBestRound(rounds, players, courts));
+ }
+
+ for (let i = 0; i < rounds.length; i++) {
+ const prior = rounds.slice(0, i);
+ const { hasRepeats } = analyzeSessionRepeats(
+ rounds[i],
+ prior,
+ []
+ );
+ if (hasRepeats) {
+ expect(rounds[i].repeatNote).toBeDefined();
+ }
+ }
+ } finally {
+ randomSpy.mockRestore();
+ }
+ }
+ }
+ );
+
+ test("fixed-pair partners exempt from partner repeat detection", () => {
+ const fixedPairs: Team[] = [["a", "b"]];
+ const prior: Round[] = [
+ {
+ matches: [[["a", "b"], ["c", "d"]]],
+ sitOuts: ["e", "f"],
+ },
+ ];
+ // a-b partner again (exempt); face e-f for the first time (no opponent repeats).
+ const round: Round = {
+ matches: [[["a", "b"], ["e", "f"]]],
+ sitOuts: ["c", "d"],
+ };
+ const { hasRepeats } = analyzeSessionRepeats(round, prior, fixedPairs);
+ expect(hasRepeats).toBe(false);
+ });
+
+ test("getMaxSessionPairingCount tracks worst pairing depth", () => {
+ const prior: Round[] = [
+ {
+ matches: [[["a", "b"], ["c", "d"]]],
+ sitOuts: [],
+ },
+ {
+ matches: [[["a", "b"], ["c", "d"]]],
+ sitOuts: [],
+ },
+ ];
+ const round: Round = {
+ matches: [[["a", "b"], ["c", "d"]]],
+ sitOuts: [],
+ };
+ expect(getMaxSessionPairingCount(round, prior)).toBe(3);
+ expect(
+ getOpponentPairIdentifier(round.matches[0][0][0], round.matches[0][1][0])
+ ).toBe("a c");
+ });
+
+ test("degradation avoids third-plus session pairings when possible", async () => {
+ const randomSpy = mockSeededRandom(42);
+ try {
+ const players = sampleNames.slice(0, 8);
+ const rounds = await generateRounds(players, 2, 20, true);
+ let thirdPlus = 0;
+ for (let i = 0; i < rounds.length; i++) {
+ const maxCount = getMaxSessionPairingCount(
+ rounds[i],
+ rounds.slice(0, i)
+ );
+ if (maxCount >= 3) thirdPlus += 1;
+ }
+ expect(thirdPlus).toBe(0);
+ } finally {
+ randomSpy.mockRestore();
+ }
+ });
+});
+
describe("fixed pairs", () => {
test("fixed pair always on same team across 10 generated rounds", async () => {
const players = ["a", "b", "c", "d", "e", "f"];