From 7adede84552f8e3692f81cd9bd0841dd64091e98 Mon Sep 17 00:00:00 2001 From: javaisbetterthanpython Date: Tue, 9 Jun 2026 01:31:10 -0400 Subject: [PATCH 1/3] [#30] Richer session mix + instant next round Enforce session-wide partner/opponent variety with graceful degradation, background pre-generation for instant next rounds, and repeat notes when unavoidable repeats occur. Co-authored-by: Cursor --- docs/ISSUE_QUEUE.md | 2 +- pages/new.tsx | 4 +- pages/rounds.tsx | 17 ++- src/matching/heuristics.ts | 239 +++++++++++++++++++++++++++--- src/useShuffler.tsx | 292 +++++++++++++++++++++++++++++++++++-- test/heuristics.spec.tsx | 161 ++++++++++++++++++++ 6 files changed, 672 insertions(+), 43 deletions(-) diff --git a/docs/ISSUE_QUEUE.md b/docs/ISSUE_QUEUE.md index 3849075..1f32241 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-progress | — | — | | 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/useShuffler.tsx b/src/useShuffler.tsx index 3e5d65e..28f870f 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 }); 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 + ); + if (precomputed?.matches) { + dispatch({ type: "start-generation", payload }); + dispatch({ + type: "new-round", + payload: { + round: precomputed, + volunteerSitouts: payload.volunteerSitouts, + regenerate: payload.regenerate ?? false, + }, + }); + return; + } + + invalidatePregen(pregen); + dispatch({ type: "start-generation", payload }); try { 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"); @@ -343,10 +498,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) @@ -411,7 +568,8 @@ 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 +578,33 @@ 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: "start-generation", + payload: { volunteerSitouts, regenerate }, + }); + dispatch({ + type: "new-round", + payload: { + round: precomputed, + volunteerSitouts, + courts, + regenerate, + }, + }); + return; + } + + invalidatePregen(pregen); dispatch({ type: "start-generation", payload: { volunteerSitouts, regenerate }, @@ -470,7 +655,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 +670,37 @@ 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: "start-generation", + payload: { + volunteerSitouts, + regenerate, + playersById, + players: playerIds, + fixedPairs: sanitizedPairs, + }, + }); + dispatch({ + type: "new-round", + payload: { + round: precomputed, + volunteerSitouts, + regenerate, + }, + }); + return; + } + + invalidatePregen(pregen); dispatch({ type: "start-generation", payload: { @@ -516,26 +733,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 +845,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"]; From 7714806b714de00269da59c3542c9b6e354ea36e Mon Sep 17 00:00:00 2001 From: javaisbetterthanpython Date: Tue, 9 Jun 2026 01:31:34 -0400 Subject: [PATCH 2/3] Update issue queue: #30 in review (PR #40). Co-authored-by: Cursor --- docs/ISSUE_QUEUE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ISSUE_QUEUE.md b/docs/ISSUE_QUEUE.md index 1f32241..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 | in-progress | — | — | +| 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 | — | — | From 14cc0e2bfabb9cb0a582106486343322fa329f93 Mon Sep 17 00:00:00 2001 From: javaisbetterthanpython Date: Tue, 9 Jun 2026 08:44:20 -0400 Subject: [PATCH 3/3] [#30] Fix worker race that hung round generation Serialize Web Worker requests so background pre-gen and Start round cannot steal each other's responses, and skip the generating flash when serving a cached round. Co-authored-by: Cursor --- src/matching/worker.ts | 36 ++++++++++++----- src/useShuffler.tsx | 88 ++++++++++++++++++++++++++---------------- 2 files changed, 80 insertions(+), 44 deletions(-) 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 28f870f..5887a56 100644 --- a/src/useShuffler.tsx +++ b/src/useShuffler.tsx @@ -440,7 +440,8 @@ async function newRound( pregen: React.MutableRefObject ) { if (!worker) return; - if (state.generating) return; + 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; @@ -453,23 +454,22 @@ async function newRound( fixedPairs, payload.volunteerSitouts ); - if (precomputed?.matches) { - dispatch({ type: "start-generation", payload }); - dispatch({ - type: "new-round", - payload: { - round: precomputed, - volunteerSitouts: payload.volunteerSitouts, - regenerate: payload.regenerate ?? false, - }, - }); - return; - } + 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 }); + invalidatePregen(pregen); + dispatch({ type: "start-generation", payload }); - try { const nextRound = await generateRound( worker, rounds, @@ -491,6 +491,8 @@ async function newRound( }); } catch (error) { dispatch({ type: "new-round-fail", payload: { error: error as Error } }); + } finally { + roundGenerationInFlight = false; } } @@ -539,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[], @@ -549,21 +555,49 @@ 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, @@ -588,10 +622,6 @@ async function editCourts( volunteerSitouts ); if (precomputed?.matches) { - dispatch({ - type: "start-generation", - payload: { volunteerSitouts, regenerate }, - }); dispatch({ type: "new-round", payload: { @@ -679,16 +709,6 @@ async function editPlayers( volunteerSitouts ); if (precomputed?.matches) { - dispatch({ - type: "start-generation", - payload: { - volunteerSitouts, - regenerate, - playersById, - players: playerIds, - fixedPairs: sanitizedPairs, - }, - }); dispatch({ type: "new-round", payload: {