diff --git a/convex/_fixtures/createMockTournament.ts b/convex/_fixtures/createMockTournament.ts index 3fe91512..a1e2fd4e 100644 --- a/convex/_fixtures/createMockTournament.ts +++ b/convex/_fixtures/createMockTournament.ts @@ -2,7 +2,7 @@ import { CurrencyCode, GameSystem, getGameSystem, - TournamentPairingMethod, + tournamentPairingConfig, } from '@ianpaschal/combat-command-game-systems/common'; import { Doc } from '../_generated/dataModel'; @@ -45,7 +45,7 @@ export const createMockTournament = ( currency: CurrencyCode.EUR, }, roundCount: 5, - pairingMethod: TournamentPairingMethod.Adjacent, + pairingConfig: tournamentPairingConfig.defaultValues, gameSystemConfig: gameSystemConfig.defaultValues, startsAt: Date.now() + (DAY_LENGTH_MS * 3), endsAt: Date.now() + (DAY_LENGTH_MS * 5), diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index eb578722..cfa31a8f 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -38,6 +38,7 @@ import type * as _model_common_matchResultDetails from "../_model/common/matchRe import type * as _model_common_rankingFactor from "../_model/common/rankingFactor.js"; import type * as _model_common_scoreAdjustment from "../_model/common/scoreAdjustment.js"; import type * as _model_common_themes from "../_model/common/themes.js"; +import type * as _model_common_tournamentPairingConfig from "../_model/common/tournamentPairingConfig.js"; import type * as _model_common_tournamentStatus from "../_model/common/tournamentStatus.js"; import type * as _model_common_types from "../_model/common/types.js"; import type * as _model_files_index from "../_model/files/index.js"; @@ -146,6 +147,7 @@ import type * as _model_tournamentOrganizers_queries_getTournamentOrganizersByUs import type * as _model_tournamentOrganizers_table from "../_model/tournamentOrganizers/table.js"; import type * as _model_tournamentOrganizers_types from "../_model/tournamentOrganizers/types.js"; import type * as _model_tournamentPairings__helpers_assignBye from "../_model/tournamentPairings/_helpers/assignBye.js"; +import type * as _model_tournamentPairings__helpers_assignTables from "../_model/tournamentPairings/_helpers/assignTables.js"; import type * as _model_tournamentPairings__helpers_checkIfPairingIsRepeat from "../_model/tournamentPairings/_helpers/checkIfPairingIsRepeat.js"; import type * as _model_tournamentPairings__helpers_checkIfPairingIsSameAlignment from "../_model/tournamentPairings/_helpers/checkIfPairingIsSameAlignment.js"; import type * as _model_tournamentPairings__helpers_deepenTournamentPairing from "../_model/tournamentPairings/_helpers/deepenTournamentPairing.js"; @@ -158,6 +160,8 @@ import type * as _model_tournamentPairings__helpers_sortByRank from "../_model/t import type * as _model_tournamentPairings__helpers_sortCompetitorPairs from "../_model/tournamentPairings/_helpers/sortCompetitorPairs.js"; import type * as _model_tournamentPairings__helpers_sortPairingsByTable from "../_model/tournamentPairings/_helpers/sortPairingsByTable.js"; import type * as _model_tournamentPairings__helpers_validateTournamentPairing from "../_model/tournamentPairings/_helpers/validateTournamentPairing.js"; +import type * as _model_tournamentPairings_actions_generateDraftTournamentPairings from "../_model/tournamentPairings/actions/generateDraftTournamentPairings.js"; +import type * as _model_tournamentPairings_actions_generateTableAssignments from "../_model/tournamentPairings/actions/generateTableAssignments.js"; import type * as _model_tournamentPairings_index from "../_model/tournamentPairings/index.js"; import type * as _model_tournamentPairings_mutations_createTournamentPairings from "../_model/tournamentPairings/mutations/createTournamentPairings.js"; import type * as _model_tournamentPairings_mutations_deleteTournamentPairings from "../_model/tournamentPairings/mutations/deleteTournamentPairings.js"; @@ -347,6 +351,7 @@ declare const fullApi: ApiFromModules<{ "_model/common/rankingFactor": typeof _model_common_rankingFactor; "_model/common/scoreAdjustment": typeof _model_common_scoreAdjustment; "_model/common/themes": typeof _model_common_themes; + "_model/common/tournamentPairingConfig": typeof _model_common_tournamentPairingConfig; "_model/common/tournamentStatus": typeof _model_common_tournamentStatus; "_model/common/types": typeof _model_common_types; "_model/files/index": typeof _model_files_index; @@ -455,6 +460,7 @@ declare const fullApi: ApiFromModules<{ "_model/tournamentOrganizers/table": typeof _model_tournamentOrganizers_table; "_model/tournamentOrganizers/types": typeof _model_tournamentOrganizers_types; "_model/tournamentPairings/_helpers/assignBye": typeof _model_tournamentPairings__helpers_assignBye; + "_model/tournamentPairings/_helpers/assignTables": typeof _model_tournamentPairings__helpers_assignTables; "_model/tournamentPairings/_helpers/checkIfPairingIsRepeat": typeof _model_tournamentPairings__helpers_checkIfPairingIsRepeat; "_model/tournamentPairings/_helpers/checkIfPairingIsSameAlignment": typeof _model_tournamentPairings__helpers_checkIfPairingIsSameAlignment; "_model/tournamentPairings/_helpers/deepenTournamentPairing": typeof _model_tournamentPairings__helpers_deepenTournamentPairing; @@ -467,6 +473,8 @@ declare const fullApi: ApiFromModules<{ "_model/tournamentPairings/_helpers/sortCompetitorPairs": typeof _model_tournamentPairings__helpers_sortCompetitorPairs; "_model/tournamentPairings/_helpers/sortPairingsByTable": typeof _model_tournamentPairings__helpers_sortPairingsByTable; "_model/tournamentPairings/_helpers/validateTournamentPairing": typeof _model_tournamentPairings__helpers_validateTournamentPairing; + "_model/tournamentPairings/actions/generateDraftTournamentPairings": typeof _model_tournamentPairings_actions_generateDraftTournamentPairings; + "_model/tournamentPairings/actions/generateTableAssignments": typeof _model_tournamentPairings_actions_generateTableAssignments; "_model/tournamentPairings/index": typeof _model_tournamentPairings_index; "_model/tournamentPairings/mutations/createTournamentPairings": typeof _model_tournamentPairings_mutations_createTournamentPairings; "_model/tournamentPairings/mutations/deleteTournamentPairings": typeof _model_tournamentPairings_mutations_deleteTournamentPairings; diff --git a/convex/_model/common/errors.ts b/convex/_model/common/errors.ts index d91f05c4..95baa9ee 100644 --- a/convex/_model/common/errors.ts +++ b/convex/_model/common/errors.ts @@ -55,6 +55,7 @@ export const errors = { TOURNAMENT_TIMER_ALREADY_RUNNING: 'Tournament timer is already running.', TOURNAMENT_TIMER_ALREADY_EXISTS: 'Tournament already has a timer for this round.', CANNOT_REMOVE_LAST_ORGANIZER_FROM_LEAGUE: 'Cannot remove the last organizer from league.', + NOT_ENOUGH_AVAILABLE_TABLES: 'There are more unassigned pairings than available tables!', // Missing docs FILE_NOT_FOUND: 'Could not find a file with that ID.', diff --git a/convex/_model/common/tournamentPairingConfig.ts b/convex/_model/common/tournamentPairingConfig.ts new file mode 100644 index 00000000..20730f1d --- /dev/null +++ b/convex/_model/common/tournamentPairingConfig.ts @@ -0,0 +1,4 @@ +import { tournamentPairingConfig as config } from '@ianpaschal/combat-command-game-systems/common'; +import { zodToConvex } from 'convex-helpers/server/zod'; + +export const tournamentPairingConfig = zodToConvex(config.schema); diff --git a/convex/_model/tournamentPairings/_helpers/assignTables.test.ts b/convex/_model/tournamentPairings/_helpers/assignTables.test.ts new file mode 100644 index 00000000..d74211e8 --- /dev/null +++ b/convex/_model/tournamentPairings/_helpers/assignTables.test.ts @@ -0,0 +1,345 @@ +import { + describe, + expect, + it, +} from 'vitest'; + +import { createMockTournamentCompetitors } from '../../../_fixtures/createMockTournamentCompetitor'; +import { Id } from '../../../_generated/dataModel'; +import { DeepTournamentCompetitor } from '../../tournamentCompetitors'; +import { TournamentDeep } from '../../tournaments'; +import { DraftTournamentPairing } from '..'; +import { assignTables } from './assignTables'; + +const createMockTournament = (competitorCount: number): TournamentDeep => ({ + competitorCount, +}) as TournamentDeep; + +const createMockData = ( + competitorCount: number, + playedTablesMap: Record = {}, +): { tournament: TournamentDeep; tournamentCompetitors: DeepTournamentCompetitor[] } => { + const tournamentCompetitors = createMockTournamentCompetitors(competitorCount); + + // Apply playedTables overrides + for (const [id, playedTables] of Object.entries(playedTablesMap)) { + const competitor = tournamentCompetitors.find((c) => c._id === id); + if (competitor) { + competitor.playedTables = playedTables; + } + } + + return { + tournament: createMockTournament(competitorCount), + tournamentCompetitors, + }; +}; + +describe('assignTables', () => { + it('assigns tables to basic pairings', () => { + const pairings: DraftTournamentPairing[] = [ + { + table: -1, + tournamentCompetitor0Id: 'player1' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'player2' as Id<'tournamentCompetitors'>, + }, + { + table: -1, + tournamentCompetitor0Id: 'player3' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'player4' as Id<'tournamentCompetitors'>, + }, + ]; + + const data = { + tournament: createMockTournament(8), // 8 competitors = 4 tables + tournamentCompetitors: [] as DeepTournamentCompetitor[], + }; + + const result = assignTables(pairings, data); + + expect(result).toHaveLength(2); + + // Tables should be assigned (could be any available tables due to randomization) + expect(result[0].table).toBeGreaterThanOrEqual(0); + expect(result[0].table).toBeLessThan(4); + expect(result[1].table).toBeGreaterThanOrEqual(0); + expect(result[1].table).toBeLessThan(4); + + // Should not have the same table: + expect(result[0].table).not.toBe(result[1].table); + }); + + it('handles byes correctly', () => { + const pairings: DraftTournamentPairing[] = [ + { + table: -1, + tournamentCompetitor0Id: 'player1' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: null, + }, + ]; + + const data = { + tournament: createMockTournament(8), + tournamentCompetitors: [] as DeepTournamentCompetitor[], + }; + + const result = assignTables(pairings, data); + + expect(result).toHaveLength(1); + expect(result[0].table).toBe(null); + expect(result[0].tournamentCompetitor0Id).toBe('player1'); + expect(result[0].tournamentCompetitor1Id).toBe(null); + }); + + it('preserves pre-assigned tables', () => { + const pairings: DraftTournamentPairing[] = [ + { + table: 1, + tournamentCompetitor0Id: 'player1' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'player2' as Id<'tournamentCompetitors'>, + }, + { + table: -1, + tournamentCompetitor0Id: 'player3' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'player4' as Id<'tournamentCompetitors'>, + }, + ]; + + const data = { + tournament: createMockTournament(4), // 4 competitors = 2 tables + tournamentCompetitors: [] as DeepTournamentCompetitor[], + }; + + const result = assignTables(pairings, data); + + const preAssignedPairing = result.find( + (p) => p.tournamentCompetitor0Id === 'player1', + ); + expect(preAssignedPairing?.table).toBe(1); + + const newPairing = result.find( + (p) => p.tournamentCompetitor0Id === 'player3', + ); + expect(newPairing?.table).not.toBe(1); // Should get a different table + expect(newPairing?.table).toBeGreaterThanOrEqual(0); + }); + + it('sorts byes to the end', () => { + const pairings: DraftTournamentPairing[] = [ + { + table: -1, + tournamentCompetitor0Id: 'player1' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: null, + }, + { + table: -1, + tournamentCompetitor0Id: 'player2' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'player3' as Id<'tournamentCompetitors'>, + }, + ]; + + const data = { + tournament: createMockTournament(8), + tournamentCompetitors: [] as DeepTournamentCompetitor[], + }; + + const result = assignTables(pairings, data); + + expect(result[0].table).not.toBe(null); // Regular pairing comes first + expect(result[1].table).toBe(null); // Bye comes last + }); + + it('never assigns the same table to two different pairings', () => { + const pairings: DraftTournamentPairing[] = [ + { + table: -1, + tournamentCompetitor0Id: 'C0' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'C1' as Id<'tournamentCompetitors'>, + }, + { + table: -1, + tournamentCompetitor0Id: 'C2' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'C3' as Id<'tournamentCompetitors'>, + }, + ]; + + // Create competitors with playedTables + const data = createMockData(4); + data.tournamentCompetitors[0].playedTables = [0, 1]; + data.tournamentCompetitors[1].playedTables = [0, 1]; + data.tournamentCompetitors[2].playedTables = [0, 1]; + data.tournamentCompetitors[3].playedTables = [0, 1]; + + const result = assignTables(pairings, data); + + expect(result).toHaveLength(2); + expect(result[0].table).not.toBe(result[1].table); + expect(result[0].table).toBeGreaterThanOrEqual(0); + expect(result[0].table).toBeLessThan(2); + expect(result[1].table).toBeGreaterThanOrEqual(0); + expect(result[1].table).toBeLessThan(2); + }); + + it('attempts to avoid tables players have already played on', () => { + const pairings: DraftTournamentPairing[] = [ + { + table: -1, + tournamentCompetitor0Id: 'C0' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'C1' as Id<'tournamentCompetitors'>, + }, + { + table: -1, + tournamentCompetitor0Id: 'C2' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'C3' as Id<'tournamentCompetitors'>, + }, + ]; + + // Create competitors with playedTables + const data = createMockData(8); // 8 competitors = 4 tables + data.tournamentCompetitors[0].playedTables = [0]; // C0 played on table 0 + data.tournamentCompetitors[1].playedTables = [0]; // C1 played on table 0 + data.tournamentCompetitors[2].playedTables = [1]; // C2 played on table 1 + data.tournamentCompetitors[3].playedTables = [1]; // C3 played on table 1 + + const result = assignTables(pairings, data); + + const pairing1 = result.find((p) => p.tournamentCompetitor0Id === 'C0'); + const pairing2 = result.find((p) => p.tournamentCompetitor0Id === 'C2'); + + // With 4 tables and only 2 pairings, there's plenty of room to avoid conflicts + // Due to random assignment and swapping logic, we can't guarantee exact tables, + // but we can verify no duplicates + expect(pairing1?.table).not.toBe(pairing2?.table); + }); + + it('performs swaps to optimize table assignments', () => { + // Create a scenario where swapping would help: + // C0+C1 have played [0], C2+C3 have played [1] + // Using auto-assignment (table: -1) to test the swap optimization logic + const pairings: DraftTournamentPairing[] = [ + { + table: -1, // Auto-assign so swap logic can optimize + tournamentCompetitor0Id: 'C0' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'C1' as Id<'tournamentCompetitors'>, + }, + { + table: -1, // Auto-assign so swap logic can optimize + tournamentCompetitor0Id: 'C2' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'C3' as Id<'tournamentCompetitors'>, + }, + ]; + + // Create competitors with playedTables + const data = createMockData(4); // 4 competitors = 2 tables + data.tournamentCompetitors[0].playedTables = [0]; // C0 played on table 0 + data.tournamentCompetitors[1].playedTables = [0]; // C1 played on table 0 + data.tournamentCompetitors[2].playedTables = [1]; // C2 played on table 1 + data.tournamentCompetitors[3].playedTables = [1]; // C3 played on table 1 + + const result = assignTables(pairings, data); + + const pairing1 = result.find((p) => p.tournamentCompetitor0Id === 'C0'); + const pairing2 = result.find((p) => p.tournamentCompetitor0Id === 'C2'); + + // With the optimization logic, tables should be assigned to avoid conflicts + // C0+C1 should NOT be on table 0, C2+C3 should NOT be on table 1 + expect(pairing1?.table).not.toBe(0); + expect(pairing2?.table).not.toBe(1); + }); + + it('handles complex scenarios with multiple rounds of play history', () => { + const pairings: DraftTournamentPairing[] = [ + { + table: -1, + tournamentCompetitor0Id: 'C0' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'C1' as Id<'tournamentCompetitors'>, + }, + { + table: -1, + tournamentCompetitor0Id: 'C2' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'C3' as Id<'tournamentCompetitors'>, + }, + { + table: -1, + tournamentCompetitor0Id: 'C4' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'C5' as Id<'tournamentCompetitors'>, + }, + ]; + + // Create competitors with playedTables + const data = createMockData(10); // 10 competitors = 5 tables + data.tournamentCompetitors[0].playedTables = [0, 1, 2]; // C0 + data.tournamentCompetitors[1].playedTables = [0, 1, 2]; // C1 + data.tournamentCompetitors[2].playedTables = [1, 2, 3]; // C2 + data.tournamentCompetitors[3].playedTables = [1, 2, 3]; // C3 + data.tournamentCompetitors[4].playedTables = [0, 2, 4]; // C4 + data.tournamentCompetitors[5].playedTables = [0, 2, 4]; // C5 + + const result = assignTables(pairings, data); + + // All pairings should have unique tables + const tables = result.map((p) => p.table).filter((t) => t !== null); + const uniqueTables = new Set(tables); + expect(tables.length).toBe(uniqueTables.size); + + // Each pairing should have a valid table + result.forEach((pairing) => { + expect(pairing.table).toBeGreaterThanOrEqual(0); + expect(pairing.table).toBeLessThan(5); + }); + }); + + it('throws error when there are more pairings than tables', () => { + const pairings: DraftTournamentPairing[] = [ + { + table: -1, + tournamentCompetitor0Id: 'player1' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'player2' as Id<'tournamentCompetitors'>, + }, + { + table: -1, + tournamentCompetitor0Id: 'player3' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'player4' as Id<'tournamentCompetitors'>, + }, + { + table: -1, + tournamentCompetitor0Id: 'player5' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'player6' as Id<'tournamentCompetitors'>, + }, + ]; + + const data = { + tournament: createMockTournament(4), // 4 competitors = 2 tables + tournamentCompetitors: [] as DeepTournamentCompetitor[], + }; + + // With only 2 tables and 3 pairings, an error should be thrown + expect(() => assignTables(pairings, data)).toThrowError(); + }); + + it('ignores empty pairings', () => { + const pairings: DraftTournamentPairing[] = [ + { + table: -1, + tournamentCompetitor0Id: null, + tournamentCompetitor1Id: null, + }, + { + table: -1, + tournamentCompetitor0Id: 'player1' as Id<'tournamentCompetitors'>, + tournamentCompetitor1Id: 'player2' as Id<'tournamentCompetitors'>, + }, + ]; + + const data = { + tournament: createMockTournament(8), + tournamentCompetitors: [] as DeepTournamentCompetitor[], + }; + + const result = assignTables(pairings, data); + + // Only the non-empty pairing should be returned + expect(result).toHaveLength(1); + expect(result[0].tournamentCompetitor0Id).toBe('player1'); + }); +}); diff --git a/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.utils.tsx b/convex/_model/tournamentPairings/_helpers/assignTables.ts similarity index 64% rename from src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.utils.tsx rename to convex/_model/tournamentPairings/_helpers/assignTables.ts index 089d3fa3..e4f2bc0e 100644 --- a/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.utils.tsx +++ b/convex/_model/tournamentPairings/_helpers/assignTables.ts @@ -1,58 +1,42 @@ -import { ColumnDef } from '@ianpaschal/combat-command-components'; - -import { DraftTournamentPairing, TournamentCompetitor } from '~/api'; -import { TournamentPairingRow } from '~/components/TournamentPairingRow'; -import { TournamentPairingFormItem } from '../../TournamentPairingsPage.schema'; - -import styles from './ConfirmPairingsDialog.module.scss'; - -export const getTableColumns = (competitors: TournamentCompetitor[]): ColumnDef[] => [ - { - key: 'table', - label: 'Table', - width: 'auto', - xAlign: 'center', - renderCell: (r) => ( -
- {r.table === null ? '-' : r.table + 1} -
- ), - }, - { - key: 'pairing', - label: 'Pairing', - width: '1fr', - xAlign: 'center', - renderCell: (r) => { - const tournamentCompetitor0 = competitors.find((c) => c._id === r.tournamentCompetitor0Id) ?? null; - const tournamentCompetitor1 = competitors.find((c) => c._id === r.tournamentCompetitor1Id) ?? null; - if (!tournamentCompetitor0) { - return null; - } - return ( - - ); - }, - }, -]; +import { ConvexError } from 'convex/values'; + +import { Id } from '../../../_generated/dataModel'; +import { getErrorMessage } from '../../common/errors'; +import { DeepTournamentCompetitor } from '../../tournamentCompetitors'; +import { TournamentDeep } from '../../tournaments'; +import { DraftTournamentPairing } from '..'; export const assignTables = ( - pairings: (TournamentPairingFormItem & { - playedTables: (number | null)[]; - })[], - tableCount: number, + pairings: DraftTournamentPairing[], + data: { + tournament: TournamentDeep, + tournamentCompetitors: DeepTournamentCompetitor[], + }, ): DraftTournamentPairing[] => { + + // Use a map for more efficient look-up of played tables: + const playedTablesMap = new Map | null, number[]>( + data.tournamentCompetitors.map((c) => [c._id, c.playedTables]), + ); + playedTablesMap.set(null, []); + + const tableCount = (data.tournament?.competitorCount ?? 2) / 2; // TODO: Use actual table count + + const draftPairings = pairings.map((p) => ({ + ...p, + playedTables: Array.from(new Set([ + ...(playedTablesMap.get(p.tournamentCompetitor0Id) ?? []), + ...(playedTablesMap.get(p.tournamentCompetitor1Id) ?? []), + ])), + })); + const availableTables = new Set(Array.from({ length: tableCount }, (_, i) => i)); // Step 1: Organize pairings by which DO need tables and which DON'T: const autoAssignedPairings = []; const manuallyAssignedPairings = []; - for (const { playedTables, ...pairing } of pairings) { + for (const { playedTables, ...pairing } of draftPairings) { const ids = [ pairing.tournamentCompetitor0Id, pairing.tournamentCompetitor1Id, @@ -91,14 +75,15 @@ export const assignTables = ( } if (availableTables.size < autoAssignedPairings.length) { - throw new Error('There are more unassigned pairings than available tables!'); + throw new ConvexError(getErrorMessage('NOT_ENOUGH_AVAILABLE_TABLES')); } // Step 2: Randomly assign tables to pairings that need them: + const availableTablesList = Array.from(availableTables); for (let i = 0; i < autoAssignedPairings.length; i++) { - const randomTable = Array.from(availableTables)[Math.floor(Math.random() * availableTables.size)]; - autoAssignedPairings[i].table = randomTable; - availableTables.delete(randomTable); + const randomIndex = Math.floor(Math.random() * availableTablesList.length); + autoAssignedPairings[i].table = availableTablesList[randomIndex]; + availableTablesList.splice(randomIndex, 1); } // Step 3: Optimization pass - try to swap tables to avoid repeats: diff --git a/convex/_model/tournamentPairings/_helpers/checkIfPairingIsRepeat.ts b/convex/_model/tournamentPairings/_helpers/checkIfPairingIsRepeat.ts index a245ac16..bf176ad2 100644 --- a/convex/_model/tournamentPairings/_helpers/checkIfPairingIsRepeat.ts +++ b/convex/_model/tournamentPairings/_helpers/checkIfPairingIsRepeat.ts @@ -5,6 +5,6 @@ import { DeepTournamentCompetitor } from '../../tournamentCompetitors'; */ export const checkIfPairingIsRepeat = ( - a: DeepTournamentCompetitor, - b: DeepTournamentCompetitor, -): boolean => a.opponentIds.includes(b._id) || b.opponentIds.includes(a._id); + a: DeepTournamentCompetitor | null, + b: DeepTournamentCompetitor | null, +): boolean => !!a && !!b && (a.opponentIds.includes(b._id) || b.opponentIds.includes(a._id)); diff --git a/convex/_model/tournamentPairings/_helpers/checkIfPairingIsSameAlignment.test.ts b/convex/_model/tournamentPairings/_helpers/checkIfPairingIsSameAlignment.test.ts index 2fb025ce..b583bb3e 100644 --- a/convex/_model/tournamentPairings/_helpers/checkIfPairingIsSameAlignment.test.ts +++ b/convex/_model/tournamentPairings/_helpers/checkIfPairingIsSameAlignment.test.ts @@ -63,12 +63,26 @@ describe('checkIfPairingIsSameAlignment', () => { expect(checkIfPairingIsSameAlignment(a, b)).toBe(true); }); - it('returns true if both have empty alignments.', () => { + it('returns false if first competitor is null.', () => { + const b = createMockTournamentCompetitor({ id: 'B' }); + expect(checkIfPairingIsSameAlignment(null, b)).toBe(false); + }); + + it('returns false if second competitor is null.', () => { + const a = createMockTournamentCompetitor({ id: 'A' }); + expect(checkIfPairingIsSameAlignment(a, null)).toBe(false); + }); + + it('returns false if both competitors are null.', () => { + expect(checkIfPairingIsSameAlignment(null, null)).toBe(false); + }); + + it('returns false if both have empty alignments.', () => { const a = createMockTournamentCompetitor({ id: 'A' }); const b = createMockTournamentCompetitor({ id: 'B' }); a.details.alignments = []; b.details.alignments = []; - expect(checkIfPairingIsSameAlignment(a, b)).toBe(true); + expect(checkIfPairingIsSameAlignment(a, b)).toBe(false); }); }); diff --git a/convex/_model/tournamentPairings/_helpers/checkIfPairingIsSameAlignment.ts b/convex/_model/tournamentPairings/_helpers/checkIfPairingIsSameAlignment.ts index dd23d328..8521ac2c 100644 --- a/convex/_model/tournamentPairings/_helpers/checkIfPairingIsSameAlignment.ts +++ b/convex/_model/tournamentPairings/_helpers/checkIfPairingIsSameAlignment.ts @@ -5,11 +5,14 @@ import { DeepTournamentCompetitor } from '../../tournamentCompetitors'; * Returns true if they have the same single alignment (conflict). * Returns false if pairing is allowed (flexible, multiple alignments, or different). */ - export const checkIfPairingIsSameAlignment = ( - a: DeepTournamentCompetitor, - b: DeepTournamentCompetitor, + a: DeepTournamentCompetitor | null, + b: DeepTournamentCompetitor | null, ): boolean => { + if (!a || !b) { + return false; + } + const aAlignments = a.details.alignments; const bAlignments = b.details.alignments; diff --git a/convex/_model/tournamentPairings/_helpers/deepenTournamentPairing.ts b/convex/_model/tournamentPairings/_helpers/deepenTournamentPairing.ts index f5dd6362..802aa520 100644 --- a/convex/_model/tournamentPairings/_helpers/deepenTournamentPairing.ts +++ b/convex/_model/tournamentPairings/_helpers/deepenTournamentPairing.ts @@ -35,11 +35,14 @@ export const deepenTournamentPairing = async ( return acc; }, [] as Id<'users'>[]); - const rawTournamentCompetitor0 = await ctx.db.get(tournamentPairing.tournamentCompetitor0Id); - if (!rawTournamentCompetitor0) { - throw new ConvexError(getErrorMessage('TOURNAMENT_COMPETITOR_NOT_FOUND')); + let tournamentCompetitor0 = null; + if (tournamentPairing.tournamentCompetitor0Id) { + const rawTournamentCompetitor0 = await ctx.db.get(tournamentPairing.tournamentCompetitor0Id); + if (!rawTournamentCompetitor0) { + throw new ConvexError(getErrorMessage('TOURNAMENT_COMPETITOR_NOT_FOUND')); + } + tournamentCompetitor0 = await deepenTournamentCompetitor(ctx, rawTournamentCompetitor0, tournamentPairing.round); } - const tournamentCompetitor0 = await deepenTournamentCompetitor(ctx, rawTournamentCompetitor0, tournamentPairing.round); let tournamentCompetitor1 = null; if (tournamentPairing.tournamentCompetitor1Id) { @@ -52,12 +55,12 @@ export const deepenTournamentPairing = async ( return { ...tournamentPairing, - displayName: `${tournamentCompetitor0.displayName} vs. ${tournamentCompetitor1?.displayName ?? 'Bye'}`, + displayName: `${tournamentCompetitor0?.displayName ?? 'Bye'} vs. ${tournamentCompetitor1?.displayName ?? 'Bye'}`, tournamentCompetitor0, tournamentCompetitor1, playerUserIds: [ - ...tournamentCompetitor0.registrations.map((r) => r.user?._id), - ...(tournamentCompetitor1?.registrations ?? []).map((r) => r.user?._id), + ...(tournamentCompetitor0?.registrations ?? []).map((r) => r.userId), + ...(tournamentCompetitor1?.registrations ?? []).map((r) => r.userId), ], submittedUserIds, matchResultsProgress: { diff --git a/convex/_model/tournamentPairings/_helpers/generateDraftPairings.test.ts b/convex/_model/tournamentPairings/_helpers/generateDraftPairings.test.ts index bfd0a0fd..3e20afa3 100644 --- a/convex/_model/tournamentPairings/_helpers/generateDraftPairings.test.ts +++ b/convex/_model/tournamentPairings/_helpers/generateDraftPairings.test.ts @@ -1,3 +1,4 @@ +import { TournamentPairingPolicy } from '@ianpaschal/combat-command-game-systems/common'; import { Alignment } from '@ianpaschal/combat-command-game-systems/flamesOfWarV4'; import { beforeEach, @@ -11,10 +12,15 @@ import { errors } from '../../common/errors'; import { DeepTournamentCompetitor } from '../../tournamentCompetitors'; import { generateDraftPairings } from './generateDraftPairings'; +const defaultPolicies = { + repeat: TournamentPairingPolicy.Block, + sameAlignment: TournamentPairingPolicy.Allow, +}; + describe('generateDraftPairings', () => { it('Handles 2 players (trivial pair).', () => { const competitors = createMockTournamentCompetitors(2); - const pairings = generateDraftPairings(competitors); + const pairings = generateDraftPairings(competitors, defaultPolicies); expect(pairings.length).toBe(1); expect(pairings[0][1]).not.toBeNull(); }); @@ -29,7 +35,7 @@ describe('generateDraftPairings', () => { it('Assigns a bye.', () => { // ---- Act ---- - const pairings = generateDraftPairings(competitors); + const pairings = generateDraftPairings(competitors, defaultPolicies); const byePairings = pairings.filter(([ _, b ]) => b === null); // ---- Assert ---- @@ -43,20 +49,22 @@ describe('generateDraftPairings', () => { competitors[competitorCount - 1].byeRounds.push(1); // ---- Act ---- - const pairings = generateDraftPairings(competitors); + const pairings = generateDraftPairings(competitors, defaultPolicies); const byeCompetitor = pairings.find(([ _, b ]) => b === null)?.[0]; // ---- Assert ---- expect(byeCompetitor?._id).toBe(competitors[competitorCount - 2]?._id); }); - + it('Assigns a bye to the lowest-ranked competitor if all have had byes.', () => { // ---- Arrange ---- // All competitors have already had a bye: - competitors.forEach((p) => (p.byeRounds = [ 1 ])); - + competitors.forEach((p) => { + p.byeRounds = [ 1 ]; + }); + // ---- Act ---- - const pairings = generateDraftPairings(competitors); + const pairings = generateDraftPairings(competitors, defaultPolicies); const byeCompetitor = pairings.find(([ _, b ]) => b === null)?.[0]; // ---- Assert ---- @@ -76,14 +84,18 @@ describe('generateDraftPairings', () => { } }); - it('Does not allow repeat pairings by default.', () => { + it('Does not allow repeat pairings when repeat policy is Block.', () => { // ---- Act & Assert ---- - expect(() => generateDraftPairings(competitors)).toThrow(errors.NO_VALID_PAIRINGS_POSSIBLE_WITHOUT_REPEAT); + expect(() => generateDraftPairings(competitors, defaultPolicies)) + .toThrow(errors.NO_VALID_PAIRINGS_POSSIBLE_WITHOUT_REPEAT); }); it('Does allow repeat pairings when explicitly enabled.', () => { // ---- Act ---- - const pairings = generateDraftPairings(competitors, { allowRepeats: true }); + const pairings = generateDraftPairings(competitors, { + repeat: TournamentPairingPolicy.Allow, + sameAlignment: TournamentPairingPolicy.Allow, + }); // ---- Assert ---- expect(pairings.length).toBe(2); @@ -104,13 +116,15 @@ describe('generateDraftPairings', () => { it('Does not allow same alignment pairings when disabled.', () => { // ---- Act & Assert ---- - expect(() => generateDraftPairings(competitors, { allowSameAlignment: false })) - .toThrow(errors.NO_VALID_PAIRINGS_POSSIBLE_WITHOUT_SAME_ALIGNMENT); + expect(() => generateDraftPairings(competitors, { + repeat: TournamentPairingPolicy.Block, + sameAlignment: TournamentPairingPolicy.Block, + })).toThrow(errors.NO_VALID_PAIRINGS_POSSIBLE_WITHOUT_SAME_ALIGNMENT); }); - it('Does allow same alignment pairings by default.', () => { + it('Does allow same alignment pairings when sameAlignment policy is Allow.', () => { // ---- Act ---- - const pairings = generateDraftPairings(competitors); + const pairings = generateDraftPairings(competitors, defaultPolicies); // ---- Assert ---- expect(pairings.length).toBe(2); @@ -118,7 +132,10 @@ describe('generateDraftPairings', () => { it('Does allow same alignment pairings when explicitly enabled.', () => { // ---- Act ---- - const pairings = generateDraftPairings(competitors, { allowSameAlignment: true }); + const pairings = generateDraftPairings(competitors, { + repeat: TournamentPairingPolicy.Block, + sameAlignment: TournamentPairingPolicy.Allow, + }); // ---- Assert ---- expect(pairings.length).toBe(2); @@ -143,8 +160,10 @@ describe('generateDraftPairings', () => { // ---- Act & Assert ---- // With both constraints active, relaxing either one alone doesn't help, // so the generic error is thrown - expect(() => generateDraftPairings(competitors, { allowSameAlignment: false })) - .toThrow(errors.NO_VALID_PAIRINGS_POSSIBLE); + expect(() => generateDraftPairings(competitors, { + repeat: TournamentPairingPolicy.Block, + sameAlignment: TournamentPairingPolicy.Block, + })).toThrow(errors.NO_VALID_PAIRINGS_POSSIBLE); }); it('Throws same alignment error when repeats allowed but still impossible.', () => { @@ -162,10 +181,12 @@ describe('generateDraftPairings', () => { } // ---- Act & Assert ---- - // With allowRepeats: true but allowSameAlignment: false, + // With repeat: Allow but sameAlignment: Block, // the same alignment check triggers - expect(() => generateDraftPairings(competitors, { allowRepeats: true, allowSameAlignment: false })) - .toThrow(errors.NO_VALID_PAIRINGS_POSSIBLE_WITHOUT_SAME_ALIGNMENT); + expect(() => generateDraftPairings(competitors, { + repeat: TournamentPairingPolicy.Allow, + sameAlignment: TournamentPairingPolicy.Block, + })).toThrow(errors.NO_VALID_PAIRINGS_POSSIBLE_WITHOUT_SAME_ALIGNMENT); }); }); @@ -179,7 +200,7 @@ describe('generateDraftPairings', () => { for (let round = 0; round < roundCount; round++) { // ---- Act ---- - const pairings = generateDraftPairings(competitors); + const pairings = generateDraftPairings(competitors, defaultPolicies); for (const [ a, b ] of pairings) { if (b) { a.opponentIds.push(b._id); diff --git a/convex/_model/tournamentPairings/_helpers/generateDraftPairings.ts b/convex/_model/tournamentPairings/_helpers/generateDraftPairings.ts index 3a21fbf9..9cc7d327 100644 --- a/convex/_model/tournamentPairings/_helpers/generateDraftPairings.ts +++ b/convex/_model/tournamentPairings/_helpers/generateDraftPairings.ts @@ -1,8 +1,8 @@ +import { TournamentPairingPolicies, TournamentPairingPolicy } from '@ianpaschal/combat-command-game-systems/common'; import { ConvexError } from 'convex/values'; import { getErrorMessage } from '../../common/errors'; import { DeepTournamentCompetitor } from '../../tournamentCompetitors'; -import { TournamentPairingOptions } from '../types'; import { assignBye } from './assignBye'; import { validateTournamentPairing } from './validateTournamentPairing'; @@ -24,10 +24,7 @@ export type CompetitorPair = [DeepTournamentCompetitor, DeepTournamentCompetitor */ export const generateDraftPairings = ( orderedCompetitors: DeepTournamentCompetitor[], - options: TournamentPairingOptions = { - allowRepeats: false, - allowSameAlignment: true, - }, + policies: TournamentPairingPolicies, ): CompetitorPair[] => { const pairings: CompetitorPair[] = []; @@ -38,22 +35,28 @@ export const generateDraftPairings = ( } // Resolve pairings by input order: - const resolvedPairings = recursivePair(restCompetitors, options); + const resolvedPairings = recursivePair(restCompetitors, policies); if (resolvedPairings === null) { // NOTE: In principle these should never happen, but it's good to know if they do. // Check if allowing repeats would have worked: - if (!options.allowRepeats) { - const withRepeats = recursivePair(restCompetitors, { ...options, allowRepeats: true }); + if (policies.repeat !== TournamentPairingPolicy.Allow) { + const withRepeats = recursivePair(restCompetitors, { + ...policies, + repeat: TournamentPairingPolicy.Allow, + }); if (withRepeats !== null) { throw new ConvexError(getErrorMessage('NO_VALID_PAIRINGS_POSSIBLE_WITHOUT_REPEAT')); } } // Check if allowing same alignment would have worked: - if (!options.allowSameAlignment) { - const withSameAlignment = recursivePair(restCompetitors, { ...options, allowSameAlignment: true }); + if (policies.sameAlignment !== TournamentPairingPolicy.Allow) { + const withSameAlignment = recursivePair(restCompetitors, { + ...policies, + sameAlignment: TournamentPairingPolicy.Allow, + }); if (withSameAlignment !== null) { throw new ConvexError(getErrorMessage('NO_VALID_PAIRINGS_POSSIBLE_WITHOUT_SAME_ALIGNMENT')); } @@ -71,7 +74,7 @@ export const generateDraftPairings = ( */ export const recursivePair = ( pool: DeepTournamentCompetitor[], - options: TournamentPairingOptions, + policies: TournamentPairingPolicies, ): CompetitorPair[] | null => { if (pool.length === 0) { return []; // everyone paired @@ -81,11 +84,11 @@ export const recursivePair = ( const opponent = rest[i]; // If the potential pairing is invalid, skip and continue: - if (validateTournamentPairing(options, anchor, opponent).status === 'error') { + if (validateTournamentPairing(policies, anchor, opponent).status === 'error') { continue; // hard‑constraint } const nextPool = rest.slice(0, i).concat(rest.slice(i + 1)); - const sub = recursivePair(nextPool, options); + const sub = recursivePair(nextPool, policies); if (sub) { return [ [ anchor, opponent ], ...sub ]; } // success – unwind diff --git a/convex/_model/tournamentPairings/_helpers/getTournamentPairingUserIds.ts b/convex/_model/tournamentPairings/_helpers/getTournamentPairingUserIds.ts index bec88097..674f0e5d 100644 --- a/convex/_model/tournamentPairings/_helpers/getTournamentPairingUserIds.ts +++ b/convex/_model/tournamentPairings/_helpers/getTournamentPairingUserIds.ts @@ -6,11 +6,11 @@ export const getTournamentPairingUserIds = async ( ctx: QueryCtx, doc: Doc<'tournamentPairings'>, ): Promise[]> => { - const competitor0Registrations = await getTournamentRegistrationsByCompetitor(ctx, { + const competitor0Registrations = doc.tournamentCompetitor0Id ? await getTournamentRegistrationsByCompetitor(ctx, { tournamentCompetitorId: doc.tournamentCompetitor0Id, - }); + }) : []; const competitor1Registrations = doc.tournamentCompetitor1Id ? await getTournamentRegistrationsByCompetitor(ctx, { - tournamentCompetitorId: doc.tournamentCompetitor0Id, + tournamentCompetitorId: doc.tournamentCompetitor1Id, }) : []; return [ ...competitor0Registrations.map((r) => r.userId), diff --git a/convex/_model/tournamentPairings/_helpers/validateTournamentPairing.test.ts b/convex/_model/tournamentPairings/_helpers/validateTournamentPairing.test.ts index 4324dbb1..6cb8df8a 100644 --- a/convex/_model/tournamentPairings/_helpers/validateTournamentPairing.test.ts +++ b/convex/_model/tournamentPairings/_helpers/validateTournamentPairing.test.ts @@ -1,3 +1,4 @@ +import { TournamentPairingPolicy } from '@ianpaschal/combat-command-game-systems/common'; import { Alignment } from '@ianpaschal/combat-command-game-systems/flamesOfWarV4'; import { describe, @@ -9,6 +10,11 @@ import { createMockTournamentCompetitor } from '../../../_fixtures/createMockTou import { Id } from '../../../_generated/dataModel'; import { validateTournamentPairing } from './validateTournamentPairing'; +const allowAllPolicies = { + repeat: TournamentPairingPolicy.Allow, + sameAlignment: TournamentPairingPolicy.Allow, +}; + describe('validateTournamentPairing', () => { describe('returns error', () => { @@ -17,7 +23,10 @@ describe('validateTournamentPairing', () => { const a = createMockTournamentCompetitor({ id: 'A', opponentIds: ['B'] as Id<'tournamentCompetitors'>[] }); const b = createMockTournamentCompetitor({ id: 'B' }); - const result = validateTournamentPairing({ allowRepeats: false }, a, b); + const result = validateTournamentPairing({ + repeat: TournamentPairingPolicy.Block, + sameAlignment: TournamentPairingPolicy.Allow, + }, a, b); expect(result.status).toBe('error'); expect(result.message).toBe('These opponents have already played each other.'); @@ -29,7 +38,10 @@ describe('validateTournamentPairing', () => { a.details.alignments = [Alignment.Allies]; b.details.alignments = [Alignment.Allies]; - const result = validateTournamentPairing({ allowSameAlignment: false }, a, b); + const result = validateTournamentPairing({ + repeat: TournamentPairingPolicy.Allow, + sameAlignment: TournamentPairingPolicy.Block, + }, a, b); expect(result.status).toBe('error'); expect(result.message).toBe('These opponents have the same alignment.'); @@ -40,7 +52,7 @@ describe('validateTournamentPairing', () => { it('if competitor A has already had a bye.', () => { const a = createMockTournamentCompetitor({ id: 'A', byeRounds: [1], displayName: 'Player A' }); - const result = validateTournamentPairing({}, a, null); + const result = validateTournamentPairing(allowAllPolicies, a, null); expect(result.status).toBe('warning'); expect(result.message).toBe('Player A has already had a bye.'); @@ -49,7 +61,7 @@ describe('validateTournamentPairing', () => { it('if competitor B has already had a bye.', () => { const b = createMockTournamentCompetitor({ id: 'B', byeRounds: [1], displayName: 'Player B' }); - const result = validateTournamentPairing({}, null, b); + const result = validateTournamentPairing(allowAllPolicies, null, b); expect(result.status).toBe('warning'); expect(result.message).toBe('Player B has already had a bye.'); @@ -59,7 +71,7 @@ describe('validateTournamentPairing', () => { const a = createMockTournamentCompetitor({ id: 'A', playedTables: [1, 2, 3], displayName: 'Player A' }); const b = createMockTournamentCompetitor({ id: 'B' }); - const result = validateTournamentPairing({}, a, b, 2); + const result = validateTournamentPairing(allowAllPolicies, a, b, 2); expect(result.status).toBe('warning'); expect(result.message).toBe('Player A has already played this table.'); @@ -69,7 +81,7 @@ describe('validateTournamentPairing', () => { const a = createMockTournamentCompetitor({ id: 'A' }); const b = createMockTournamentCompetitor({ id: 'B', playedTables: [1, 2, 3], displayName: 'Player B' }); - const result = validateTournamentPairing({}, a, b, 3); + const result = validateTournamentPairing(allowAllPolicies, a, b, 3); expect(result.status).toBe('warning'); expect(result.message).toBe('Player B has already played this table.'); @@ -82,7 +94,7 @@ describe('validateTournamentPairing', () => { const a = createMockTournamentCompetitor({ id: 'A', playedTables: [1, 2, 3] }); const b = createMockTournamentCompetitor({ id: 'B' }); - const result = validateTournamentPairing({}, a, b, null); + const result = validateTournamentPairing(allowAllPolicies, a, b, null); expect(result.status).toBe('ok'); }); @@ -91,7 +103,7 @@ describe('validateTournamentPairing', () => { const a = createMockTournamentCompetitor({ id: 'A', playedTables: [1, 2, 3] }); const b = createMockTournamentCompetitor({ id: 'B' }); - const result = validateTournamentPairing({}, a, b, -1); + const result = validateTournamentPairing(allowAllPolicies, a, b, -1); expect(result.status).toBe('ok'); }); @@ -100,7 +112,7 @@ describe('validateTournamentPairing', () => { const a = createMockTournamentCompetitor({ id: 'A', playedTables: [1, 2, 3] }); const b = createMockTournamentCompetitor({ id: 'B' }); - const result = validateTournamentPairing({}, a, b, undefined); + const result = validateTournamentPairing(allowAllPolicies, a, b, undefined); expect(result.status).toBe('ok'); }); @@ -112,7 +124,10 @@ describe('validateTournamentPairing', () => { a.details.alignments = [Alignment.Allies]; b.details.alignments = [Alignment.Allies]; - const result = validateTournamentPairing({ allowRepeats: false, allowSameAlignment: false }, a, b); + const result = validateTournamentPairing({ + repeat: TournamentPairingPolicy.Block, + sameAlignment: TournamentPairingPolicy.Block, + }, a, b); expect(result.status).toBe('error'); expect(result.message).toBe('These opponents have already played each other.'); @@ -122,7 +137,10 @@ describe('validateTournamentPairing', () => { const a = createMockTournamentCompetitor({ id: 'A', opponentIds: ['B'] as Id<'tournamentCompetitors'>[], playedTables: [1] }); const b = createMockTournamentCompetitor({ id: 'B' }); - const result = validateTournamentPairing({ allowRepeats: false }, a, b, 1); + const result = validateTournamentPairing({ + repeat: TournamentPairingPolicy.Block, + sameAlignment: TournamentPairingPolicy.Allow, + }, a, b, 1); expect(result.status).toBe('error'); }); @@ -135,14 +153,14 @@ describe('validateTournamentPairing', () => { a.details.alignments = [Alignment.Allies]; b.details.alignments = [Alignment.Axis]; - const result = validateTournamentPairing({}, a, b); + const result = validateTournamentPairing(allowAllPolicies, a, b); expect(result.status).toBe('ok'); expect(result.message).toBe('Pairing is valid.'); }); it('returns ok when both competitors are null.', () => { - const result = validateTournamentPairing({}, null, null); + const result = validateTournamentPairing(allowAllPolicies, null, null); expect(result.status).toBe('ok'); }); diff --git a/convex/_model/tournamentPairings/_helpers/validateTournamentPairing.ts b/convex/_model/tournamentPairings/_helpers/validateTournamentPairing.ts index f0d43a9d..f14e14fd 100644 --- a/convex/_model/tournamentPairings/_helpers/validateTournamentPairing.ts +++ b/convex/_model/tournamentPairings/_helpers/validateTournamentPairing.ts @@ -1,5 +1,7 @@ +import { TournamentPairingPolicies, TournamentPairingPolicy } from '@ianpaschal/combat-command-game-systems/common'; + import { DeepTournamentCompetitor } from '../../tournamentCompetitors'; -import { TournamentPairingOptions, TournamentPairingStatus } from '../types'; +import { TournamentPairingStatus } from '../types'; import { checkIfPairingIsRepeat } from './checkIfPairingIsRepeat'; import { checkIfPairingIsSameAlignment } from './checkIfPairingIsSameAlignment'; @@ -13,30 +15,39 @@ import { checkIfPairingIsSameAlignment } from './checkIfPairingIsSameAlignment'; * @returns TournamentPairingStatus with 'error', 'warning', or 'ok' status */ export const validateTournamentPairing = ( - options: Omit = {}, + policies: TournamentPairingPolicies, a: DeepTournamentCompetitor | null, b: DeepTournamentCompetitor | null, table?: number | null, ): TournamentPairingStatus => { - const { allowRepeats = false, allowSameAlignment = true } = options; + const { repeat, sameAlignment } = policies ?? { + repeat: TournamentPairingPolicy.Block, + sameAlignment: TournamentPairingPolicy.Block, + }; // Both competitors present - check pairing constraints if (a && b) { // Check repeat opponents (error): - if (!allowRepeats && checkIfPairingIsRepeat(a, b)) { - return { - status: 'error', - message: 'These opponents have already played each other.', - }; + if (checkIfPairingIsRepeat(a, b)) { + const message = 'These opponents have already played each other.'; + if (repeat === TournamentPairingPolicy.Block) { + return { status: 'error', message }; + } + if (repeat === TournamentPairingPolicy.Avoid) { + return { status: 'warning', message }; + } } // Check same alignment (error): - if (!allowSameAlignment && checkIfPairingIsSameAlignment(a, b)) { - return { - status: 'error', - message: 'These opponents have the same alignment.', - }; + if (checkIfPairingIsSameAlignment(a, b)) { + const message = 'These opponents have the same alignment.'; + if (sameAlignment === TournamentPairingPolicy.Block) { + return { status: 'error', message }; + } + if (sameAlignment === TournamentPairingPolicy.Avoid) { + return { status: 'warning', message }; + } } } diff --git a/convex/_model/tournamentPairings/actions/generateDraftTournamentPairings.ts b/convex/_model/tournamentPairings/actions/generateDraftTournamentPairings.ts new file mode 100644 index 00000000..ef1eca59 --- /dev/null +++ b/convex/_model/tournamentPairings/actions/generateDraftTournamentPairings.ts @@ -0,0 +1,48 @@ +import { Infer, v } from 'convex/values'; + +import { api } from '../../../_generated/api'; +import { ActionCtx } from '../../../_generated/server'; +import { tournamentPairingConfig } from '../../common/tournamentPairingConfig'; +import { DeepTournamentCompetitor } from '../../tournamentCompetitors'; +import { generateDraftPairings } from '../_helpers/generateDraftPairings'; +import { shuffle } from '../_helpers/shuffle'; +import { sortByRank } from '../_helpers/sortByRank'; +import { sortCompetitorPairs } from '../_helpers/sortCompetitorPairs'; +import { uniqueFields } from '../table'; + +export const draftTournamentPairing = v.object(uniqueFields); +export type DraftTournamentPairing = Infer; + +export const generateDraftTournamentPairingsArgs = v.object({ + tournamentId: v.id('tournaments'), + round: v.number(), + config: tournamentPairingConfig, +}); + +export const generateDraftTournamentPairings = async ( + ctx: ActionCtx, + args: Infer, +): Promise => { + + const tournamentCompetitors = await ctx.runQuery( + api.tournamentCompetitors.getTournamentCompetitorsByTournament, { + tournamentId: args.tournamentId, + }, + ); + + const activeCompetitors = tournamentCompetitors.filter(({ active }) => active); + + const orderedCompetitors: DeepTournamentCompetitor[] = []; + if (args.config.orderBy === 'ranking') { + orderedCompetitors.push(...sortByRank(activeCompetitors)); + } + if (args.config.orderBy === 'random') { + orderedCompetitors.push(...shuffle(activeCompetitors)); + } + + return generateDraftPairings(orderedCompetitors, args.config.policies).sort(sortCompetitorPairs).map((draftPairing) => ({ + tournamentCompetitor0Id: draftPairing[0]._id, + tournamentCompetitor1Id: draftPairing[1]?._id ?? null, + table: -1, + })); +}; diff --git a/convex/_model/tournamentPairings/actions/generateTableAssignments.ts b/convex/_model/tournamentPairings/actions/generateTableAssignments.ts new file mode 100644 index 00000000..5bedda3d --- /dev/null +++ b/convex/_model/tournamentPairings/actions/generateTableAssignments.ts @@ -0,0 +1,44 @@ +import { + ConvexError, + Infer, + v, +} from 'convex/values'; + +import { api } from '../../../_generated/api'; +import { ActionCtx } from '../../../_generated/server'; +import { getErrorMessage } from '../../common/errors'; +import { assignTables } from '../_helpers/assignTables'; +import { DraftTournamentPairing, draftTournamentPairing } from './generateDraftTournamentPairings'; + +export const generateTableAssignmentsArgs = v.object({ + tournamentId: v.id('tournaments'), + pairings: v.array(draftTournamentPairing), +}); + +// NOTE: This is not round specific. It always uses the latest set of playedTables. +export const generateTableAssignments = async ( + ctx: ActionCtx, + args: Infer, +): Promise => { + + const tournament = await ctx.runQuery( + api.tournaments.getTournament, { + id: args.tournamentId, + }, + ); + + if (!tournament) { + throw new ConvexError(getErrorMessage('TOURNAMENT_NOT_FOUND')); + } + + const tournamentCompetitors = await ctx.runQuery( + api.tournamentCompetitors.getTournamentCompetitorsByTournament, { + tournamentId: args.tournamentId, + }, + ); + + return assignTables(args.pairings, { + tournament, + tournamentCompetitors, + }); +}; diff --git a/convex/_model/tournamentPairings/index.ts b/convex/_model/tournamentPairings/index.ts index f564b9f3..b20e9d35 100644 --- a/convex/_model/tournamentPairings/index.ts +++ b/convex/_model/tournamentPairings/index.ts @@ -17,10 +17,19 @@ export { validateTournamentPairing, } from './_helpers/validateTournamentPairing'; export type { - TournamentPairingOptions, TournamentPairingStatus, } from './types'; +// Actions +export { + generateDraftTournamentPairings, + generateDraftTournamentPairingsArgs, +} from './actions/generateDraftTournamentPairings'; +export { + generateTableAssignments, + generateTableAssignmentsArgs, +} from './actions/generateTableAssignments'; + // Queries export { getActiveTournamentPairingsByUser, diff --git a/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts b/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts index 977cf0bf..edab209d 100644 --- a/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts +++ b/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts @@ -61,9 +61,15 @@ export const createTournamentPairings = async ( round: args.round, }); + // Create each pairing: for (const pairing of args.pairings) { - // Validate each pairing: + // Skip if empty (neither side has a competitor): + if (!pairing.tournamentCompetitor0Id && !pairing.tournamentCompetitor1Id) { + continue; + } + + // Validate: for (const id of [pairing.tournamentCompetitor0Id, pairing.tournamentCompetitor1Id]) { const competitor = competitors.find((c) => c._id === id); const activePlayers = (competitor?.registrations ?? []).filter((p) => p.active); @@ -71,10 +77,10 @@ export const createTournamentPairings = async ( if (!competitor) { throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_MISSING_COMPETITOR')); } - if (!competitor?.active) { + if (!competitor.active) { throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_INACTIVE_COMPETITOR')); } - if (pairedCompetitorIds.has(pairing.tournamentCompetitor0Id)) { + if (pairedCompetitorIds.has(id)) { throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_ALREADY_PAIRED_COMPETITOR')); } if (activePlayers.length < tournament.competitorSize) { @@ -95,7 +101,9 @@ export const createTournamentPairings = async ( // Track results: pairingIds.push(id); - pairedCompetitorIds.add(pairing.tournamentCompetitor0Id); + if (pairing.tournamentCompetitor0Id) { + pairedCompetitorIds.add(pairing.tournamentCompetitor0Id); + } if (pairing.tournamentCompetitor1Id) { pairedCompetitorIds.add(pairing.tournamentCompetitor1Id); } diff --git a/convex/_model/tournamentPairings/queries/getDraftTournamentPairings.ts b/convex/_model/tournamentPairings/queries/getDraftTournamentPairings.ts index 71ed5d85..202abe69 100644 --- a/convex/_model/tournamentPairings/queries/getDraftTournamentPairings.ts +++ b/convex/_model/tournamentPairings/queries/getDraftTournamentPairings.ts @@ -1,8 +1,7 @@ -import { TournamentPairingMethod } from '@ianpaschal/combat-command-game-systems/common'; import { Infer, v } from 'convex/values'; import { QueryCtx } from '../../../_generated/server'; -import { getStaticEnumConvexValidator } from '../../common/_helpers/getStaticEnumConvexValidator'; +import { tournamentPairingConfig } from '../../common/tournamentPairingConfig'; import { DeepTournamentCompetitor, getTournamentCompetitorsByTournament } from '../../tournamentCompetitors'; import { generateDraftPairings } from '../_helpers/generateDraftPairings'; import { shuffle } from '../_helpers/shuffle'; @@ -12,16 +11,11 @@ import { uniqueFields } from '../table'; const draftTournamentPairing = v.object(uniqueFields); export type DraftTournamentPairing = Infer; -const tournamentPairingMethod = getStaticEnumConvexValidator(TournamentPairingMethod); export const getDraftTournamentPairingsArgs = v.object({ - method: tournamentPairingMethod, - round: v.number(), tournamentId: v.id('tournaments'), - options: v.object({ - allowSameAlignment: v.optional(v.boolean()), - allowRepeats: v.optional(v.boolean()), - }), + round: v.number(), + config: tournamentPairingConfig, }); /** @@ -44,16 +38,15 @@ export const getDraftTournamentPairings = async ( !!competitors.find((c) => c._id === _id && c.active) )); - const orderBy = args.method === TournamentPairingMethod.AdjacentAlignment ? TournamentPairingMethod.Adjacent : args.method ?? TournamentPairingMethod.Adjacent; const orderedCompetitors: DeepTournamentCompetitor[] = []; - if (orderBy === TournamentPairingMethod.Adjacent) { + if (args.config.orderBy === 'ranking') { orderedCompetitors.push(...sortByRank(activeCompetitors)); } - if (orderBy === TournamentPairingMethod.Random) { + if (args.config.orderBy === 'random') { orderedCompetitors.push(...shuffle(activeCompetitors)); } - return generateDraftPairings(orderedCompetitors, args.options).sort(sortCompetitorPairs).map((draftPairing) => ({ + return generateDraftPairings(orderedCompetitors, args.config.policies).sort(sortCompetitorPairs).map((draftPairing) => ({ tournamentId: args.tournamentId, tournamentCompetitor0Id: draftPairing[0]._id, tournamentCompetitor1Id: draftPairing[1]?._id ?? null, diff --git a/convex/_model/tournamentPairings/table.ts b/convex/_model/tournamentPairings/table.ts index 464857fa..0c00b480 100644 --- a/convex/_model/tournamentPairings/table.ts +++ b/convex/_model/tournamentPairings/table.ts @@ -3,7 +3,7 @@ import { v } from 'convex/values'; export const uniqueFields = { table: v.union(v.number(), v.null()), - tournamentCompetitor0Id: v.id('tournamentCompetitors'), + tournamentCompetitor0Id: v.union(v.id('tournamentCompetitors'), v.null()), tournamentCompetitor1Id: v.union(v.id('tournamentCompetitors'), v.null()), }; diff --git a/convex/_model/tournamentPairings/types.ts b/convex/_model/tournamentPairings/types.ts index 9b528a54..a0f1d22a 100644 --- a/convex/_model/tournamentPairings/types.ts +++ b/convex/_model/tournamentPairings/types.ts @@ -1,8 +1,3 @@ -export type TournamentPairingOptions = { - allowRepeats?: boolean; - allowSameAlignment?: boolean; -}; - export type TournamentPairingStatus = { status: 'error' | 'warning' | 'ok'; message: string; diff --git a/convex/_model/tournamentResults/_helpers/aggregateTournamentData.ts b/convex/_model/tournamentResults/_helpers/aggregateTournamentData.ts index 83d419b8..03fab27d 100644 --- a/convex/_model/tournamentResults/_helpers/aggregateTournamentData.ts +++ b/convex/_model/tournamentResults/_helpers/aggregateTournamentData.ts @@ -109,7 +109,7 @@ export const aggregateTournamentData = async ( if (wasBye) { competitorStats[competitorId].byeRounds.add(tournamentPairing.round); } - if (!wasBye && tournamentPairing.table) { + if (!wasBye && typeof tournamentPairing.table === 'number') { competitorStats[competitorId].playedTables.add(tournamentPairing.table); } }); diff --git a/convex/_model/tournaments/table.ts b/convex/_model/tournaments/table.ts index 944d8d3d..24065eeb 100644 --- a/convex/_model/tournaments/table.ts +++ b/convex/_model/tournaments/table.ts @@ -10,6 +10,7 @@ import { getStaticEnumConvexValidator } from '../common/_helpers/getStaticEnumCo import { gameSystemConfig } from '../common/gameSystemConfig'; import { location } from '../common/location'; import { rankingFactor } from '../common/rankingFactor'; +import { tournamentPairingConfig } from '../common/tournamentPairingConfig'; import { tournamentStatus } from '../common/tournamentStatus'; const currencyCode = getStaticEnumConvexValidator(CurrencyCode); @@ -66,7 +67,8 @@ export const editableFields = { factionsRevealed: v.optional(v.boolean()), // Format - pairingMethod: tournamentPairingMethod, + pairingConfig: v.optional(tournamentPairingConfig), // TODO: Remove optional after migration + pairingMethod: v.optional(tournamentPairingMethod), // TODO: Remove optional after migration roundCount: v.number(), roundStructure: v.object({ pairingTime: v.number(), // Should always be 0 for non team tournaments diff --git a/convex/_model/utils/createTestTournamentMatchResults.ts b/convex/_model/utils/createTestTournamentMatchResults.ts index 3e0506e4..de9685e9 100644 --- a/convex/_model/utils/createTestTournamentMatchResults.ts +++ b/convex/_model/utils/createTestTournamentMatchResults.ts @@ -53,31 +53,22 @@ export const createTestTournamentMatchResults = async ( } const playerData: Pick, 'player0UserId' | 'player1UserId' | 'player1Placeholder' | 'player0Placeholder'> = {}; - const tournamentCompetitor0Registrations = await ctx.db.query('tournamentRegistrations') - .withIndex('by_tournament_competitor', (q) => q.eq('tournamentCompetitorId', pairing.tournamentCompetitor0Id)) - .collect(); - const tournamentCompetitor0UserIds = tournamentCompetitor0Registrations.filter((r) => ( - r.active && !usedPlayerIds.includes(r.userId) - )).map((r) => r.userId); - const player0UserId = tournamentCompetitor0UserIds.pop(); - if (player0UserId) { - playerData.player0UserId = player0UserId; - } else { - playerData.player0Placeholder = 'Bye'; - } - - const competitor1Id = pairing.tournamentCompetitor1Id; - if (competitor1Id) { - const tournamentCompetitor1Registrations = await ctx.db.query('tournamentRegistrations') - .withIndex('by_tournament_competitor', (q) => q.eq('tournamentCompetitorId', competitor1Id)) - .collect(); - const tournamentCompetitor1UserIds = tournamentCompetitor1Registrations.filter((r) => ( - r.active && !usedPlayerIds.includes(r.userId) - )).map((r) => r.userId); - const player1UserId = tournamentCompetitor1UserIds.pop(); - playerData.player1UserId = player1UserId; - } else { - playerData.player1Placeholder = 'Bye'; + + for (const [index, id] of [pairing.tournamentCompetitor0Id, pairing.tournamentCompetitor1Id].entries()) { + let userId: Id<'users'> | undefined; + if (id) { + const registrations = await ctx.db.query('tournamentRegistrations') + .withIndex('by_tournament_competitor', (q) => q.eq('tournamentCompetitorId', id)) + .collect(); + userId = registrations.filter((r) => ( + r.active && !usedPlayerIds.includes(r.userId) + )).map((r) => r.userId).pop(); + } + if (userId) { + playerData[`player${index}UserId` as 'player0UserId' | 'player1UserId'] = userId; + } else { + playerData[`player${index}Placeholder` as 'player0Placeholder' | 'player1Placeholder'] = 'Bye'; + } } // TODO: Replace with actual call to the create mutation diff --git a/convex/migrations.ts b/convex/migrations.ts index 1874157c..59669ac3 100644 --- a/convex/migrations.ts +++ b/convex/migrations.ts @@ -1,4 +1,5 @@ import { Migrations } from '@convex-dev/migrations'; +import { getTournamentPairingConfigByMethod } from '@ianpaschal/combat-command-game-systems/common'; import { components } from './_generated/api'; import { DataModel, Id } from './_generated/dataModel'; @@ -64,3 +65,24 @@ export const migrateTournamentResults = migrations.define({ } }, }); + +export const migratePairingMethodToConfig = migrations.define({ + table: 'tournaments', + migrateOne: async (ctx, doc) => { + if (doc.pairingConfig) { + // Already has pairingConfig, just remove pairingMethod + await ctx.db.patch(doc._id, { pairingMethod: undefined }); + return; + } + if (doc.pairingMethod) { + const pairingConfig = getTournamentPairingConfigByMethod(doc.pairingMethod); + if (!pairingConfig) { + throw new Error('Found a pairing method with no corresponding config'); + } + await ctx.db.patch(doc._id, { + pairingConfig, + pairingMethod: undefined, + }); + } + }, +}); diff --git a/convex/tournamentPairings.ts b/convex/tournamentPairings.ts index 57d4226e..7dfde7f4 100644 --- a/convex/tournamentPairings.ts +++ b/convex/tournamentPairings.ts @@ -1,4 +1,8 @@ -import { mutation, query } from './_generated/server'; +import { + action, + mutation, + query, +} from './_generated/server'; import * as model from './_model/tournamentPairings'; export const getTournamentPairing = query({ @@ -30,3 +34,13 @@ export const deleteTournamentPairings = mutation({ args: model.deleteTournamentPairingsArgs, handler: model.deleteTournamentPairings, }); + +export const generateDraftTournamentPairings = action({ + args: model.generateDraftTournamentPairingsArgs, + handler: model.generateDraftTournamentPairings, +}); + +export const generateTableAssignments = action({ + args: model.generateTableAssignmentsArgs, + handler: model.generateTableAssignments, +}); diff --git a/package-lock.json b/package-lock.json index 388ed56f..4c06aaee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@dnd-kit/sortable": "^10.0.0", "@fontsource/figtree": "^5.1.0", "@hookform/resolvers": "^3.9.0", - "@ianpaschal/combat-command-components": "^1.8.0", + "@ianpaschal/combat-command-components": "^1.8.1", "@ianpaschal/combat-command-game-systems": "^1.4.0", "@mapbox/search-js-core": "^1.0.0-beta.25", "@radix-ui/colors": "^3.0.0", @@ -1500,9 +1500,9 @@ "license": "BSD-3-Clause" }, "node_modules/@ianpaschal/combat-command-components": { - "version": "1.8.0", - "resolved": "https://npm.pkg.github.com/download/@ianpaschal/combat-command-components/1.8.0/ce5e99dbfc0babd642ba8f016d9a5bcf19b81707", - "integrity": "sha512-bVlbdRvfbrNbeKgR4guI9G5KY7aMKoxtZzXEX7SpDMwwe8aRQW4va8NcF/h6fN6Mp36so2IoDd+WLy00a0hbRA==", + "version": "1.8.1", + "resolved": "https://npm.pkg.github.com/download/@ianpaschal/combat-command-components/1.8.1/8ca0d8b7c48a70225a7dd5a73ad56632dfda3147", + "integrity": "sha512-o0ZHdj7eVRREIhbAlAr4lOBLiuYGH1kXMhhI5puSdqEY+rjpWH88zmKDYv3zS0Lc5igahPRaHzMTW0J/GDOKmA==", "license": "MIT", "dependencies": { "@base-ui/react": "^1.0.0", diff --git a/package.json b/package.json index 01be5df3..247316f4 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "npm-run-all --parallel dev:frontend dev:backend", "dev:frontend": "vite", + "dev:frontend:host": "vite --host 0.0.0.0", "dev:backend": "convex dev", "predev": "convex dev --until-success && convex dashboard", "build": "tsc -b && vite build", diff --git a/src/api.ts b/src/api.ts index 6492c1e5..431eed20 100644 --- a/src/api.ts +++ b/src/api.ts @@ -28,7 +28,6 @@ export { type RankedLeagueUser, } from '../convex/_model/leagues'; export { - type TournamentPairingOptions, type TournamentPairingStatus, validateTournamentPairing, } from '../convex/_model/tournamentPairings'; diff --git a/src/components/TournamentForm/TournamentForm.schema.ts b/src/components/TournamentForm/TournamentForm.schema.ts index 42bf4a16..53584fbf 100644 --- a/src/components/TournamentForm/TournamentForm.schema.ts +++ b/src/components/TournamentForm/TournamentForm.schema.ts @@ -2,7 +2,8 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { CurrencyCode, GameSystem, - TournamentPairingMethod, + tournamentPairingConfig, + TournamentPairingPolicy, } from '@ianpaschal/combat-command-game-systems/common'; import { RankingFactor } from '@ianpaschal/combat-command-game-systems/flamesOfWarV4'; import { z } from 'zod'; @@ -79,7 +80,7 @@ export const tournamentFormSchema = z.object({ setUpTime: z.coerce.number().min(0).max(30), playingTime: z.coerce.number().min(0).max(240), }), - pairingMethod: z.string().transform((val) => val as TournamentPairingMethod), + pairingConfig: tournamentPairingConfig.schema, rankingFactors: z.array(z.string().transform((val) => val as RankingFactor)), // Game Config @@ -89,11 +90,11 @@ export const tournamentFormSchema = z.object({ message: 'Invalid config for the selected game system.', path: ['gameSystemConfig'], // Highlight the game_system_config field in case of error }).superRefine((data, ctx) => { - if (data.pairingMethod === TournamentPairingMethod.AdjacentAlignment && data.competitorSize > 1) { + if (data.pairingConfig.policies.sameAlignment === TournamentPairingPolicy.Block && data.competitorSize > 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'Adjacent alignment (red vs. blue) pairing is not available for team competitions.', - path: ['pairingMethod'], + message: 'Red vs. blue pairing is not available for team competitions.', + path: ['pairingConfig'], }); } }); @@ -112,7 +113,7 @@ export const defaultValues: Omit, 'location description: '', roundCount: 3, rulesPackUrl: '', - pairingMethod: TournamentPairingMethod.Adjacent, + pairingConfig: tournamentPairingConfig.defaultValues, title: '', gameSystem: GameSystem.FlamesOfWarV4, gameSystemConfig: getGameSystemConfigDefaultValues(GameSystem.FlamesOfWarV4), diff --git a/src/components/TournamentForm/TournamentForm.tsx b/src/components/TournamentForm/TournamentForm.tsx index ebd09658..0fcc9605 100644 --- a/src/components/TournamentForm/TournamentForm.tsx +++ b/src/components/TournamentForm/TournamentForm.tsx @@ -17,6 +17,7 @@ import { validateForm } from '~/utils/validateForm'; import { CompetitorFields } from './components/CompetitorFields'; import { FormatFields } from './components/FormatFields'; import { GeneralFields } from './components/GeneralFields'; +import { PairingFields } from './components/PairingFields'; import { defaultValues, TournamentFormData, @@ -84,6 +85,10 @@ export const TournamentForm = ({

Competitors

+ +

Pairing

+ +

Format

diff --git a/src/components/TournamentForm/TournamentForm.utils.ts b/src/components/TournamentForm/TournamentForm.utils.ts index a1a55207..669f6c96 100644 --- a/src/components/TournamentForm/TournamentForm.utils.ts +++ b/src/components/TournamentForm/TournamentForm.utils.ts @@ -1,5 +1,7 @@ import { fromZonedTime, toZonedTime } from 'date-fns-tz'; +export const ALLOWED_EDIT_STATUSES = ['draft', 'published']; + export const convertEpochToDate = (epoch: number, timezone: string): Date => { // Create a Date object from the epoch (this will be in UTC) const utcDate = new Date(epoch); diff --git a/src/components/TournamentForm/components/CompetitorFields.tsx b/src/components/TournamentForm/components/CompetitorFields.tsx index 5a8742d9..3bcd0529 100644 --- a/src/components/TournamentForm/components/CompetitorFields.tsx +++ b/src/components/TournamentForm/components/CompetitorFields.tsx @@ -1,6 +1,6 @@ import { useFormContext } from 'react-hook-form'; import { Select } from '@ianpaschal/combat-command-components'; -import { TournamentPairingMethod } from '@ianpaschal/combat-command-game-systems/common'; +import { TournamentPairingPolicy } from '@ianpaschal/combat-command-game-systems/common'; import clsx from 'clsx'; import { Animate } from '~/components/generic/Animate'; @@ -24,7 +24,7 @@ export const CompetitorFields = ({ status = 'draft', }: CompetitorFieldsProps): JSX.Element => { const { reset, watch } = useFormContext(); - const { maxCompetitors, competitorSize, pairingMethod } = watch(); + const { maxCompetitors, competitorSize, pairingConfig } = watch(); // TODO: Implement later // const { fields: competitorGroupFields, append, remove } = useFieldArray({ @@ -81,7 +81,7 @@ export const CompetitorFields = ({ // Once a tournament is published, lock some fields const allowedEditStatuses = ['draft']; const disableFields = !allowedEditStatuses.includes(status); - const disableAlignmentField = pairingMethod === TournamentPairingMethod.AdjacentAlignment; + const disableAlignmentField = pairingConfig.policies.sameAlignment === TournamentPairingPolicy.Block; return (
diff --git a/src/components/TournamentForm/components/FormatFields.module.scss b/src/components/TournamentForm/components/FormatFields.module.scss index b3849f77..04451db7 100644 --- a/src/components/TournamentForm/components/FormatFields.module.scss +++ b/src/components/TournamentForm/components/FormatFields.module.scss @@ -10,7 +10,10 @@ @import "@radix-ui/colors/iris.css"; .FormatFields { - @include flex.column; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + row-gap: 2rem; + column-gap: 1.5rem; &_RoundsSection { @include flex.column; diff --git a/src/components/TournamentForm/components/FormatFields.tsx b/src/components/TournamentForm/components/FormatFields.tsx index 93395d3d..ba862e3d 100644 --- a/src/components/TournamentForm/components/FormatFields.tsx +++ b/src/components/TournamentForm/components/FormatFields.tsx @@ -1,16 +1,11 @@ -import { useEffect } from 'react'; import { useFormContext } from 'react-hook-form'; -import { getTournamentPairingMethodOptions, TournamentPairingMethod } from '@ianpaschal/combat-command-game-systems/common'; import { Animate } from '~/components/generic/Animate'; import { FormField } from '~/components/generic/Form'; -import { InputSelect } from '~/components/generic/InputSelect'; import { InputText } from '~/components/generic/InputText'; -import { Label } from '~/components/generic/Label'; -import { Separator } from '~/components/generic/Separator'; -import { RankingFactorFields } from '~/components/TournamentForm/components/RankingFactorFields'; import { TournamentFormData } from '~/components/TournamentForm/TournamentForm.schema'; import { TournamentRoundStructure } from '~/components/TournamentRoundStructure'; +import { ALLOWED_EDIT_STATUSES } from '../TournamentForm.utils'; import styles from './FormatFields.module.scss'; @@ -21,58 +16,36 @@ export interface FormatFieldsProps { export const FormatFields = ({ status = 'draft', }: FormatFieldsProps): JSX.Element => { - const { watch, setValue } = useFormContext(); - const { roundStructure, competitorSize, pairingMethod } = watch(); + const { watch } = useFormContext(); + const [roundStructure, competitorSize] = watch(['roundStructure', 'competitorSize']); const isTeam = competitorSize > 1; - useEffect(() => { - if (pairingMethod === TournamentPairingMethod.AdjacentAlignment) { - setValue('registrationDetails.alignment', 'required'); - } - }, [pairingMethod, setValue]); - // Once a tournament is active, lock some fields - const allowedEditStatuses = ['draft', 'published']; - const disableFields = !allowedEditStatuses.includes(status); + const disableFields = !ALLOWED_EDIT_STATUSES.includes(status); return (
-
-
- - - - -
-
- - - - - - - - - - - -
+
+ + + +
- -
-
- - +
+ + + -

Combat Command uses a system of progressive tie breakers. Competitors are ranked according to the first ranking factor. If they are tied, the next ranking factor is compared until the tie is broken.

-
-
- - -
+ + + + + + +
); diff --git a/src/components/TournamentForm/components/PairingFields.module.scss b/src/components/TournamentForm/components/PairingFields.module.scss new file mode 100644 index 00000000..326a7e4e --- /dev/null +++ b/src/components/TournamentForm/components/PairingFields.module.scss @@ -0,0 +1,14 @@ +@use "/src/style/flex"; +@use "/src/style/variables"; + +.PairingFields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + row-gap: 2rem; + column-gap: 1.5rem; + + &_Config, + &_RankingFactors { + @include flex.column($gap: var(--form-field-gap)); + } +} diff --git a/src/components/TournamentForm/components/PairingFields.tsx b/src/components/TournamentForm/components/PairingFields.tsx new file mode 100644 index 00000000..57c7458b --- /dev/null +++ b/src/components/TournamentForm/components/PairingFields.tsx @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { TournamentPairingPolicy } from '@ianpaschal/combat-command-game-systems/common'; + +import { Label } from '~/components/generic/Label'; +import { RankingFactorFields } from '~/components/TournamentForm/components/RankingFactorFields'; +import { TournamentFormData } from '~/components/TournamentForm/TournamentForm.schema'; +import { TournamentPairingConfigFields } from '~/components/TournamentPairingConfigForm'; +import { ALLOWED_EDIT_STATUSES } from '../TournamentForm.utils'; + +import styles from './PairingFields.module.scss'; + +export interface PairingFieldsProps { + status?: 'draft' | 'published' | 'active' | 'archived'; +} + +export const PairingFields = ({ + status = 'draft', +}: PairingFieldsProps): JSX.Element => { + const { watch, setValue } = useFormContext(); + const pairingConfig = watch('pairingConfig'); + + useEffect(() => { + if (pairingConfig.policies.sameAlignment !== TournamentPairingPolicy.Allow) { + setValue('registrationDetails.alignment', 'required'); + } + }, [pairingConfig, setValue]); + + const disabled = !ALLOWED_EDIT_STATUSES.includes(status); + + return ( +
+
+ + +
+
+ + +

Combat Command uses a system of progressive tie breakers. Competitors are ranked according to the first ranking factor. If they are tied, the next ranking factor is compared until the tie is broken.

+
+
+ ); +}; diff --git a/src/components/TournamentForm/components/RankingFactorFields.tsx b/src/components/TournamentForm/components/RankingFactorFields.tsx index 4a1c7418..9a7bd967 100644 --- a/src/components/TournamentForm/components/RankingFactorFields.tsx +++ b/src/components/TournamentForm/components/RankingFactorFields.tsx @@ -6,6 +6,7 @@ import { RankingFactor } from '~/api'; import { Button } from '~/components/generic/Button'; import { InputSelect } from '~/components/generic/InputSelect'; import { TournamentFormData } from '~/components/TournamentForm/TournamentForm.schema'; +import { ALLOWED_EDIT_STATUSES } from '../TournamentForm.utils'; import styles from './RankingFactorFields.module.scss'; @@ -13,10 +14,12 @@ const isValidFactor = (factor: string | number | null | undefined): factor is Ra export interface RankingFactorFieldsProps { status?: 'draft' | 'published' | 'active' | 'archived'; + disabled?: boolean; } export const RankingFactorFields = ({ status = 'draft', + disabled = false, }: RankingFactorFieldsProps): JSX.Element => { const { watch, setValue } = useFormContext(); const rankingFactors = watch('rankingFactors'); @@ -41,9 +44,7 @@ export const RankingFactorFields = ({ ]); }; - // Once a tournament is active, lock some fields - const allowedEditStatuses = ['draft', 'published']; - const disableFields = !allowedEditStatuses.includes(status); + const disableFields = disabled || !ALLOWED_EDIT_STATUSES.includes(status); return (
diff --git a/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx b/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx index f0895d97..7e6c19eb 100644 --- a/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx +++ b/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx @@ -1,8 +1,4 @@ -import { - GameSystem, - getGameSystemDisplayName, - getTournamentPairingMethodDisplayName, -} from '@ianpaschal/combat-command-game-systems/common'; +import { GameSystem, getGameSystemDisplayName } from '@ianpaschal/combat-command-game-systems/common'; import clsx from 'clsx'; import { format } from 'date-fns'; import { @@ -16,10 +12,10 @@ import { } from 'lucide-react'; import { GameSystemTypeGuard } from '~/components/GameSystemTypeGuard'; -import { getLocalizedCompetitorFee } from '~/components/TournamentInfoBlock/TournamentInfoBlock.utils'; import { useTournament } from '~/components/TournamentProvider'; import { FlamesOfWarV4InfoLine } from './gameSystems/FlamesOfWarV4InfoLine'; import { TeamYankeeV2InfoLine } from './gameSystems/TeamYankeeV2InfoLine'; +import { getLocalizedCompetitorFee, getPairingMethod } from './TournamentInfoBlock.utils'; import styles from './TournamentInfoBlock.module.scss'; @@ -89,7 +85,7 @@ export const TournamentInfoBlock = ({
{`${tournament.roundCount} rounds`} - {`${getTournamentPairingMethodDisplayName(tournament.pairingMethod)} pairing`} + {`${getPairingMethod(tournament)} pairing`}
{tournament.rulesPackUrl && (
diff --git a/src/components/TournamentInfoBlock/TournamentInfoBlock.utils.ts b/src/components/TournamentInfoBlock/TournamentInfoBlock.utils.ts index e84934b6..676a9b84 100644 --- a/src/components/TournamentInfoBlock/TournamentInfoBlock.utils.ts +++ b/src/components/TournamentInfoBlock/TournamentInfoBlock.utils.ts @@ -1,3 +1,9 @@ +import { + getTournamentPairingMethodByConfig, + getTournamentPairingMethodDisplayName, + TournamentPairingMethod, +} from '@ianpaschal/combat-command-game-systems/common'; + import { Tournament } from '~/api'; export const getLocalizedCompetitorFee = (tournament: Tournament): string => { @@ -12,3 +18,11 @@ export const getLocalizedCompetitorFee = (tournament: Tournament): string => { }).format(amount / 100 ); return `${localizedFee} per ${tournament.useTeams ? 'team' : 'player'}`; }; + +export const getPairingMethod = (tournament: Tournament): string => { + if (!tournament.pairingConfig) { // TODO: Remove after migration + return 'Unknown'; + } + const method = getTournamentPairingMethodByConfig(tournament.pairingConfig); + return getTournamentPairingMethodDisplayName(method ?? TournamentPairingMethod.Custom) ?? 'Unknown'; +}; diff --git a/src/components/TournamentPairingConfigForm/TournamentPairingConfigFields.hooks.ts b/src/components/TournamentPairingConfigForm/TournamentPairingConfigFields.hooks.ts new file mode 100644 index 00000000..c8ee6764 --- /dev/null +++ b/src/components/TournamentPairingConfigForm/TournamentPairingConfigFields.hooks.ts @@ -0,0 +1,44 @@ +import { + FieldPathByValue, + FieldValues, + useFormContext, +} from 'react-hook-form'; +import { SelectOption, SelectValue } from '@ianpaschal/combat-command-components'; +import { + getTournamentPairingConfigByMethod, + getTournamentPairingMethodByConfig, + getTournamentPairingMethodOptions, + TournamentPairingConfig, + TournamentPairingMethod, +} from '@ianpaschal/combat-command-game-systems/common'; + +export type UsePresetFieldResult = { + value: TournamentPairingMethod | null; + onChange: (value: SelectValue | null) => void; + options: SelectOption[]; +}; + +export const usePresetField = ( + path?: FieldPathByValue, +): UsePresetFieldResult => { + const { watch, setValue, reset } = useFormContext(); + const currentConfig = (path ? watch(path) : watch()) as unknown as TournamentPairingConfig; + return { + value: getTournamentPairingMethodByConfig(currentConfig), + onChange: (value: string | number | null) => { + if (!value || typeof value !== 'string') { + return; + } + const config = getTournamentPairingConfigByMethod(value); + if (!config) { + return; + } + if (path) { + setValue(path as Parameters[0], config as Parameters[1]); + } else { + reset(config as unknown as TFormValues); + } + }, + options: getTournamentPairingMethodOptions(), + }; +}; diff --git a/src/components/TournamentPairingConfigForm/TournamentPairingConfigFields.module.scss b/src/components/TournamentPairingConfigForm/TournamentPairingConfigFields.module.scss new file mode 100644 index 00000000..ce3a4dc9 --- /dev/null +++ b/src/components/TournamentPairingConfigForm/TournamentPairingConfigFields.module.scss @@ -0,0 +1,14 @@ +@use "/src/style/flex"; +@use "/src/style/variables"; + +.TournamentPairingConfigFields { + @include flex.column; + + &_TabsList { + margin-bottom: 1rem; + } + + &_TabsContent { + @include flex.column; + } +} diff --git a/src/components/TournamentPairingConfigForm/TournamentPairingConfigFields.tsx b/src/components/TournamentPairingConfigForm/TournamentPairingConfigFields.tsx new file mode 100644 index 00000000..8dbb2946 --- /dev/null +++ b/src/components/TournamentPairingConfigForm/TournamentPairingConfigFields.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { + FieldPathByValue, + FieldValues, + get, + useFormContext, +} from 'react-hook-form'; +import { Select } from '@ianpaschal/combat-command-components'; +import { + getTournamentPairingOrderMethodOptions, + getTournamentPairingPolicyOptions, + TournamentPairingConfig, + TournamentPairingPolicy, +} from '@ianpaschal/combat-command-game-systems/common'; +import clsx from 'clsx'; + +import { FormField } from '~/components/generic/Form'; +import { + Tabs, + TabsContent, + TabsList, +} from '~/components/generic/Tabs'; +import { Warning } from '~/components/generic/Warning'; +import { usePresetField } from './TournamentPairingConfigFields.hooks'; + +import styles from './TournamentPairingConfigFields.module.scss'; + +const TABS = [ + { value: 'preset', label: 'Preset' }, + { value: 'advanced', label: 'Advanced' }, +]; + +const ORDER_BY_OPTIONS = getTournamentPairingOrderMethodOptions(); +// FIXME: Avoid is not yet supported on the back-end, so hide it in the front-end for now. +const POLICY_OPTIONS = getTournamentPairingPolicyOptions().filter((option) => option.value !== TournamentPairingPolicy.Avoid); + +export interface TournamentPairingConfigFieldsProps { + className?: string; + disabled?: boolean; + loading?: boolean; + path?: FieldPathByValue; +} + +export const TournamentPairingConfigFields = ({ + className, + disabled = false, + loading = false, + path, +}: TournamentPairingConfigFieldsProps): JSX.Element => { + const { formState: { errors } } = useFormContext(); + const [tab, setTab] = useState('preset'); + + const presetFieldProps = usePresetField(path); + + const pathPrefix = (name: string): string => path ? `${path}.${name}` : name; + const fieldProps = { disabled, loading }; + const error = path ? get(errors, path) : errors; + const errorMessage = (error?.message ?? error?.root?.message) as string | undefined; + + return ( +
+ + + + + + + + + + + + + {errorMessage && } +
+ ); +}; diff --git a/src/components/TournamentPairingConfigForm/TournamentPairingConfigForm.tsx b/src/components/TournamentPairingConfigForm/TournamentPairingConfigForm.tsx new file mode 100644 index 00000000..14317b28 --- /dev/null +++ b/src/components/TournamentPairingConfigForm/TournamentPairingConfigForm.tsx @@ -0,0 +1,70 @@ +import { useEffect } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { + TournamentPairingConfig, + tournamentPairingConfig, + tournamentPairingConfigSchema, +} from '@ianpaschal/combat-command-game-systems/common'; + +import { Form } from '~/components/generic/Form'; +import { validateForm } from '~/utils/validateForm'; +import { TournamentPairingConfigFields } from './TournamentPairingConfigFields'; + +export interface TournamentPairingConfigFormProps { + className?: string; + disabled?: boolean; + forcedValues?: Partial; + existingValues?: Partial; + id?: string; + loading?: boolean; + onSubmit?: (data: TournamentPairingConfig) => void; + setDirty?: (dirty: boolean) => void; +} + +export const TournamentPairingConfigForm = ({ + className, + disabled = false, + forcedValues, + existingValues, + id, + loading = false, + onSubmit, + setDirty, +}: TournamentPairingConfigFormProps): JSX.Element => { + const form = useForm({ + defaultValues: { + ...tournamentPairingConfig.defaultValues, + ...existingValues, + ...forcedValues, + }, + mode: 'onSubmit', + }); + + // Track form dirty state and notify parent: + useEffect(() => { + setDirty?.(form.formState.isDirty); + }, [form.formState.isDirty, setDirty]); + + const handleSubmit: SubmitHandler = async (formData): Promise => { + const validFormData = validateForm(tournamentPairingConfigSchema, formData, form.setError); + if (validFormData) { + onSubmit?.(validFormData); + } + }; + + const showLoading = [ + loading, + ].some((l) => !!l); + + return ( +
+ + + ); +}; diff --git a/src/components/TournamentPairingConfigForm/index.ts b/src/components/TournamentPairingConfigForm/index.ts new file mode 100644 index 00000000..90b4c014 --- /dev/null +++ b/src/components/TournamentPairingConfigForm/index.ts @@ -0,0 +1,6 @@ +export { + TournamentPairingConfigFields, +} from './TournamentPairingConfigFields'; +export { + TournamentPairingConfigForm, +} from './TournamentPairingConfigForm'; diff --git a/src/components/generic/Checkbox/Checkbox.tsx b/src/components/generic/Checkbox/Checkbox.tsx index 4482757b..6e60529e 100644 --- a/src/components/generic/Checkbox/Checkbox.tsx +++ b/src/components/generic/Checkbox/Checkbox.tsx @@ -16,19 +16,21 @@ const cn = createCn('Checkbox'); type CheckboxRef = ElementRef; type CheckboxProps = Omit, 'value' | 'onChange'> & { - value?: boolean; + loading?: boolean; onChange?: (checked: boolean) => void; size?: ElementSize; + value?: boolean; variant?: ElementVariant; }; export const Checkbox = forwardRef(({ className, disabled, + loading: _, + onChange, size = 'tiny', - variant = 'solid', value, - onChange, + variant = 'primary', ...props }, ref) => ( { const { control, formState: { errors } } = useFormContext(); @@ -40,12 +46,14 @@ export const FormField = ({ const error = get(errors, name); const showErrorState = !!error; const nonTextual = isValidElement(children) && ['Switch', 'Checkbox'].includes(getComponentName(children)); + const isHorizontal = orientation ? orientation === 'horizontal' : nonTextual; return (
{(name && control) ? ( @@ -65,6 +73,7 @@ export const FormField = ({ 'aria-invalid': showErrorState, 'aria-label': name, disabled: formDisabled || disabled, + loading, }) )} name={name} @@ -78,6 +87,7 @@ export const FormField = ({ ...props, className: clsx(styles.Input), disabled: formDisabled || disabled, + loading, }) )} {description && ( diff --git a/src/components/generic/Switch/Switch.scss b/src/components/generic/Switch/Switch.scss index ae6cb1fd..3fc42446 100644 --- a/src/components/generic/Switch/Switch.scss +++ b/src/components/generic/Switch/Switch.scss @@ -14,6 +14,8 @@ $thumb-offset: calc($switch-width - ($thumb-inset + $thumb-size)); position: relative; + flex-shrink: 0; + width: $switch-width; height: $switch-height; diff --git a/src/hooks/useAsyncState.ts b/src/hooks/useAsyncState.ts index 60da428c..f659afae 100644 --- a/src/hooks/useAsyncState.ts +++ b/src/hooks/useAsyncState.ts @@ -4,6 +4,7 @@ import { useEffect, useState, } from 'react'; +import isEqual from 'fast-deep-equal/es6'; export const useAsyncState = ( defaultValue: T, @@ -11,7 +12,7 @@ export const useAsyncState = ( ): [T, Dispatch>] => { const [value, setValue] = useState(asyncValue ?? defaultValue); useEffect(() => { - if (asyncValue && asyncValue !== value) { + if (asyncValue && !isEqual(asyncValue, value)) { setValue(asyncValue); } }, [value, asyncValue]); diff --git a/src/pages/DashboardPage/components/ActiveTournament/ActiveTournament.utils.tsx b/src/pages/DashboardPage/components/ActiveTournament/ActiveTournament.utils.tsx index aa247343..2edcd873 100644 --- a/src/pages/DashboardPage/components/ActiveTournament/ActiveTournament.utils.tsx +++ b/src/pages/DashboardPage/components/ActiveTournament/ActiveTournament.utils.tsx @@ -24,12 +24,12 @@ export const getOpponent = (userId?: UserId, pairing?: TournamentPairing): Tourn return null; } - const competitor0UserIds = pairing.tournamentCompetitor0.registrations.map((r) => r.user?._id); + const competitor0UserIds = (pairing.tournamentCompetitor0?.registrations ?? []).map((r) => r.userId); if (competitor0UserIds.includes(userId)) { return pairing.tournamentCompetitor1; } - const competitor1UserIds = (pairing.tournamentCompetitor1?.registrations ?? []).map((r) => r.user?._id); + const competitor1UserIds = (pairing.tournamentCompetitor1?.registrations ?? []).map((r) => r.userId); if (competitor1UserIds.includes(userId)) { return pairing.tournamentCompetitor0; } diff --git a/src/pages/DashboardPage/components/TournamentsCard/TournamentsCard.utils.ts b/src/pages/DashboardPage/components/TournamentsCard/TournamentsCard.utils.ts deleted file mode 100644 index 33dbec44..00000000 --- a/src/pages/DashboardPage/components/TournamentsCard/TournamentsCard.utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - TournamentCompetitor, - TournamentPairing, - UserId, -} from '~/api'; - -export const getOpponent = (userId?: UserId, pairing?: TournamentPairing): TournamentCompetitor | null => { - - if (!userId || !pairing) { - return null; - } - - const competitor0UserIds = pairing.tournamentCompetitor0.registrations.map((r) => r.user?._id); - if (competitor0UserIds.includes(userId)) { - return pairing.tournamentCompetitor1; - } - const competitor1UserIds = pairing.tournamentCompetitor0.registrations.map((r) => r.user?._id); - if (competitor1UserIds.includes(userId)) { - return pairing.tournamentCompetitor0; - } - - return null; -}; diff --git a/src/pages/TournamentPairingsPage/TournamentPairingsPage.module.scss b/src/pages/TournamentPairingsPage/TournamentPairingsPage.module.scss index b0e920e1..8eb95fcc 100644 --- a/src/pages/TournamentPairingsPage/TournamentPairingsPage.module.scss +++ b/src/pages/TournamentPairingsPage/TournamentPairingsPage.module.scss @@ -8,28 +8,36 @@ .TournamentPairingsPage { @include flex.column; - @include variants.card; - &_Header { - @include flex.row; + &_Config { + @include flex.column($gap: 1.5rem); + @include variants.card; - padding: var(--container-padding-y) var(--container-padding-x) 0; - } + grid-area: config; + padding: var(--container-padding-y) var(--container-padding-x); - &_TableRow { - padding: 0 var(--container-padding-x); + &_Submit { + align-self: flex-end; + } } - &_Form { - display: grid; - grid-template-areas: - ". tableHeader competitorsHeader" - "alerts tableInputs competitorsGrid"; - grid-template-columns: auto 6rem 1fr; - grid-template-rows: auto auto; - gap: 1rem; + &_Pairings { + @include flex.column($gap: 1.5rem); + @include variants.card; - padding: 0 var(--container-padding-x) var(--container-padding-y); + grid-area: pairings; + padding: var(--container-padding-y) var(--container-padding-x); + + &_Grid { + display: grid; + grid-template-areas: + ". tableHeader competitorsHeader" + "alerts tableInputs competitorsGrid"; + grid-template-columns: auto 6rem 1fr; + grid-template-rows: auto auto; + flex-grow: 1; + gap: 1rem; + } &_Alerts { display: grid; @@ -94,3 +102,70 @@ } } } + +// 3 x 320px + 4 x 16px +@media (width >= 1024px) { + .TournamentPairingsPage { + display: grid; + grid-template-areas: "config pairings pairings"; + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: auto; + gap: 1rem; + align-items: start; + } +} + +.DetailsScrollArea { + @include flex.stretchy; +} + +.ConfirmPairingsDialog { + padding-bottom: var(--container-padding-y); + + &_Content { + margin: 0 var(--container-padding-x); + } + + &_Filters { + @include flex.row; + } + + &_EmptyState { + @include flex.stretchy; + @include flex.centered; + @include text.ui($muted: true); + + padding-bottom: 4rem; + } + + &_FirstColumn { + padding-left: var(--container-padding-x); + } + + &_LastColumn { + padding-right: var(--container-padding-x); + } + + &_Table { + --cell-padding-y: 1rem; + --table-padding: var(--container-padding-x); + } + + &_TableNumber { + @include text.ui; + + font-size: 1.5rem; + font-weight: 300; + line-height: 1.75rem; + } + + &_MatchIndicatorIcon { + width: 1.25rem; + height: 1.25rem; + stroke: currentcolor; + } + + &_MatchIndicatorInner { + @include text.ui; + } +} diff --git a/src/pages/TournamentPairingsPage/TournamentPairingsPage.tsx b/src/pages/TournamentPairingsPage/TournamentPairingsPage.tsx index 9fa245c0..160ce533 100644 --- a/src/pages/TournamentPairingsPage/TournamentPairingsPage.tsx +++ b/src/pages/TournamentPairingsPage/TournamentPairingsPage.tsx @@ -1,9 +1,4 @@ -import { - MouseEvent, - useCallback, - useEffect, - useState, -} from 'react'; +import { MouseEvent } from 'react'; import { useFieldArray, useForm, @@ -16,37 +11,36 @@ import { } from 'react-router-dom'; import { UniqueIdentifier } from '@dnd-kit/core'; import { zodResolver } from '@hookform/resolvers/zod'; -import { getTournamentPairingMethodOptions, TournamentPairingMethod } from '@ianpaschal/combat-command-game-systems/common'; +import { Table } from '@ianpaschal/combat-command-components'; +import { TournamentPairingConfig, TournamentPairingPolicy } from '@ianpaschal/combat-command-game-systems/common'; -import { - DraftTournamentPairing, - TournamentId, - TournamentPairingOptions, -} from '~/api'; -import { ConfirmationDialog, useConfirmationDialog } from '~/components/ConfirmationDialog'; +import { DraftTournamentPairing, TournamentId } from '~/api'; import { Button } from '~/components/generic/Button'; import { InfoPopover } from '~/components/generic/InfoPopover'; import { InputSelect } from '~/components/generic/InputSelect'; import { Label } from '~/components/generic/Label'; import { Pulsar } from '~/components/generic/Pulsar'; -import { Separator } from '~/components/generic/Separator'; import { SortableGrid } from '~/components/generic/SortableGrid'; +import { Warning } from '~/components/generic/Warning'; import { PageWrapper } from '~/components/PageWrapper'; import { toast } from '~/components/ToastProvider'; import { TournamentCompetitorsProvider } from '~/components/TournamentCompetitorsProvider'; +import { TournamentPairingConfigForm } from '~/components/TournamentPairingConfigForm'; import { TournamentProvider } from '~/components/TournamentProvider'; -import { ConfirmPairingsDialog } from '~/pages/TournamentPairingsPage/components/ConfirmPairingsDialog'; +import { useAsyncState } from '~/hooks/useAsyncState'; +import { useDialogInstance } from '~/hooks/useDialogInstance'; import { useGetTournamentCompetitorsByTournament } from '~/services/tournamentCompetitors'; -import { useCreateTournamentPairings, useGetDraftTournamentPairings } from '~/services/tournamentPairings'; -import { useGetTournament } from '~/services/tournaments'; -import { PATHS } from '~/settings'; import { - FormData, - sanitize, - schema, -} from './TournamentPairingsPage.schema'; + useCreateTournamentPairings, + useGenerateDraftTournamentPairings, + useGenerateTableAssignments, +} from '~/services/tournamentPairings'; +import { useGetTournament } from '~/services/tournaments'; +import { MAX_WIDTH, PATHS } from '~/settings'; +import { FormData, schema } from './TournamentPairingsPage.schema'; import { flattenPairings, + getConfirmDialogTableColumns, getPairingsStatuses, renderCompetitorCard, updatePairings, @@ -54,7 +48,11 @@ import { import styles from './TournamentPairingsPage.module.scss'; -const WIDTH = 800; +const STATUS_COLORS: Record<'error' | 'warning' | 'ok', 'red' | 'yellow' | 'green'> = { + error: 'red', + warning: 'yellow', + ok: 'green', +}; export const TournamentPairingsPage = (): JSX.Element => { const params = useParams(); @@ -63,67 +61,67 @@ export const TournamentPairingsPage = (): JSX.Element => { const tournamentId = params.id! as TournamentId; // Must exist or else how did we get to this route? const { data: tournament } = useGetTournament({ id: tournamentId }); const lastRound = tournament?.lastRound ?? -1; + const nextRound = lastRound + 1; + + const [pairingConfig, setPairingConfig] = useAsyncState({ + orderBy: 'ranking', + policies: { + sameAlignment: TournamentPairingPolicy.Allow, + repeat: TournamentPairingPolicy.Block, + }, + }, tournament?.pairingConfig); + + const { open: openConfirmRegenerateDialog } = useDialogInstance(); + const { open: openConfirmCancelDialog } = useDialogInstance(); + const { open: openConfirmCreateDialog } = useDialogInstance(); const { data: tournamentCompetitors } = useGetTournamentCompetitorsByTournament({ tournamentId, rankingRound: lastRound, }); - const isFirstRound = (tournament?.lastRound ?? -1) < 0; - const defaultPairingMethod = isFirstRound ? ( - TournamentPairingMethod.Random - ) : ( - tournament?.pairingMethod ?? TournamentPairingMethod.Adjacent - ); - const [pairingMethod, setPairingMethod] = useState(defaultPairingMethod); - - const pairingOptions: TournamentPairingOptions = { - allowRepeats: false, - allowSameAlignment: pairingMethod !== TournamentPairingMethod.AdjacentAlignment, - }; - - const round = lastRound + 1; - const { data: generatedPairings } = useGetDraftTournamentPairings(tournament ? { - method: pairingMethod === TournamentPairingMethod.AdjacentAlignment ? TournamentPairingMethod.Adjacent : pairingMethod, - tournamentId, - round, - options: pairingOptions, - } : 'skip'); + const { action: generateTournamentPairings } = useGenerateDraftTournamentPairings({ + onSuccess: (pairings): void => form.reset({ pairings }), + }); + const { action: generateTableAssignments } = useGenerateTableAssignments({ + onSuccess: (pairings): void => openConfirmCreateDialog({ + title: 'Confirm Pairings', + disablePadding: true, + content: ( + <> +

+ The following pairings will be created: +

+ + + Once created, pairings cannot be edited. Please ensure all competitors are present and ready to play! + + + ), + actions: [{ + text: 'Create', + onClick: () => handleConfirmCreate(pairings), + }], + }), + }); const { mutation: createTournamentPairings } = useCreateTournamentPairings({ onSuccess: (): void => { - toast.success(`Round ${round + 1} pairings created!`); + toast.success(`Round ${nextRound + 1} pairings created!`); navigate(`${generatePath(PATHS.tournamentDetails, { id: tournamentId })}?tab=pairings`); }, }); - const { - id: confirmChangePairingMethodDialogId, - open: openConfirmChangePairingMethodDialog, - } = useConfirmationDialog(); - const { - id: confirmResetPairingsDialogId, - open: openConfirmResetPairingsDialog, - } = useConfirmationDialog(); - const { - id: confirmPairingsDialogId, - open: openConfirmPairingsDialog, - } = useConfirmationDialog(); - const form = useForm({ resolver: zodResolver(schema), defaultValues: { - pairings: sanitize(generatedPairings), + pairings: [], }, mode: 'onSubmit', }); - const reset = useCallback((pairings: unknown[]) => form.reset({ - pairings: sanitize(pairings), - }), [form]); - useEffect(() => { - if (tournament && generatedPairings) { - reset(generatedPairings); - } - }, [tournament, generatedPairings, reset]); const { fields } = useFieldArray({ control: form.control, name: 'pairings', @@ -150,47 +148,72 @@ export const TournamentPairingsPage = (): JSX.Element => { updatePairings(items, form.reset); }; - const handleChangePairingMethod = (value: TournamentPairingMethod): void => { + const handleRegenerate = async (config: TournamentPairingConfig): Promise => { + const onConfirm = async (): Promise => { + setPairingConfig(config); + await generateTournamentPairings({ + tournamentId: tournament._id, + round: nextRound, + config, + }); + }; if (form.formState.isDirty) { - openConfirmChangePairingMethodDialog({ - onConfirm: () => setPairingMethod(value), + openConfirmRegenerateDialog({ + title: 'Regenerate Pairings', + content: 'Your current pairings will be lost. Are you sure you want to regenerate?', + actions: [ + { + intent: 'danger', + onClick: onConfirm, + text: 'Regenerate', + }, + ], }); } else { - setPairingMethod(value); - } - }; - - const handleReset = (): void => { - if (generatedPairings) { - if (form.formState.isDirty) { - openConfirmResetPairingsDialog({ - onConfirm: () => reset(generatedPairings), - }); - } else { - reset(generatedPairings); - } + onConfirm(); } }; const handleCancel = (_e: MouseEvent): void => { - // TODO: If dirty, open confirmation dialog - navigate(-1); + const onConfirm = () => navigate(-1); + if (form.formState.isDirty) { + openConfirmCancelDialog({ + title: 'Are you sure you want to cancel?', + content: 'Your current pairings will be lost.', + actions: [ + { + intent: 'danger', + onClick: onConfirm, + text: 'Cancel', + }, + ], + }); + } else { + onConfirm(); + } }; - const handleProceed = (_e: MouseEvent): void => { - openConfirmPairingsDialog(); + const handleProceed = async (_: MouseEvent): Promise => { + await generateTableAssignments({ + tournamentId, + pairings, + }); }; - const handleConfirm = async (pairings: DraftTournamentPairing[]): Promise => { - await createTournamentPairings({ tournamentId, round, pairings }); + const handleConfirmCreate = async (pairings: DraftTournamentPairing[]): Promise => { + await createTournamentPairings({ + tournamentId: tournament._id, + round: nextRound, + pairings, + }); }; - const pairingStatuses = getPairingsStatuses(pairingOptions, tournamentCompetitors, pairings); + const pairingStatuses = getPairingsStatuses(pairingConfig, tournamentCompetitors, pairings); return ( @@ -204,6 +227,7 @@ export const TournamentPairingsPage = (): JSX.Element => { key="proceed" text="Proceed" onClick={handleProceed} + disabled={pairings.length < 1} /> } @@ -211,79 +235,58 @@ export const TournamentPairingsPage = (): JSX.Element => {
-
- - +

Configuration

+
- -
-
- {fields.map((field, i) => { - const { status, message } = pairingStatuses[i]; - const statusColors: Record = { - 'error': 'red', - 'warning': 'yellow', - 'ok': 'green', - }; - return ( - - - - ); - })} -
- -
- {fields.map((field, i) => ( -
- form.setValue(`pairings.${i}.table`, value as number, { shouldDirty: true })} - /> -
- ))} +
+

Pairings

+
+
+ {fields.map((field, i) => { + const { status, message } = pairingStatuses[i] ?? { status: 'ok' as const, message: '' }; + return ( + + + + ); + })} +
+ +
+ {fields.map((field, i) => ( +
+ form.setValue(`pairings.${i}.table`, value as number, { shouldDirty: true })} + /> +
+ ))} +
+ + renderCompetitorCard(id, state, tournamentCompetitors)} + />
- - renderCompetitorCard(id, state, tournamentCompetitors)} - />
- - - diff --git a/src/pages/TournamentPairingsPage/TournamentPairingsPage.utils.tsx b/src/pages/TournamentPairingsPage/TournamentPairingsPage.utils.tsx index bf90548b..d2478b9e 100644 --- a/src/pages/TournamentPairingsPage/TournamentPairingsPage.utils.tsx +++ b/src/pages/TournamentPairingsPage/TournamentPairingsPage.utils.tsx @@ -1,17 +1,20 @@ import { ReactElement } from 'react'; import { UseFormReset } from 'react-hook-form'; import { UniqueIdentifier } from '@dnd-kit/core'; +import { ColumnDef } from '@ianpaschal/combat-command-components'; +import { TournamentPairingConfig } from '@ianpaschal/combat-command-game-systems/common'; import { + DraftTournamentPairing, TournamentCompetitor, TournamentCompetitorId, - TournamentPairingOptions, TournamentPairingStatus, validateTournamentPairing, } from '~/api'; import { FactionIndicator } from '~/components/FactionIndicator'; import { IdentityBadge } from '~/components/IdentityBadge'; import { TournamentCompetitorIdentity } from '~/components/TournamentCompetitorIdentity'; +import { TournamentPairingRow } from '~/components/TournamentPairingRow'; import { FormData, TournamentPairingFormItem } from './TournamentPairingsPage.schema'; import styles from './TournamentPairingsPage.module.scss'; @@ -54,7 +57,7 @@ export const flattenPairings = ( * @returns Array of TournamentPairingStatus for each pairing */ export const getPairingsStatuses = ( - options: TournamentPairingOptions, + config: TournamentPairingConfig, rankedCompetitors: TournamentCompetitor[], pairings: TournamentPairingFormItem[], ): TournamentPairingStatus[] => { @@ -86,7 +89,7 @@ export const getPairingsStatuses = ( } // Use universal validator for all other checks - return validateTournamentPairing(options, competitorA, competitorB, pairing.table); + return validateTournamentPairing(config.policies, competitorA, competitorB, pairing.table); }); }; @@ -108,7 +111,7 @@ export const renderCompetitorCard = ( if (!tournamentCompetitors) { return ( -
+
); @@ -118,9 +121,9 @@ export const renderCompetitorCard = ( if (!rankedCompetitor) { return ( -
+
@@ -130,15 +133,54 @@ export const renderCompetitorCard = ( const rank = (rankedCompetitor.rank ?? -1) < 0 ? '-' : rankedCompetitor.rank + 1; return ( -
-
+
+
{rank}
); }; + +export const getConfirmDialogTableColumns = (competitors: TournamentCompetitor[]): ColumnDef[] => { + const competitorMap = new Map( + competitors.map((c) => [c._id, c]), + ); + return [ + { + key: 'table', + label: 'Table', + width: 'auto', + xAlign: 'center', + renderCell: (r) => ( +
+ {r.table === null ? '-' : r.table + 1} +
+ ), + }, + { + key: 'pairing', + label: 'Pairing', + width: '1fr', + xAlign: 'center', + renderCell: (r) => { + const tournamentCompetitor0 = competitorMap.get(r.tournamentCompetitor0Id) ?? null; + const tournamentCompetitor1 = competitorMap.get(r.tournamentCompetitor1Id) ?? null; + if (!tournamentCompetitor0 && !tournamentCompetitor1) { + return null; + } + return ( + + ); + }, + }, + ]; +}; diff --git a/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss deleted file mode 100644 index 1584048c..00000000 --- a/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.module.scss +++ /dev/null @@ -1,54 +0,0 @@ -@use "/src/style/variables"; -@use "/src/style/flex"; -@use "/src/style/text"; - -.ConfirmPairingsDialog { - padding-bottom: var(--container-padding-y); - - &_Content { - margin: 0 var(--container-padding-x); - } - - &_Filters { - @include flex.row; - } - - &_EmptyState { - @include flex.stretchy; - @include flex.centered; - @include text.ui($muted: true); - - padding-bottom: 4rem; - } - - &_FirstColumn { - padding-left: var(--container-padding-x); - } - - &_LastColumn { - padding-right: var(--container-padding-x); - } - - &_Table { - --cell-padding-y: 1rem; - --table-padding: var(--container-padding-x); - } - - &_TableNumber { - @include text.ui; - - font-size: 1.5rem; - font-weight: 300; - line-height: 1.75rem; - } - - &_MatchIndicatorIcon { - width: 1.25rem; - height: 1.25rem; - stroke: currentcolor; - } - - &_MatchIndicatorInner { - @include text.ui; - } -} diff --git a/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx deleted file mode 100644 index 717caac1..00000000 --- a/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Table } from '@ianpaschal/combat-command-components'; - -import { DraftTournamentPairing, TournamentCompetitor } from '~/api'; -import { ConfirmationDialog } from '~/components/ConfirmationDialog'; -import { Warning } from '~/components/generic/Warning'; -import { useTournament } from '~/components/TournamentProvider'; -import { TournamentPairingFormItem } from '../../TournamentPairingsPage.schema'; -import { assignTables, getTableColumns } from './ConfirmPairingsDialog.utils'; - -import styles from './ConfirmPairingsDialog.module.scss'; - -export interface ConfirmPairingsDialogProps { - competitors: TournamentCompetitor[]; - id: string; - onConfirm: (assignedPairings: DraftTournamentPairing[]) => void; - pairings: TournamentPairingFormItem[]; -} - -export const ConfirmPairingsDialog = ({ - competitors, - id, - onConfirm, - pairings, -}: ConfirmPairingsDialogProps): JSX.Element => { - const { maxCompetitors } = useTournament(); - - const assignedPairings = assignTables(pairings.filter((pairing) => ( - pairing.tournamentCompetitor0Id || pairing.tournamentCompetitor1Id - )).map((pairing) => ({ - ...pairing, - playedTables: Array.from(new Set([ - ...competitors.find((c) => c._id === pairing.tournamentCompetitor0Id)?.playedTables ?? [], - ...competitors.find((c) => c._id === pairing.tournamentCompetitor1Id)?.playedTables ?? [], - ])), - })), Math.ceil(maxCompetitors / 2)); - - const handleConfirm = (): void => { - onConfirm(assignedPairings); - }; - - const columns = getTableColumns(competitors); - - return ( - -

- The following pairings will be created: -

-
- - Once created, pairings cannot be edited. Please ensure all competitors are present and ready to play! - - - ); -}; diff --git a/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.utils.test.ts b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.utils.test.ts deleted file mode 100644 index 837a4803..00000000 --- a/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.utils.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { - describe, - expect, - it, -} from 'vitest'; - -import { TournamentCompetitorId } from '~/api'; -import { TournamentPairingFormItem } from '../../TournamentPairingsPage.schema'; -import { assignTables } from './ConfirmPairingsDialog.utils'; - -describe('assignTables', () => { - it('assigns tables to basic pairings', () => { - const pairings: (TournamentPairingFormItem & { playedTables: (number | null)[] })[] = [ - { - table: -1, - tournamentCompetitor0Id: 'player1' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player2' as TournamentCompetitorId, - playedTables: [], - }, - { - table: -1, - tournamentCompetitor0Id: 'player3' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player4' as TournamentCompetitorId, - playedTables: [], - }, - ]; - - const result = assignTables(pairings, 4); - - expect(result).toHaveLength(2); - - // Tables should be assigned (could be any available tables due to randomization) - expect(result[0].table).toBeGreaterThanOrEqual(0); - expect(result[0].table).toBeLessThan(4); - expect(result[1].table).toBeGreaterThanOrEqual(0); - expect(result[1].table).toBeLessThan(4); - - // Should not have the same table: - expect(result[0].table).not.toBe(result[1].table); - }); - - it('handles byes correctly', () => { - const pairings: (TournamentPairingFormItem & { playedTables: (number | null)[] })[] = [ - { - table: -1, - tournamentCompetitor0Id: 'player1' as TournamentCompetitorId, - tournamentCompetitor1Id: null, - playedTables: [], - }, - ]; - - const result = assignTables(pairings, 4); - - expect(result).toHaveLength(1); - expect(result[0].table).toBe(null); - expect(result[0].tournamentCompetitor0Id).toBe('player1'); - expect(result[0].tournamentCompetitor1Id).toBe(null); - }); - - it('preserves pre-assigned tables', () => { - const pairings: (TournamentPairingFormItem & { playedTables: (number | null)[] })[] = [ - { - table: 1, - tournamentCompetitor0Id: 'player1' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player2' as TournamentCompetitorId, - playedTables: [], - }, - { - table: -1, - tournamentCompetitor0Id: 'player3' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player4' as TournamentCompetitorId, - playedTables: [], - }, - ]; - - const result = assignTables(pairings, 2); - - const preAssignedPairing = result.find( - (p) => p.tournamentCompetitor0Id === 'player1', - ); - expect(preAssignedPairing?.table).toBe(1); - - const newPairing = result.find( - (p) => p.tournamentCompetitor0Id === 'player3', - ); - expect(newPairing?.table).not.toBe(1); // Should get a different table - expect(newPairing?.table).toBeGreaterThanOrEqual(0); - }); - - it('sorts byes to the end', () => { - const pairings: (TournamentPairingFormItem & { playedTables: (number | null)[] })[] = [ - { - table: -1, - tournamentCompetitor0Id: 'player1' as TournamentCompetitorId, - tournamentCompetitor1Id: null, - playedTables: [], - }, - { - table: -1, - tournamentCompetitor0Id: 'player2' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player3' as TournamentCompetitorId, - playedTables: [], - }, - ]; - - const result = assignTables(pairings, 4); - - expect(result[0].table).not.toBe(null); // Regular pairing comes first - expect(result[1].table).toBe(null); // Bye comes last - }); - - it('never assigns the same table to two different pairings', () => { - const pairings: (TournamentPairingFormItem & { playedTables: (number | null)[] })[] = [ - { - table: -1, - tournamentCompetitor0Id: 'player1' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player2' as TournamentCompetitorId, - playedTables: [0, 1], - }, - { - table: -1, - tournamentCompetitor0Id: 'player3' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player4' as TournamentCompetitorId, - playedTables: [0, 1], - }, - ]; - - const result = assignTables(pairings, 2); - - expect(result).toHaveLength(2); - expect(result[0].table).not.toBe(result[1].table); - expect(result[0].table).toBeGreaterThanOrEqual(0); - expect(result[0].table).toBeLessThan(2); - expect(result[1].table).toBeGreaterThanOrEqual(0); - expect(result[1].table).toBeLessThan(2); - }); - - it('attempts to avoid tables players have already played on', () => { - const pairings: (TournamentPairingFormItem & { playedTables: (number | null)[] })[] = [ - { - table: -1, - tournamentCompetitor0Id: 'player1' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player2' as TournamentCompetitorId, - playedTables: [0], // Played on table 0 - }, - { - table: -1, - tournamentCompetitor0Id: 'player3' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player4' as TournamentCompetitorId, - playedTables: [1], // Played on table 1 - }, - ]; - - const result = assignTables(pairings, 4); - - const pairing1 = result.find((p) => p.tournamentCompetitor0Id === 'player1'); - const pairing2 = result.find((p) => p.tournamentCompetitor0Id === 'player3'); - - // With 4 tables and only 2 pairings, there's plenty of room to avoid conflicts - // Due to random assignment and swapping logic, we can't guarantee exact tables, - // but we can verify no duplicates - expect(pairing1?.table).not.toBe(pairing2?.table); - }); - - it('performs swaps to optimize table assignments', () => { - // Create a scenario where swapping would help: - // Player1+2 have played [0], Player3+4 have played [1] - // Using auto-assignment (table: -1) to test the swap optimization logic - const pairings: (TournamentPairingFormItem & { playedTables: (number | null)[] })[] = [ - { - table: -1, // Auto-assign so swap logic can optimize - tournamentCompetitor0Id: 'player1' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player2' as TournamentCompetitorId, - playedTables: [0], // Have played on table 0 - }, - { - table: -1, // Auto-assign so swap logic can optimize - tournamentCompetitor0Id: 'player3' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player4' as TournamentCompetitorId, - playedTables: [1], // Have played on table 1 - }, - ]; - - const result = assignTables(pairings, 2); - - const pairing1 = result.find((p) => p.tournamentCompetitor0Id === 'player1'); - const pairing2 = result.find((p) => p.tournamentCompetitor0Id === 'player3'); - - // With the optimization logic, tables should be assigned to avoid conflicts - // player1+2 should NOT be on table 0, player3+4 should NOT be on table 1 - expect(pairing1?.table).not.toBe(0); - expect(pairing2?.table).not.toBe(1); - }); - - it('handles complex scenarios with multiple rounds of play history', () => { - const pairings: (TournamentPairingFormItem & { playedTables: (number | null)[] })[] = [ - { - table: -1, - tournamentCompetitor0Id: 'player1' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player2' as TournamentCompetitorId, - playedTables: [0, 1, 2], // Played on tables 0, 1, 2 - }, - { - table: -1, - tournamentCompetitor0Id: 'player3' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player4' as TournamentCompetitorId, - playedTables: [1, 2, 3], // Played on tables 1, 2, 3 - }, - { - table: -1, - tournamentCompetitor0Id: 'player5' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player6' as TournamentCompetitorId, - playedTables: [0, 2, 4], // Played on tables 0, 2, 4 - }, - ]; - - const result = assignTables(pairings, 5); - - // All pairings should have unique tables - const tables = result.map((p) => p.table).filter((t) => t !== null); - const uniqueTables = new Set(tables); - expect(tables.length).toBe(uniqueTables.size); - - // Each pairing should have a valid table - result.forEach((pairing) => { - expect(pairing.table).toBeGreaterThanOrEqual(0); - expect(pairing.table).toBeLessThan(5); - }); - }); - - it('throws error when there are more pairings than tables', () => { - const pairings: (TournamentPairingFormItem & { playedTables: (number | null)[] })[] = [ - { - table: -1, - tournamentCompetitor0Id: 'player1' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player2' as TournamentCompetitorId, - playedTables: [], - }, - { - table: -1, - tournamentCompetitor0Id: 'player3' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player4' as TournamentCompetitorId, - playedTables: [], - }, - { - table: -1, - tournamentCompetitor0Id: 'player5' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player6' as TournamentCompetitorId, - playedTables: [], - }, - ]; - - // With only 2 tables and 3 pairings, an error should be thrown - expect(() => assignTables(pairings, 2)).toThrowError(); - }); - - it('ignores empty pairings', () => { - const pairings: (TournamentPairingFormItem & { playedTables: (number | null)[] })[] = [ - { - table: -1, - tournamentCompetitor0Id: null, - tournamentCompetitor1Id: null, - playedTables: [], - }, - { - table: -1, - tournamentCompetitor0Id: 'player1' as TournamentCompetitorId, - tournamentCompetitor1Id: 'player2' as TournamentCompetitorId, - playedTables: [], - }, - ]; - - const result = assignTables(pairings, 4); - - // Only the non-empty pairing should be returned - expect(result).toHaveLength(1); - expect(result[0].tournamentCompetitor0Id).toBe('player1'); - }); -}); diff --git a/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/index.ts b/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/index.ts deleted file mode 100644 index bb257aca..00000000 --- a/src/pages/TournamentPairingsPage/components/ConfirmPairingsDialog/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - ConfirmPairingsDialog, - type ConfirmPairingsDialogProps, -} from './ConfirmPairingsDialog'; diff --git a/src/services/auth/useUpdateEmail.ts b/src/services/auth/useUpdateEmail.ts index 1fdd92f9..7b48953e 100644 --- a/src/services/auth/useUpdateEmail.ts +++ b/src/services/auth/useUpdateEmail.ts @@ -4,7 +4,7 @@ import { ChangeEmailFormData } from '~/components/ChangeEmailDialog'; import { toast } from '~/components/ToastProvider'; import { handleError } from '~/services/utils/handleError'; -export const useUpdateEmail = () => useMutation({ +export const useUpdateEmail = () => useMutation({ mutationFn: async ({ email: _email }: ChangeEmailFormData): Promise => { // TODO: Implement Convex email update }, diff --git a/src/services/auth/useUpdatePassword.ts b/src/services/auth/useUpdatePassword.ts index 5107bd39..1d95273a 100644 --- a/src/services/auth/useUpdatePassword.ts +++ b/src/services/auth/useUpdatePassword.ts @@ -4,7 +4,7 @@ import { ChangePasswordFormData } from '~/components/ChangePasswordDialog'; import { toast } from '~/components/ToastProvider'; import { handleError } from '~/services/utils/handleError'; -export const useUpdatePassword = () => useMutation({ +export const useUpdatePassword = () => useMutation({ mutationFn: async ({ password: _password }: ChangePasswordFormData): Promise => { // TODO: Implement Convex password update }, diff --git a/src/services/tournamentPairings.ts b/src/services/tournamentPairings.ts index cc8115ef..c1b2665d 100644 --- a/src/services/tournamentPairings.ts +++ b/src/services/tournamentPairings.ts @@ -1,5 +1,9 @@ import { api } from '~/api'; -import { createMutationHook, createQueryHook } from '~/services/utils'; +import { + createActionHook, + createMutationHook, + createQueryHook, +} from '~/services/utils'; // Basic Queries export const useGetTournamentPairing = createQueryHook(api.tournamentPairings.getTournamentPairing); @@ -11,3 +15,7 @@ export const useGetDraftTournamentPairings = createQueryHook(api.tournamentPairi // Mutations export const useCreateTournamentPairings = createMutationHook(api.tournamentPairings.createTournamentPairings); + +// Actions +export const useGenerateDraftTournamentPairings = createActionHook(api.tournamentPairings.generateDraftTournamentPairings); +export const useGenerateTableAssignments = createActionHook(api.tournamentPairings.generateTableAssignments); diff --git a/src/services/utils/createActionHook.ts b/src/services/utils/createActionHook.ts index a6c70682..3fa9f96f 100644 --- a/src/services/utils/createActionHook.ts +++ b/src/services/utils/createActionHook.ts @@ -1,13 +1,13 @@ import { useState } from 'react'; import { useAction } from 'convex/react'; import { FunctionReference } from 'convex/server'; -import { ConvexError } from 'convex/values'; import { toast } from '~/components/ToastProvider'; +import { handleError } from '~/services/utils/handleError'; export type ActionFn = FunctionReference<'action'>; export type ActionHookConfig = { - onSuccess?: (response: T['_returnType']) => void; + onSuccess?: (response: T['_returnType'], args: T['_args']) => void; onError?: (error: unknown) => void; successMessage?: string; }; @@ -16,7 +16,7 @@ export const createActionHook = (actionFn: T) => (config?: A const handler = useAction(actionFn); const [loading, setIsLoading] = useState(false); return { - action: async (args: T['_args']) => { + action: async (args: T['_args'], instanceConfig?: ActionHookConfig) => { setIsLoading(true); try { const response = await handler(args); @@ -24,15 +24,19 @@ export const createActionHook = (actionFn: T) => (config?: A toast.success(config.successMessage); } if (config?.onSuccess) { - config.onSuccess(response); + config.onSuccess(response, args); + } + if (instanceConfig?.onSuccess) { + instanceConfig.onSuccess(response, args); } return response; } catch (error) { - if (error instanceof ConvexError) { - toast.error('Error', { description: error.data.message }); - if (config?.onError) { - config.onError(error); - } + handleError(error); + if (config?.onError) { + config.onError(error); + } + if (instanceConfig?.onError) { + instanceConfig.onError(error); } } finally { setIsLoading(false); diff --git a/src/services/utils/createMutationHook.ts b/src/services/utils/createMutationHook.ts index 92e77cbd..7576458f 100644 --- a/src/services/utils/createMutationHook.ts +++ b/src/services/utils/createMutationHook.ts @@ -1,9 +1,9 @@ import { useState } from 'react'; import { useMutation } from 'convex/react'; import { FunctionReference } from 'convex/server'; -import { ConvexError } from 'convex/values'; import { toast } from '~/components/ToastProvider'; +import { handleError } from '~/services/utils/handleError'; export type MutationFn = FunctionReference<'mutation'>; export type MutationHookConfig = { @@ -29,20 +29,18 @@ export const createMutationHook = (mutationFn: T) => (conf if (instanceConfig?.onSuccess) { instanceConfig.onSuccess(response, args); } + return response; } catch (error) { - if (error instanceof ConvexError) { - toast.error('Error', { description: error.data.message }); - } else if (error instanceof Error) { - toast.error('Error', { description: error.message as string }); - } + handleError(error); if (config?.onError) { config.onError(error); } if (instanceConfig?.onError) { instanceConfig.onError(error); } + } finally { + setIsLoading(false); } - setIsLoading(false); }, loading, }; diff --git a/src/services/utils/handleError.ts b/src/services/utils/handleError.ts index 677c6995..4531ef6e 100644 --- a/src/services/utils/handleError.ts +++ b/src/services/utils/handleError.ts @@ -1,6 +1,20 @@ +import { ConvexError } from 'convex/values'; + import { toast } from '~/components/ToastProvider'; -export const handleError = (error: Error): void => { - toast.error(error.name, { description: error.message }); +export const handleError = (error: unknown): void => { + if (error instanceof ConvexError) { + toast.error('Error', { + description: error.data.message, + }); + } else if (error instanceof Error) { + toast.error('Error', { + description: error.message, + }); + } else { + toast.error('Error', { + description: typeof error === 'string' ? error : JSON.stringify(error) ?? 'Unknown error', + }); + } console.error(error); }; diff --git a/src/utils/emptyToUndefined.ts b/src/utils/emptyToUndefined.ts new file mode 100644 index 00000000..154994de --- /dev/null +++ b/src/utils/emptyToUndefined.ts @@ -0,0 +1,4 @@ +import z from 'zod'; + +// Helper to convert empty strings and null to undefined +export const emptyToUndefined = (schema: T) => z.preprocess((val) => (val === '' || val === null ? undefined : val), schema);