mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 21:55:15 -05:00
Refactor tournament
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user