diff --git a/app/db/seed/dev/tournaments.ts b/app/db/seed/dev/tournaments.ts index c665b6c4d..f61bf6be1 100644 --- a/app/db/seed/dev/tournaments.ts +++ b/app/db/seed/dev/tournaments.ts @@ -257,7 +257,7 @@ async function seedPlayedAwaitingFinalization({ users, rosters }: Ctx) { mapPool: () => counterpickMapPool("AUTO_ALL"), }); - await playOut(tournament.id, DOUBLE_ELIMINATION); + await TournamentFactory.playOut(tournament.id); } /** #6 single elim with third place match, TO maps — finalized, badge awarded. */ @@ -280,26 +280,13 @@ async function seedFinalizedSingleElim({ badges: [badgeId], }); - const teams = await registerTeams({ + await registerTeams({ tournamentId: tournament.id, rosters: rosters.take({ teamCount: 8, teamSize: 4 }), isCheckedIn: true, }); - const winnerTeamId = await playOut(tournament.id, SINGLE_ELIMINATION); - const winners = teams.find((team) => team.id === winnerTeamId); - - await TournamentFactory.finalize(tournament.id, { - badgeReceivers: winners - ? [ - { - badgeId, - tournamentTeamId: winners.id, - userIds: winners.memberUserIds, - }, - ] - : undefined, - }); + await TournamentFactory.playOut(tournament.id, "all"); } /** #7 round robin → SE, AUTO_SZ, ranked — finalized. */ @@ -321,8 +308,7 @@ async function seedFinalizedRoundRobin({ users, rosters }: Ctx) { mapPool: () => counterpickMapPool("AUTO_SZ"), }); - await playOut(tournament.id, ROUND_ROBIN_TO_SINGLE_ELIMINATION); - await TournamentFactory.finalize(tournament.id); + await TournamentFactory.playOut(tournament.id, "all"); } /** #8 1v1 — reg open, exercises small-roster registration UI. */ @@ -364,8 +350,7 @@ async function seedFinalizedTwoVersusTwo({ users, rosters }: Ctx) { mapPool: () => counterpickMapPool("AUTO_SZ"), }); - await playOut(tournament.id, SINGLE_ELIMINATION); - await TournamentFactory.finalize(tournament.id); + await TournamentFactory.playOut(tournament.id, "all"); } /** #10 invitational double elim, TO maps — pre-bracket, no open reg. */ @@ -418,9 +403,10 @@ async function seedHistoricalTournaments({ bracketProgression: progression, teamsPerGroup: 4, isRanked: isRecent, + badges: badgeId ? [badgeId] : [], }); - const teams = await registerTeams({ + await registerTeams({ tournamentId: tournament.id, rosters: rosters.take({ teamCount: 8, teamSize: 4 }), isCheckedIn: true, @@ -428,21 +414,7 @@ async function seedHistoricalTournaments({ mapPool: () => counterpickMapPool(isRecent ? "AUTO_SZ" : "AUTO_ALL"), }); - const winnerTeamId = await playOut(tournament.id, progression); - const winners = teams.find((team) => team.id === winnerTeamId); - - await TournamentFactory.finalize(tournament.id, { - badgeReceivers: - badgeId && winners - ? [ - { - badgeId, - tournamentTeamId: winners.id, - userIds: winners.memberUserIds, - }, - ] - : undefined, - }); + await TournamentFactory.playOut(tournament.id, "all"); } } @@ -494,36 +466,6 @@ async function registerTeams({ return teams; } -/** Starts and plays every bracket of the progression; returns the winner's team id. */ -async function playOut(tournamentId: number, progression: Progression) { - const standingsBracketIdx = finalStandingsBracketIdx(progression); - let winnerTeamId: number | undefined; - - for (let bracketIdx = 0; bracketIdx < progression.length; bracketIdx++) { - await TournamentFactory.startBracket(tournamentId, { bracketIdx }); - - while (true) { - const played = await TournamentFactory.playMatches(tournamentId); - if (played.length === 0) break; - - if (bracketIdx === standingsBracketIdx) { - winnerTeamId = played[played.length - 1].winnerTeamId; - } - } - } - - return winnerTeamId; -} - -/** The bracket first place comes out of: the one sourcing the groups winners, or the first. */ -function finalStandingsBracketIdx(progression: Progression) { - const index = progression.findIndex((bracket) => - bracket.sources?.some((source) => source.placements.includes(1)), - ); - - return index === -1 ? 0 : index; -} - function rosterBuilder(users: SeededUsers) { const corePlayers = users.showcaseIds.slice(0, CORE_PLAYER_COUNT); const pool = [ diff --git a/app/db/seed/factories/TournamentFactory.ts b/app/db/seed/factories/TournamentFactory.ts index 82421df8e..2c3a52725 100644 --- a/app/db/seed/factories/TournamentFactory.ts +++ b/app/db/seed/factories/TournamentFactory.ts @@ -1,6 +1,7 @@ import * as R from "remeda"; import type { TournamentSettings } from "~/db/tables-json"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; +import * as Standings from "~/features/tournament/core/Standings"; import type { TournamentTierNumber } from "~/features/tournament/core/tiering"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; @@ -48,14 +49,14 @@ type InsertArgs = Omit< >; type Options = { - /** Mark the tournament finished without recording any results. For cases that - * only need the flag; a tournament with real results is finalized by - * `TournamentRepository.finalize` with a summary. */ - isFinalized?: boolean; /** Confirmed tier, as starting the first bracket computes one. */ tier?: TournamentTierNumber; }; +/** Brackets to play out fully: one by its idx in the progression, several, or + * `"all"` for every bracket followed by finalizing the tournament. */ +type PlayedBrackets = number | number[] | "all"; + /** * Creates tournaments. Aggregate factory: the `CalendarEvent` wrapping the * tournament and its start date are created with it, because there is no such thing @@ -79,31 +80,32 @@ export const { create } = defineFactory({ return { id: tournamentId, eventId }; }, - applyOptions: async (tournament, { isFinalized, tier }: Options) => { - if (tier) { - await TournamentRepository.updateTournamentTier({ - tournamentId: tournament.id, - tier, - }); - } + applyOptions: async (tournament, { tier }: Options) => { + if (!tier) return; - if (!isFinalized) return; - - await TournamentRepository.finalizeWithoutSummary(tournament.id); + await TournamentRepository.updateTournamentTier({ + tournamentId: tournament.id, + tier, + }); }, }); /** * Creates a tournament that has been played. Every entry of `teamRosters` registers - * as a team owned by the first of its users and checks in, the first bracket is - * started off that seeding, and every match of it is played out. + * as a team owned by the first of its users and checks in, and the brackets + * `playedOut` names are played out off that seeding — the first bracket when not + * given, `"all"` for the whole tournament played and finalized. * * Returns the teams and the matches played alongside the tournament, so that a - * progression can carry on: start its next bracket and play that too. + * test can carry on from wherever `playedOut` left the tournament. */ export async function createPlayed( overrides: Parameters[0], - { teamRosters, ...options }: Options & { teamRosters: number[][] }, + { + teamRosters, + playedOut = 0, + ...options + }: Options & { teamRosters: number[][]; playedOut?: PlayedBrackets }, ) { const tournament = await create(overrides, options); @@ -117,29 +119,46 @@ export async function createPlayed( ); } - await startBracket(tournament.id); - const matches = await playMatches(tournament.id); + const matches = await playOut(tournament.id, playedOut); return { ...tournament, teams, matches }; } -// xxx: do we really want to chain different methods or just have some isFinalized in the create? /** - * Finalizes a fully played tournament with a real summary, the same way the - * organizer's finalize button does: results on profiles, badges awarded to - * `badgeReceivers`, skills and leaderboard entries. + * Plays brackets out fully, in the order given: each is started and every match of + * it played. `"all"` plays every bracket of the progression and then finalizes the + * tournament the same way the organizer's finalize button does: results on + * profiles, the tournament's badges awarded to its winning team, skills and + * leaderboard entries. + * + * Returns the matches played, in play order. */ -export async function finalize( +export async function playOut( tournamentId: number, - { - badgeReceivers, - }: { - badgeReceivers?: Parameters[0]["badgeReceivers"]; - } = {}, -) { + brackets: PlayedBrackets = 0, +): Promise { const tournament = await tournamentFromDB({ tournamentId, user: undefined }); + const bracketIdxs = + brackets === "all" + ? tournament.ctx.settings.bracketProgression.map((_, idx) => idx) + : [brackets].flat(); - await finalizeTournament({ tournament, badgeReceivers }); + const matches: PlayedMatch[] = []; + for (const bracketIdx of bracketIdxs) { + await startBracket(tournamentId, { bracketIdx }); + + let playedThisPass: PlayedMatch[]; + do { + playedThisPass = await playMatches(tournamentId); + matches.push(...playedThisPass); + } while (playedThisPass.length > 0); + } + + if (brackets === "all") { + await finalize(tournamentId); + } + + return matches; } /** @@ -189,6 +208,8 @@ export async function startBracket( interface PlayedMatch { id: number; + /** Index of the bracket the match belongs to in the progression. */ + bracketIdx: number; /** Number of the bracket group the match belongs to, e.g. its round robin pool. */ groupNumber: number; winnerTeamId: number; @@ -202,9 +223,8 @@ interface PlayedMatch { * participation rows end up exactly as they do when the teams play it. * * One pass only: matches the played ones advance teams into are left for the next - * call, so a caller can stop after any round. + * call, so a caller can stop after any round. `playOut` plays to the end. */ -// xxx: later optional param to play all matches of the bracket out? export async function playMatches( tournamentId: number, ): Promise { @@ -213,7 +233,7 @@ export async function playMatches( const played = playableMatches(tournament); for (const match of played) { await setActiveRosters(tournamentId, match); - await playOut(tournamentId, match); + await playOutMatch(tournamentId, match); } clearTournamentDataCache(tournamentId); @@ -237,28 +257,29 @@ function roundMapsFor( function playableMatches( tournament: Awaited>, ): PlayedMatch[] { - return tournament.brackets - .filter((bracket) => !bracket.preview) - .flatMap((bracket) => { - const groupNumbers = new Map( - bracket.data.group.map((group) => [group.id, group.number]), - ); + return tournament.brackets.flatMap((bracket, bracketIdx) => { + if (bracket.preview) return []; - return bracket.data.match - .filter((match) => bracket.matchStatus(match.id) === "STARTED") - .flatMap((match) => - match.opponent1?.id && match.opponent2?.id - ? [ - { - id: match.id, - groupNumber: groupNumbers.get(match.groupId)!, - winnerTeamId: match.opponent1.id, - loserTeamId: match.opponent2.id, - }, - ] - : [], - ); - }); + const groupNumbers = new Map( + bracket.data.group.map((group) => [group.id, group.number]), + ); + + return bracket.data.match + .filter((match) => bracket.matchStatus(match.id) === "STARTED") + .flatMap((match) => + match.opponent1?.id && match.opponent2?.id + ? [ + { + id: match.id, + bracketIdx, + groupNumber: groupNumbers.get(match.groupId)!, + winnerTeamId: match.opponent1.id, + loserTeamId: match.opponent2.id, + }, + ] + : [], + ); + }); } async function setActiveRosters(tournamentId: number, match: PlayedMatch) { @@ -284,7 +305,7 @@ async function setActiveRosters(tournamentId: number, match: PlayedMatch) { } } -async function playOut(tournamentId: number, match: PlayedMatch) { +async function playOutMatch(tournamentId: number, match: PlayedMatch) { let position = 0; let setOver = false; @@ -316,6 +337,38 @@ async function playOut(tournamentId: number, match: PlayedMatch) { } } +async function finalize(tournamentId: number) { + const tournament = await tournamentFromDB({ tournamentId, user: undefined }); + + await finalizeTournament({ + tournament, + badgeReceivers: await winnersAsBadgeReceivers(tournament), + }); +} + +/** The tournament's badges all go to the winning team, as the organizer typically assigns them. */ +async function winnersAsBadgeReceivers( + tournament: Awaited>, +) { + const badges = ( + await CalendarRepository.findById(tournament.ctx.eventId, { + includeBadgePrizes: true, + }) + )?.badgePrizes; + if (!badges?.length) return undefined; + + const winner = Standings.flattenStandings( + Standings.tournamentStandings(tournament), + ).find((standing) => standing.placement === 1); + invariant(winner, "Tournament to award badges for has no winner"); + + return badges.map((badge) => ({ + badgeId: badge.id, + tournamentTeamId: winner.team.id, + userIds: winner.team.members.map((member) => member.userId), + })); +} + async function findMatch(matchId: number) { const match = await TournamentMatchRepository.findMatchById(matchId); invariant(match, `Match ${matchId} not found`); diff --git a/app/features/leaderboards/LeaderboardRepository.server.test.ts b/app/features/leaderboards/LeaderboardRepository.server.test.ts index 3daa4703c..edad6a091 100644 --- a/app/features/leaderboards/LeaderboardRepository.server.test.ts +++ b/app/features/leaderboards/LeaderboardRepository.server.test.ts @@ -51,7 +51,10 @@ const createTournamentMatch = async ({ }) => { const { matches } = await TournamentFactory.createPlayed( { authorId, minMembersPerTeam: 1 }, - { isFinalized, teamRosters: [[authorId], [groupFillers[1].id]] }, + { + playedOut: isFinalized ? "all" : 0, + teamRosters: [[authorId], [groupFillers[1].id]], + }, ); return matches[0]; diff --git a/app/features/tournament-match/TournamentMatchRepository.server.test.ts b/app/features/tournament-match/TournamentMatchRepository.server.test.ts index d29b9cfcc..166e97572 100644 --- a/app/features/tournament-match/TournamentMatchRepository.server.test.ts +++ b/app/features/tournament-match/TournamentMatchRepository.server.test.ts @@ -41,12 +41,17 @@ describe("findByTournamentTeamId", () => { bracketProgression: POOLS_TO_FINAL, minMembersPerTeam: 1, }, - { teamRosters: users.ids(TEAM_COUNT).map((userId) => [userId]) }, + { + teamRosters: users.ids(TEAM_COUNT).map((userId) => [userId]), + playedOut: [0, 1], + }, ); - const poolMatches = tournament.matches; - - await TournamentFactory.startBracket(tournament.id, { bracketIdx: 1 }); - const [finalMatch] = await TournamentFactory.playMatches(tournament.id); + const poolMatches = tournament.matches.filter( + (match) => match.bracketIdx === 0, + ); + const finalMatch = tournament.matches.find( + (match) => match.bracketIdx === 1, + )!; const lastPoolMatch = poolMatches.find( (match) => match.groupNumber === POOL_COUNT, diff --git a/app/routines/syncTournamentVods.test.ts b/app/routines/syncTournamentVods.test.ts index 348268aeb..c9e01e004 100644 --- a/app/routines/syncTournamentVods.test.ts +++ b/app/routines/syncTournamentVods.test.ts @@ -245,8 +245,10 @@ function twitchVideo({ } /** - * A double elimination bracket of four one-player teams, its first round played - * and optionally streamed by a cast account. + * A played out double elimination bracket of four one-player teams, its first + * round backdated to the times the VODs are matched against and optionally + * streamed by a cast account. Later matches start at the current time, far from + * any mocked VOD. */ async function seedTournamentWithMatches({ castedOn, diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index e036eb183..180bf9650 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -188,7 +188,6 @@ test.describe("Spoiler-free mode", () => { const TEAM_COUNT = 4; const TEAM_SIZE = 4; -// xxx: this should be an option of create instead async function createFinalizedTournament(factories: Factories) { const users = await factories.UserFactory.createMany(TEAM_COUNT * TEAM_SIZE); const teamRosters = Array.from({ length: TEAM_COUNT }, (_, teamIndex) => @@ -197,13 +196,8 @@ async function createFinalizedTournament(factories: Factories) { .map((user) => user.id), ); - const tournament = await factories.TournamentFactory.createPlayed( + return factories.TournamentFactory.createPlayed( { authorId: ADMIN_ID }, - { teamRosters }, + { teamRosters, playedOut: "all" }, ); - // createPlayed plays one round; the final becomes playable only after it - await factories.TournamentFactory.playMatches(tournament.id); - await factories.TournamentFactory.finalize(tournament.id); - - return tournament; } diff --git a/e2e/tournament-tiers.spec.ts b/e2e/tournament-tiers.spec.ts index 6a57138b6..b1cc4ed5a 100644 --- a/e2e/tournament-tiers.spec.ts +++ b/e2e/tournament-tiers.spec.ts @@ -33,11 +33,10 @@ test.describe("Tournament tiers", () => { }, ); - // xxx: should be an option // a finalized earlier edition: its tier seeds the series' tier history and // playing it out gives every player the seeding skill the confirmed tier // of the next edition is calculated from - const playedTournament = await factories.TournamentFactory.createPlayed( + await factories.TournamentFactory.createPlayed( { name: "PICNIC 1", authorId: NZAP_TEST_ID, @@ -46,15 +45,8 @@ test.describe("Tournament tiers", () => { tags: null, isRanked: false, }, - { teamRosters: rosters, tier: HISTORY_TIER }, + { teamRosters: rosters, tier: HISTORY_TIER, playedOut: "all" }, ); - let playedMatches: unknown[]; - do { - playedMatches = await factories.TournamentFactory.playMatches( - playedTournament.id, - ); - } while (playedMatches.length > 0); - await factories.TournamentFactory.finalize(playedTournament.id); const tournament = await factories.TournamentFactory.create({ name: "PICNIC 2",