Refactor tournament

This commit is contained in:
Kalle
2026-07-30 14:36:27 +03:00
parent 5e17c6c7cc
commit 419bd1ddef
7 changed files with 140 additions and 149 deletions

View File

@@ -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 = [

View File

@@ -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<typeof create>[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<typeof finalizeTournament>[0]["badgeReceivers"];
} = {},
) {
brackets: PlayedBrackets = 0,
): Promise<PlayedMatch[]> {
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<PlayedMatch[]> {
@@ -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<ReturnType<typeof tournamentFromDB>>,
): 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<ReturnType<typeof tournamentFromDB>>,
) {
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`);

View File

@@ -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];

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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",