diff --git a/app/db/tables.ts b/app/db/tables.ts index c26d51325..762aaf941 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -13,10 +13,10 @@ import type { Notification as NotificationValue } from "~/features/notifications import type { ScrimFilters } from "~/features/scrims/scrims-types"; import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants"; import type { TournamentTierNumber } from "~/features/tournament/core/tiering"; +import type { ParticipantResult } from "~/features/tournament-bracket/core/engine/types"; import type * as PickBan from "~/features/tournament-bracket/core/PickBan"; import type * as Progression from "~/features/tournament-bracket/core/Progression"; import type { StoredWidget } from "~/features/user-page/core/widgets/types"; -import type { ParticipantResult } from "~/modules/brackets-model"; import type { Ability, BuildAbilitiesTuple, diff --git a/app/features/api-public/schema.ts b/app/features/api-public/schema.ts index 3b73bbc79..86e31843b 100644 --- a/app/features/api-public/schema.ts +++ b/app/features/api-public/schema.ts @@ -1,6 +1,6 @@ import type { Pronouns } from "~/db/tables"; import type { TierName } from "~/features/mmr/mmr-constants"; -import type { DataTypes, ValueToArray } from "~/modules/brackets-manager/types"; +import type { TournamentManagerDataSet } from "~/features/tournament-bracket/core/engine/types"; /** GET /api/user/{userId|discordId} */ @@ -368,7 +368,10 @@ export interface GetTournamentBracketStandingsResponse { setLosses: number; mapWins: number; mapLosses: number; - points: number; + /** @deprecated points are no longer tracked, see koCount instead */ + points?: number; + /** (round robin only) how many knockout wins the team has */ + koCount?: number; winsAgainstTied: number; lossesAgainstTied?: number; buchholzSets?: number; @@ -537,7 +540,7 @@ type TournamentBracket = { name: string; }; -type TournamentBracketData = ValueToArray; +type TournamentBracketData = TournamentManagerDataSet; /** POST /api/tournament/{id}/seeds */ diff --git a/app/features/bracket-test/routes/bracket-test.tsx b/app/features/bracket-test/routes/bracket-test.tsx index 512444dcc..9dc89a7e3 100644 --- a/app/features/bracket-test/routes/bracket-test.tsx +++ b/app/features/bracket-test/routes/bracket-test.tsx @@ -7,10 +7,9 @@ import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; import type { Tables } from "~/db/tables"; import type { Bracket as BracketType } from "~/features/tournament-bracket/core/Bracket"; -import { getTournamentManager } from "~/features/tournament-bracket/core/brackets-manager"; -import * as Swiss from "~/features/tournament-bracket/core/Swiss"; +import * as Engine from "~/features/tournament-bracket/core/engine"; +import type { TournamentManagerDataSet } from "~/features/tournament-bracket/core/engine/types"; import { fillWithNullTillPowerOfTwo } from "~/features/tournament-bracket/tournament-bracket-utils"; -import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; import styles from "../bracket-test.module.css"; type FormatType = Tables["TournamentStage"]["type"]; @@ -282,9 +281,10 @@ function generateBracketData( teamIds: number[], ): TournamentManagerDataSet { if (format === "swiss") { - return Swiss.create({ + return Engine.create({ tournamentId: 1, name: "Test Bracket", + type: "swiss", seeding: teamIds, settings: { swiss: { groupCount: 1, roundCount: 5 }, @@ -292,7 +292,6 @@ function generateBracketData( }); } - const manager = getTournamentManager(); const seeding = format === "round_robin" ? teamIds : fillWithNullTillPowerOfTwo(teamIds); @@ -306,13 +305,11 @@ function generateBracketData( seedOrdering: ["groups.seed_optimized" as const], }; - manager.create({ + return Engine.create({ tournamentId: 1, name: "Test Bracket", type: format, seeding, settings, }); - - return manager.get.tournamentData(1); } diff --git a/app/features/calendar/actions/calendar.$id.server.ts b/app/features/calendar/actions/calendar.$id.server.ts index 36bd57a4d..23db4c51d 100644 --- a/app/features/calendar/actions/calendar.$id.server.ts +++ b/app/features/calendar/actions/calendar.$id.server.ts @@ -6,13 +6,13 @@ import * as CalendarRepository from "~/features/calendar/CalendarRepository.serv import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { clearTournamentDataCache, - tournamentManagerData, } from "~/features/tournament-bracket/core/Tournament.server"; import { databaseTimestampToDate } from "~/utils/dates"; import { errorToastIfFalsy, notFoundIfFalsy } from "~/utils/remix.server"; import { CALENDAR_PAGE } from "~/utils/urls"; import { actualNumber, id } from "~/utils/zod"; import { canDeleteCalendarEvent } from "../calendar-utils"; +import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server"; export const action: ActionFunction = async ({ params }) => { const user = requireUser(); @@ -25,7 +25,7 @@ export const action: ActionFunction = async ({ params }) => { if (event.tournamentId) { errorToastIfFalsy( - tournamentManagerData(event.tournamentId).stage.length === 0, + (await BracketRepository.findByTournamentId(event.tournamentId)).stage.length === 0, "Tournament has already started", ); } else { diff --git a/app/features/calendar/calendar-schemas.ts b/app/features/calendar/calendar-schemas.ts index 9784c8315..eb792e087 100644 --- a/app/features/calendar/calendar-schemas.ts +++ b/app/features/calendar/calendar-schemas.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import { type CalendarEventTag, TOURNAMENT_STAGE_TYPES } from "~/db/tables"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import * as Swiss from "~/features/tournament-bracket/core/engine/swiss/team-status"; import * as Progression from "~/features/tournament-bracket/core/Progression"; -import * as Swiss from "~/features/tournament-bracket/core/Swiss"; import { array, checkboxGroup, diff --git a/app/features/calendar/components/BracketProgressionSelector.tsx b/app/features/calendar/components/BracketProgressionSelector.tsx index d4b3da1df..80ba37919 100644 --- a/app/features/calendar/components/BracketProgressionSelector.tsx +++ b/app/features/calendar/components/BracketProgressionSelector.tsx @@ -9,8 +9,8 @@ import { FormMessage } from "~/components/FormMessage"; import { Input } from "~/components/Input"; import { Label } from "~/components/Label"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import * as Swiss from "~/features/tournament-bracket/core/engine/swiss/team-status"; import * as Progression from "~/features/tournament-bracket/core/Progression"; -import * as Swiss from "~/features/tournament-bracket/core/Swiss"; import { defaultBracketSettings } from "../../tournament/tournament-utils"; import styles from "./BracketProgressionSelector.module.css"; diff --git a/app/features/core/streams/streams.server.ts b/app/features/core/streams/streams.server.ts index 218cd888b..f3cb5c49f 100644 --- a/app/features/core/streams/streams.server.ts +++ b/app/features/core/streams/streams.server.ts @@ -1,7 +1,7 @@ import type { TournamentTierNumber } from "~/features/tournament/core/tiering"; +import { MatchStatus as Status } from "~/features/tournament-bracket/core/engine/types"; import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; -import { Status } from "~/modules/brackets-model"; import { cache } from "~/utils/cache.server"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import { tournamentStreamsPage } from "~/utils/urls"; diff --git a/app/features/tournament-admin/actions/to.$id.admin.brackets.server.ts b/app/features/tournament-admin/actions/to.$id.admin.brackets.server.ts index 85ebbac65..b6d98655b 100644 --- a/app/features/tournament-admin/actions/to.$id.admin.brackets.server.ts +++ b/app/features/tournament-admin/actions/to.$id.admin.brackets.server.ts @@ -2,6 +2,7 @@ import type { ActionFunction } from "react-router"; import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls"; import { requireUser } from "~/features/auth/core/user.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server"; import * as Progression from "~/features/tournament-bracket/core/Progression"; import { clearTournamentDataCache, @@ -57,7 +58,7 @@ export const action: ActionFunction = async ({ request, params }) => { "Some bracket that sources teams from this bracket has started", ); - await TournamentRepository.resetBracket(data.stageId); + await BracketRepository.resetBracket(data.stageId); message = "Bracket reset"; break; diff --git a/app/features/tournament-admin/actions/to.$id.admin.index.server.ts b/app/features/tournament-admin/actions/to.$id.admin.index.server.ts index 793ad0066..db804537a 100644 --- a/app/features/tournament-admin/actions/to.$id.admin.index.server.ts +++ b/app/features/tournament-admin/actions/to.$id.admin.index.server.ts @@ -1,11 +1,12 @@ import type { ActionFunction } from "react-router"; import * as R from "remeda"; +import { db } from "~/db/sql"; import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { endDroppedTeamMatches } from "~/features/tournament/tournament-utils.server"; -import { getServerTournamentManager } from "~/features/tournament-bracket/core/brackets-manager/manager.server"; +import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server"; import { clearTournamentDataCache, tournamentFromDB, @@ -116,7 +117,6 @@ export const action: ActionFunction = async ({ request, params }) => { const endedMatchIds = await dropTeamOut({ tournament, - manager: getServerTournamentManager(), teamId: data.teamId, }); @@ -152,11 +152,9 @@ export const action: ActionFunction = async ({ request, params }) => { */ async function dropTeamOut({ tournament, - manager, teamId, }: { tournament: Awaited>; - manager: ReturnType; teamId: number; }) { const droppingTeam = tournament.teamById(teamId); @@ -176,10 +174,18 @@ async function dropTeamOut({ }); } - const endedMatchIds = endDroppedTeamMatches({ - tournament, - manager, - droppedTeamId: teamId, + const endedMatchIds = await db.transaction().execute(async (trx) => { + const droppedResult = endDroppedTeamMatches({ + tournament, + data: await BracketRepository.findByTournamentId(tournament.ctx.id, trx), + droppedTeamId: teamId, + }); + await BracketRepository.applyMatchChanges( + droppedResult.changedMatches, + trx, + ); + + return droppedResult.endedMatchIds; }); await TournamentTeamRepository.dropOut({ diff --git a/app/features/tournament-bracket/BracketRepository.server.ts b/app/features/tournament-bracket/BracketRepository.server.ts new file mode 100644 index 000000000..2fdf4f5b2 --- /dev/null +++ b/app/features/tournament-bracket/BracketRepository.server.ts @@ -0,0 +1,353 @@ +import { type Kysely, sql as kyselySql, type Transaction } from "kysely"; +import { db } from "~/db/sql"; +import type { DB } from "~/db/tables"; +import { databaseTimestampNow } from "~/utils/dates"; +import { shortNanoid } from "~/utils/id"; +import type { + BracketData, + CreatedBracket, + GeneratedRound, + MatchData, + MatchStatus, + ParticipantResult, + RoundData, + StageSettings, +} from "./core/engine/types"; +import { MatchStatus as MatchStatusValues } from "./core/engine/types"; + +type DbOrTrx = Kysely | Transaction; + +/** + * Loads the full BracketData for a tournament (all stages). Includes the + * score/totalKos aggregation over TournamentMatchGameResult. Direct replacement + * for the old manager.get.tournamentData(); also called from write actions + * inside their transaction (propagation must be computed from fresh rows, not + * the cached Tournament instance). + */ +// xxx: one query with json helpers +export async function findByTournamentId( + tournamentId: number, + trx: DbOrTrx = db, +): Promise { + const stages = await trx + .selectFrom("TournamentStage") + .selectAll() + .where("tournamentId", "=", tournamentId) + .orderBy("id", "asc") + .execute(); + + if (stages.length === 0) { + return { stage: [], group: [], round: [], match: [] }; + } + + const stageIds = stages.map((stage) => stage.id); + + const [groups, rounds, matches] = await Promise.all([ + trx + .selectFrom("TournamentGroup") + .selectAll() + .where("stageId", "in", stageIds) + .orderBy("stageId", "asc") + .orderBy("id", "asc") + .execute(), + trx + .selectFrom("TournamentRound") + .selectAll() + .where("stageId", "in", stageIds) + .orderBy("stageId", "asc") + .orderBy("id", "asc") + .execute(), + trx + .selectFrom("TournamentMatch") + .leftJoin( + "TournamentMatchGameResult", + "TournamentMatch.id", + "TournamentMatchGameResult.matchId", + ) + .selectAll("TournamentMatch") + .select([ + kyselySql< + number | null + >`sum(case when "TournamentMatchGameResult"."opponentOnePoints" = 100 and "TournamentMatchGameResult"."opponentTwoPoints" = 0 then 1 else 0 end)`.as( + "opponentOneKosTotal", + ), + kyselySql< + number | null + >`sum(case when "TournamentMatchGameResult"."opponentTwoPoints" = 100 and "TournamentMatchGameResult"."opponentOnePoints" = 0 then 1 else 0 end)`.as( + "opponentTwoKosTotal", + ), + ]) + .where("TournamentMatch.stageId", "in", stageIds) + .groupBy("TournamentMatch.id") + .orderBy("TournamentMatch.stageId", "asc") + .orderBy("TournamentMatch.id", "asc") + .execute(), + ]); + + return { + stage: stages.map((stage) => ({ + id: stage.id, + tournament_id: stage.tournamentId, + name: stage.name, + type: stage.type, + settings: parseColumn(stage.settings) ?? {}, + number: stage.number, + createdAt: stage.createdAt, + })), + group: groups.map((group) => ({ + id: group.id, + stage_id: group.stageId, + number: group.number, + })), + round: rounds.map((round) => { + const maps = parseColumn< + NonNullable + >(round.maps); + + return { + id: round.id, + stage_id: round.stageId, + group_id: round.groupId, + number: round.number, + maps: maps + ? { + count: maps.count, + type: maps.type, + pickBan: maps.pickBan, + } + : null, + }; + }), + match: matches.map((match) => ({ + id: match.id, + stage_id: match.stageId, + group_id: match.groupId, + round_id: match.roundId, + number: match.number, + status: match.status as MatchStatus, + opponent1: hydrateOpponent(match.opponentOne, match.opponentOneKosTotal), + opponent2: hydrateOpponent(match.opponentTwo, match.opponentTwoKosTotal), + startedAt: match.startedAt, + })), + }; +} + +/** + * Persists Engine.create output in one transaction. Inserts stage → groups → + * rounds → matches, translating the engine's local ids to real row ids. The + * stage number is assigned from the existing stages of the tournament. + * + * @returns the created stage id and the created round rows (with real ids), + * needed for the round map-list assignment. + */ +export async function insertBracket( + args: { + tournamentId: number; + bracket: CreatedBracket; + }, + trx: DbOrTrx = db, +): Promise<{ stageId: number; rounds: RoundData[] }> { + const stageInput = args.bracket.stage[0]; + if (!stageInput) throw new Error("Bracket has no stage"); + + const stage = await trx + .insertInto("TournamentStage") + .values({ + tournamentId: args.tournamentId, + name: stageInput.name, + type: stageInput.type, + settings: JSON.stringify(stageInput.settings), + number: kyselySql`(select coalesce(max("number"), 0) + 1 from "TournamentStage" where "tournamentId" = ${args.tournamentId})`, + createdAt: databaseTimestampNow(), + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); + + const groupIdMapping = new Map(); + const roundIdMapping = new Map(); + const insertedRounds: RoundData[] = []; + + for (const group of args.bracket.group) { + const inserted = await trx + .insertInto("TournamentGroup") + .values({ + stageId: stage.id, + number: group.number, + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); + + groupIdMapping.set(group.id, inserted.id); + } + + for (const round of args.bracket.round) { + const inserted = await trx + .insertInto("TournamentRound") + .values({ + stageId: stage.id, + groupId: groupIdMapping.get(round.group_id)!, + number: round.number, + // the maps column is filled right after bracket creation; typed + // non-null but nullable at the DB level + maps: kyselySql`null`, + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); + + roundIdMapping.set(round.id, inserted.id); + insertedRounds.push({ + id: inserted.id, + stage_id: stage.id, + group_id: groupIdMapping.get(round.group_id)!, + number: round.number, + }); + } + + for (const match of args.bracket.match) { + await trx + .insertInto("TournamentMatch") + .values({ + stageId: stage.id, + groupId: groupIdMapping.get(match.group_id)!, + roundId: roundIdMapping.get(match.round_id)!, + number: match.number, + status: match.status, + opponentOne: serializeOpponent(match.opponent1), + opponentTwo: serializeOpponent(match.opponent2), + chatCode: shortNanoid(), + startedAt: null, + }) + .execute(); + } + + return { stageId: stage.id, rounds: insertedRounds }; +} + +/** + * UPDATEs the given matches' status/opponentOne/opponentTwo. Called with + * EngineResult.changedMatches, inside the caller's transaction. + */ +export async function applyMatchChanges( + changes: MatchData[], + trx: DbOrTrx = db, +): Promise { + for (const match of changes) { + await trx + .updateTable("TournamentMatch") + .set({ + status: match.status, + opponentOne: serializeOpponent(match.opponent1), + opponentTwo: serializeOpponent(match.opponent2), + }) + .where("id", "=", match.id) + .execute(); + } +} + +/** INSERTs a generated round's matches (swiss advance). */ +export async function insertRoundMatches( + args: { + stageId: number; + round: GeneratedRound; + }, + trx: DbOrTrx = db, +): Promise { + if (args.round.matches.length === 0) { + throw new Error("No matches to insert"); + } + + await trx + .insertInto("TournamentMatch") + .values( + args.round.matches.map((match) => ({ + stageId: args.stageId, + groupId: args.round.groupId, + roundId: args.round.roundId, + number: match.number, + status: MatchStatusValues.Ready, + opponentOne: serializeOpponent(match.opponent1), + opponentTwo: serializeOpponent(match.opponent2), + chatCode: shortNanoid(), + })), + ) + .execute(); +} + +/** DELETEs a round's matches (swiss unadvance). */ +export async function deleteRoundMatches(args: { + groupId: number; + roundId: number; +}): Promise { + await db + .deleteFrom("TournamentMatch") + .where("groupId", "=", args.groupId) + .where("roundId", "=", args.roundId) + .execute(); +} + +/** Deletes the whole stage subtree (matches, rounds, groups, stage). */ +export function resetBracket(tournamentStageId: number) { + return db.transaction().execute(async (trx) => { + await trx + .deleteFrom("TournamentMatch") + .where("stageId", "=", tournamentStageId) + .execute(); + + await trx + .deleteFrom("TournamentRound") + .where("stageId", "=", tournamentStageId) + .execute(); + + await trx + .deleteFrom("TournamentGroup") + .where("stageId", "=", tournamentStageId) + .execute(); + + await trx + .deleteFrom("TournamentStage") + .where("id", "=", tournamentStageId) + .execute(); + }); +} + +function hydrateOpponent( + raw: unknown, + kosTotal: number | null, +): ParticipantResult | null { + const parsed = parseColumn(raw); + if (!parsed) return null; + + // old write paths serialized the aggregated fields into the JSON; they are + // stale residue and must not shadow the fresh aggregation + const { totalPoints, totalKos, ...persisted } = parsed; + + return { + ...persisted, + totalKos: kosTotal ?? undefined, + }; +} + +/** Opponents are stored as JSON with the SQL-aggregated fields stripped ("null" string for BYEs). */ +function serializeOpponent(opponent: ParticipantResult | null): string { + if (!opponent) return "null"; + + const { totalKos, totalPoints, ...persisted } = + opponent as ParticipantResult & { + totalPoints?: number; + }; + return JSON.stringify(persisted); +} + +// xxx: we could db migrate this to be unnecessary? +/** + * Columns can arrive as raw JSON strings or already parsed by + * FastParseJSONResultsPlugin (which leaves the "null" string untouched). + */ +function parseColumn(raw: unknown): T | null { + if (raw == null) return null; + if (typeof raw === "string") { + if (raw === "null") return null; + return JSON.parse(raw) as T; + } + return raw as T; +} diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index 8d4b32356..b7ca76170 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -1,5 +1,5 @@ import type { ActionFunction } from "react-router"; -import { sql } from "~/db/sql"; +import { db } from "~/db/sql"; import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import { notify } from "~/features/notifications/core/notify.server"; @@ -7,7 +7,6 @@ import { calculateTournamentTierFromTeams, MIN_TEAMS_FOR_TIERING, } from "~/features/tournament/core/tiering"; -import { createSwissBracketInTransaction } from "~/features/tournament/queries/createSwissBracketInTransaction.server"; import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; @@ -24,10 +23,10 @@ import { import { assertUnreachable } from "~/utils/types"; import { idObject } from "~/utils/zod"; import type { PreparedMaps } from "../../../db/tables"; +import * as BracketRepository from "../BracketRepository.server"; import * as AbDivisions from "../core/AbDivisions"; -import { getServerTournamentManager } from "../core/brackets-manager/manager.server"; +import * as Engine from "../core/engine"; import * as PreparedMapsUtils from "../core/PreparedMaps"; -import * as Swiss from "../core/Swiss"; import type { Tournament } from "../core/Tournament"; import { clearTournamentDataCache, @@ -47,7 +46,6 @@ export const action: ActionFunction = async ({ params, request }) => { }); const tournament = await tournamentFromDB({ tournamentId, user }); const data = await parseRequestPayload({ request, schema: bracketSchema }); - const manager = getServerTournamentManager(); let emitTournamentUpdate = false; @@ -105,38 +103,37 @@ export const action: ActionFunction = async ({ params, request }) => { "Invalid map count", ); - sql.transaction(() => { - const stage = - bracket.type === "swiss" - ? createSwissBracketInTransaction( - Swiss.create({ - name: bracket.name, - seeding, - tournamentId, - settings, - }), - ) - : manager.create({ - tournamentId, - name: bracket.name, - type: bracket.type, - seeding: - bracket.type === "round_robin" - ? seeding - : fillWithNullTillPowerOfTwo(seeding), - settings, - abDivisions, - }); + const createdBracket = Engine.create({ + // xxx: will we really need tournamentId here? + tournamentId, + // xxx: will we really need name? + name: bracket.name, + type: bracket.type, + // xxx: this could be implementation detail + seeding: + bracket.type === "round_robin" || bracket.type === "swiss" + ? seeding + : fillWithNullTillPowerOfTwo(seeding), + settings, + abDivisions, + }); + await db.transaction().execute(async (trx) => { + const { rounds } = await BracketRepository.insertBracket( + { tournamentId, bracket: createdBracket }, + trx, + ); + + // xxx: should not be separate updateRoundMaps( roundMapsFromInput({ virtualRounds: bracket.data.round, - roundsFromDB: manager.get.stageData(stage.id).round, + roundsFromDB: rounds, maps, bracket, }), ); - })(); + }); // persist maps as prepared even if they weren't initially so sibling brackets can reuse them const existingPreparedMaps = @@ -257,14 +254,23 @@ export const action: ActionFunction = async ({ params, request }) => { const bracket = tournament.bracketByIdx(data.bracketIdx); errorToastIfFalsy(bracket, "Bracket not found"); - const matches = Swiss.generateMatchUps({ - bracket, + const round = Engine.generateRound(bracket.data, { groupId: data.groupId, + standings: bracket.standings, + settings: bracket.settings, }); - errorToastIfErr(matches); + errorToastIfErr(round); - await TournamentRepository.insertSwissMatches(matches.value); + const stageId = bracket.data.match.find( + (match) => match.group_id === data.groupId, + )?.stage_id; + errorToastIfFalsy(stageId, "No matches found for group"); + + await BracketRepository.insertRoundMatches({ + stageId, + round: round.value, + }); emitTournamentUpdate = true; @@ -281,7 +287,7 @@ export const action: ActionFunction = async ({ params, request }) => { ); errorToastIfFalsyNoFollowUpBrackets(tournament); - await TournamentRepository.deleteSwissMatches({ + await BracketRepository.deleteRoundMatches({ groupId: data.groupId, roundId: data.roundId, }); diff --git a/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx b/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx index a68523637..25b77a7a9 100644 --- a/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx +++ b/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx @@ -2,7 +2,7 @@ import { createMemoryRouter, RouterProvider } from "react-router"; import { describe, expect, test, vi } from "vitest"; import { render } from "vitest-browser-react"; import styles from "~/features/tournament-bracket/components/Bracket/bracket.module.css"; -import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; +import type { TournamentManagerDataSet } from "~/features/tournament-bracket/core/engine/types"; import type { Bracket as BracketType } from "../../core/Bracket"; import { EliminationBracketSide } from "./Elimination"; import { Bracket } from "./index"; diff --git a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx index 3bf885e3c..4417e1e31 100644 --- a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx +++ b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx @@ -12,8 +12,8 @@ import { import { useUser } from "../../../auth/core/user"; import { TOURNAMENT } from "../../../tournament/tournament-constants"; import type { Bracket, Standing } from "../../core/Bracket"; +import * as Swiss from "../../core/engine/swiss/team-status"; import * as Progression from "../../core/Progression"; -import * as Swiss from "../../core/Swiss"; import styles from "./bracket.module.css"; export function PlacementsTable({ @@ -60,7 +60,6 @@ export function PlacementsTable({ stats: { mapLosses: 0, mapWins: 0, - points: 0, koCount: 0, setLosses: 0, setWins: 0, diff --git a/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx b/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx index 9f399d9a3..d14d9206d 100644 --- a/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx +++ b/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx @@ -1,4 +1,4 @@ -import type { Match as MatchType } from "~/modules/brackets-model"; +import type { Match as MatchType } from "~/features/tournament-bracket/core/engine/types"; import type { Bracket as BracketType } from "../../core/Bracket"; import { groupNumberToLetters } from "../../tournament-bracket-utils"; import styles from "./bracket.module.css"; diff --git a/app/features/tournament-bracket/components/Bracket/Swiss.tsx b/app/features/tournament-bracket/components/Bracket/Swiss.tsx index fb2809b8f..6b4bc00a4 100644 --- a/app/features/tournament-bracket/components/Bracket/Swiss.tsx +++ b/app/features/tournament-bracket/components/Bracket/Swiss.tsx @@ -8,8 +8,8 @@ import { useBracketExpanded, useTournament, } from "~/features/tournament/routes/to.$id"; +import type { Match as MatchType } from "~/features/tournament-bracket/core/engine/types"; import { useSearchParamState } from "~/hooks/useSearchParamState"; -import type { Match as MatchType } from "~/modules/brackets-model"; import type { Bracket as BracketType } from "../../core/Bracket"; import styles from "../../tournament-bracket.module.css"; import { groupNumberToLetters } from "../../tournament-bracket-utils"; diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx index 34ba9da6a..0a2467f6e 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -29,8 +29,8 @@ import { useTournamentPreparedMaps, } from "~/features/tournament/routes/to.$id"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import type { TournamentManagerDataSet } from "~/features/tournament-bracket/core/engine/types"; import * as PickBan from "~/features/tournament-bracket/core/PickBan"; -import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; import { modesShort } from "~/modules/in-game-lists/modes"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { nullFilledArray } from "~/utils/arrays"; diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index 3a85b9553..d8392945e 100644 --- a/app/features/tournament-bracket/core/Bracket.test.ts +++ b/app/features/tournament-bracket/core/Bracket.test.ts @@ -1,9 +1,8 @@ import * as R from "remeda"; import { describe, expect, it } from "vitest"; -import { BracketsManager } from "~/modules/brackets-manager"; -import { InMemoryDatabase } from "~/modules/brackets-memory-db"; import invariant from "../../../utils/invariant"; -import * as Swiss from "../core/Swiss"; +import * as Engine from "./engine"; +import { EngineBracket } from "./engine/test-utils"; import { Tournament } from "./Tournament"; import { PADDLING_POOL_255 } from "./tests/mocks"; import { LOW_INK_DECEMBER_2024 } from "./tests/mocks-li"; @@ -98,9 +97,10 @@ describe("swiss standings - losses against tied", () => { }); const inProgressSwissTestTournament = () => { - const data = Swiss.create({ + const data = Engine.create({ tournamentId: 1, name: "Swiss", + type: "swiss", seeding: [1, 2, 3], settings: { swiss: { @@ -232,10 +232,9 @@ describe("round robin standings - dropped out teams", () => { skipMatchups?: string[]; forfeitMatchups?: string[]; } = {}) => { - const storage = new InMemoryDatabase(); - const manager = new BracketsManager(storage); + const bracket = new EngineBracket(); - manager.create({ + bracket.create({ name: "RR", tournamentId: 1, type: "round_robin", @@ -252,10 +251,9 @@ describe("round robin standings - dropped out teams", () => { winnerScore: number, loserScore: number, ) => { - const match = storage.select("match", matchId); - invariant(match, `match ${matchId} not found`); - const winnerIsOpp1 = match.opponent1.id === winnerId; - manager.update.match({ + const match = bracket.match(matchId); + const winnerIsOpp1 = match.opponent1?.id === winnerId; + bracket.updateMatch({ id: match.id, opponent1: winnerIsOpp1 ? { score: winnerScore, result: "win" } @@ -269,15 +267,14 @@ describe("round robin standings - dropped out teams", () => { // Mimics endDroppedTeamMatches: sets a winner via result only, with no // score recorded on either side (the match was never actually played). const forfeitMatch = (matchId: number, winnerId: number) => { - const match = storage.select("match", matchId); - invariant(match, `match ${matchId} not found`); - manager.update.match({ + const match = bracket.match(matchId); + bracket.updateMatch({ id: match.id, opponent1: { - result: match.opponent1.id === winnerId ? "win" : "loss", + result: match.opponent1?.id === winnerId ? "win" : "loss", }, opponent2: { - result: match.opponent2.id === winnerId ? "win" : "loss", + result: match.opponent2?.id === winnerId ? "win" : "loss", }, }); }; @@ -291,9 +288,9 @@ describe("round robin standings - dropped out teams", () => { "2-4": 2, "3-4": 3, }; - for (const match of storage.select("match")!) { - const a = match.opponent1.id as number; - const b = match.opponent2.id as number; + for (const match of bracket.matches()) { + const a = match.opponent1!.id as number; + const b = match.opponent2!.id as number; const key = a < b ? `${a}-${b}` : `${b}-${a}`; if (skipMatchups.includes(key)) continue; const winnerId = winnerByMatchup[key]; @@ -305,7 +302,7 @@ describe("round robin standings - dropped out teams", () => { } } - const data = manager.get.tournamentData(1); + const data = bracket.data!; return testTournament({ ctx: { @@ -410,10 +407,9 @@ describe("round robin standings - dropped out teams", () => { describe("round robin A/B divisions standings", () => { const abDivisionsTournament = () => { - const storage = new InMemoryDatabase(); - const manager = new BracketsManager(storage); + const bracket = new EngineBracket(); - manager.create({ + bracket.create({ name: "AB RR", tournamentId: 1, type: "round_robin", @@ -432,10 +428,9 @@ describe("round robin A/B divisions standings", () => { winnerScore: number, loserScore: number, ) => { - const match = storage.select("match", matchId); - invariant(match, `match ${matchId} not found`); - const winnerIsOpp1 = match.opponent1.id === winnerId; - manager.update.match({ + const match = bracket.match(matchId); + const winnerIsOpp1 = match.opponent1?.id === winnerId; + bracket.updateMatch({ id: match.id, opponent1: winnerIsOpp1 ? { score: winnerScore, result: "win" } @@ -452,9 +447,9 @@ describe("round robin A/B divisions standings", () => { "2-3": 2, "3-4": 3, }; - for (const match of storage.select("match")!) { - const a = match.opponent1.id as number; - const b = match.opponent2.id as number; + for (const match of bracket.matches()) { + const a = match.opponent1!.id as number; + const b = match.opponent2!.id as number; const key = a < b ? `${a}-${b}` : `${b}-${a}`; const winnerId = winnerByMatchup[key]; invariant(winnerId, `unexpected matchup ${key}`); @@ -462,7 +457,7 @@ describe("round robin A/B divisions standings", () => { setResult(match.id, winnerId, 2, loserScore); } - const data = manager.get.tournamentData(1); + const data = bracket.data!; return testTournament({ ctx: { @@ -535,10 +530,9 @@ describe("single elimination standings - third place match", () => { }: { thirdPlaceMatchReported: boolean; }) => { - const storage = new InMemoryDatabase(); - const manager = new BracketsManager(storage); + const bracket = new EngineBracket(); - manager.create({ + bracket.create({ name: "SE", tournamentId: 1, type: "single_elimination", @@ -547,21 +541,21 @@ describe("single elimination standings - third place match", () => { }); const reportLowerTeamIdAsWinner = (matchId: number) => { - manager.update.match({ + bracket.updateMatch({ id: matchId, opponent1: { score: 2, result: "win" }, opponent2: { score: 0 }, }); }; - const semifinals = storage - .select("match")! + const semifinals = bracket + .matches() .filter((match) => match.opponent1?.id && match.opponent2?.id); invariant(semifinals.length === 2, "Expected two semifinal matches"); const semifinalLoserIds: number[] = []; for (const match of semifinals) { - semifinalLoserIds.push(match.opponent2.id); + semifinalLoserIds.push(match.opponent2!.id!); reportLowerTeamIdAsWinner(match.id); } @@ -569,14 +563,14 @@ describe("single elimination standings - third place match", () => { let thirdPlaceLoserId: number | undefined; if (thirdPlaceMatchReported) { const thirdPlaceGroupId = Math.max( - ...storage.select("group")!.map((group) => group.id), + ...bracket.groups().map((group) => group.id), ); - const thirdPlaceMatch = storage - .select("match")! + const thirdPlaceMatch = bracket + .matches() .find((match) => match.group_id === thirdPlaceGroupId); invariant(thirdPlaceMatch, "Third place match not found"); - thirdPlaceWinnerId = thirdPlaceMatch.opponent1.id; - thirdPlaceLoserId = thirdPlaceMatch.opponent2.id; + thirdPlaceWinnerId = thirdPlaceMatch.opponent1!.id!; + thirdPlaceLoserId = thirdPlaceMatch.opponent2!.id!; reportLowerTeamIdAsWinner(thirdPlaceMatch.id); } @@ -594,7 +588,7 @@ describe("single elimination standings - third place match", () => { ], }, }, - data: manager.get.tournamentData(1), + data: bracket.data!, }); return { tournament, thirdPlaceWinnerId, thirdPlaceLoserId }; @@ -627,15 +621,10 @@ describe("single elimination standings - third place match", () => { }); }); -const reportLowerIdWinner = ( - storage: InMemoryDatabase, - manager: BracketsManager, - matchId: number, -) => { - const match = storage.select("match", matchId); - invariant(match, `match ${matchId} not found`); - const opponent1Lower = match.opponent1.id < match.opponent2.id; - manager.update.match({ +const reportLowerIdWinner = (bracket: EngineBracket, matchId: number) => { + const match = bracket.match(matchId); + const opponent1Lower = match.opponent1!.id! < match.opponent2!.id!; + bracket.updateMatch({ id: matchId, opponent1: opponent1Lower ? { score: 2, result: "win" } : { score: 0 }, opponent2: opponent1Lower ? { score: 0 } : { score: 2, result: "win" }, @@ -643,11 +632,11 @@ const reportLowerIdWinner = ( }; const readyMatches = ( - storage: InMemoryDatabase, - predicate: (match: any) => boolean, + bracket: EngineBracket, + predicate: (match: ReturnType[number]) => boolean, ) => - storage - .select("match")! + bracket + .matches() .filter( (match) => predicate(match) && @@ -662,10 +651,9 @@ describe("single elimination standings - projected ties", () => { // semifinal so the other is still in progress, mirroring the projected // standings bug where the finished team is shown one placement too low. const partialSingleEliminationTournament = () => { - const storage = new InMemoryDatabase(); - const manager = new BracketsManager(storage); + const bracket = new EngineBracket(); - manager.create({ + bracket.create({ name: "SE", tournamentId: 1, type: "single_elimination", @@ -673,14 +661,17 @@ describe("single elimination standings - projected ties", () => { settings: {}, }); - const semifinals = storage - .select("match")! + const semifinals = bracket + .matches() .filter((match) => match.opponent1?.id && match.opponent2?.id); invariant(semifinals.length === 2, "Expected two semifinal matches"); const decided = semifinals[0]; - const decidedLoserId = Math.max(decided.opponent1.id, decided.opponent2.id); - reportLowerIdWinner(storage, manager, decided.id); + const decidedLoserId = Math.max( + decided.opponent1!.id!, + decided.opponent2!.id!, + ); + reportLowerIdWinner(bracket, decided.id); const tournament = testTournament({ ctx: { @@ -696,7 +687,7 @@ describe("single elimination standings - projected ties", () => { ], }, }, - data: manager.get.tournamentData(1), + data: bracket.data!, }); return { tournament, decidedLoserId }; @@ -719,10 +710,9 @@ describe("double elimination standings - projected ties", () => { // losers round 2 matches so its loser should already project to tied 5th // while the sibling match is still unfinished. const partialDoubleEliminationTournament = () => { - const storage = new InMemoryDatabase(); - const manager = new BracketsManager(storage); + const bracket = new EngineBracket(); - manager.create({ + bracket.create({ name: "DE", tournamentId: 1, type: "double_elimination", @@ -731,52 +721,55 @@ describe("double elimination standings - projected ties", () => { }); const groupId = (number: number) => - storage.select("group")!.find((g) => g.number === number)!.id; + bracket.groups().find((g) => g.number === number)!.id; const winnersGroupId = groupId(1); const losersGroupId = groupId(2); const losersRoundId = (number: number) => - storage - .select("round")! + bracket + .rounds() .find((r) => r.group_id === losersGroupId && r.number === number)!.id; // play out the entire winners bracket so all losers feed in let winnersReady = readyMatches( - storage, + bracket, (m) => m.group_id === winnersGroupId, ); while (winnersReady.length) { for (const match of winnersReady) { - reportLowerIdWinner(storage, manager, match.id); + reportLowerIdWinner(bracket, match.id); } winnersReady = readyMatches( - storage, + bracket, (m) => m.group_id === winnersGroupId, ); } // losers round 1: both matches -> two teams eliminated, tied 7th/8th for (const match of readyMatches( - storage, + bracket, (m) => m.round_id === losersRoundId(1), )) { - reportLowerIdWinner(storage, manager, match.id); + reportLowerIdWinner(bracket, match.id); } // losers round 2: report only one of the two matches const losersRound2 = readyMatches( - storage, + bracket, (m) => m.round_id === losersRoundId(2), ); invariant(losersRound2.length === 2, "Expected two losers round 2 matches"); const decided = losersRound2[0]; - const decidedLoserId = Math.max(decided.opponent1.id, decided.opponent2.id); + const decidedLoserId = Math.max( + decided.opponent1!.id!, + decided.opponent2!.id!, + ); const stillPlayingTeamIds = [ - losersRound2[1].opponent1.id, - losersRound2[1].opponent2.id, + losersRound2[1].opponent1!.id, + losersRound2[1].opponent2!.id, ]; - reportLowerIdWinner(storage, manager, decided.id); + reportLowerIdWinner(bracket, decided.id); const tournament = testTournament({ ctx: { @@ -792,7 +785,7 @@ describe("double elimination standings - projected ties", () => { ], }, }, - data: manager.get.tournamentData(1), + data: bracket.data!, }); return { tournament, decidedLoserId, stillPlayingTeamIds }; diff --git a/app/features/tournament-bracket/core/Bracket/Bracket.ts b/app/features/tournament-bracket/core/Bracket/Bracket.ts index 971783fdd..8e51e7ac9 100644 --- a/app/features/tournament-bracket/core/Bracket/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket/Bracket.ts @@ -2,13 +2,15 @@ import { sub } from "date-fns"; import * as R from "remeda"; import type { Tables, TournamentStageSettings } from "~/db/tables"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; -import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; -import type { Round } from "~/modules/brackets-model"; +import type { + Round, + TournamentManagerDataSet, +} from "~/features/tournament-bracket/core/engine/types"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; import { fillWithNullTillPowerOfTwo } from "../../tournament-bracket-utils"; import * as AbDivisions from "../AbDivisions"; -import { getTournamentManager } from "../brackets-manager"; +import * as Engine from "../engine"; import * as Progression from "../Progression"; import type { OptionalIdObject, Tournament } from "../Tournament"; import type { TournamentDataTeam } from "../Tournament.server"; @@ -44,7 +46,6 @@ export interface Standing { setLosses: number; mapWins: number; mapLosses: number; - points: number; koCount?: number; winsAgainstTied: number; lossesAgainstTied?: number; @@ -125,9 +126,7 @@ export abstract class Bracket { return; try { - const manager = getTournamentManager(); - - manager.importData(this.data); + let data = this.data as Engine.BracketData; const teamOrder = this.teamOrderForSimulation(); @@ -141,7 +140,7 @@ export abstract class Bracket { matchesToResolve = false; loopCount++; - for (const match of manager.export().match) { + for (const match of data.match) { if (!match) continue; // we have a result already if ( @@ -176,8 +175,8 @@ export abstract class Bracket { ? 1 : 2; - manager.update.match({ - id: match.id, + data = Engine.reportResult(data, { + matchId: match.id, opponent1: { score: winner === 1 ? 1 : 0, result: winner === 1 ? "win" : undefined, @@ -186,11 +185,11 @@ export abstract class Bracket { score: winner === 2 ? 1 : 0, result: winner === 2 ? "win" : undefined, }, - }); + }).data; } } - this.simulatedData = manager.export(); + this.simulatedData = data; } catch (e) { logger.error("Bracket.createdSimulation: ", e); } @@ -243,7 +242,8 @@ export abstract class Bracket { .find((match) => match.id === matchId); } - get collectResultsWithPoints() { + /** Whether reporting a game in this bracket also records if the game was a KO win. */ + get collectsKos() { return false; } @@ -290,9 +290,7 @@ export abstract class Bracket { }); } - generateMatchesData(teams: number[]) { - const manager = getTournamentManager(); - + generateMatchesData(teams: number[]): TournamentManagerDataSet { const virtualTournamentId = 1; if (teams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START) { @@ -306,7 +304,7 @@ export abstract class Bracket { ? this.abDivisionsForPreview(teams, settings.groupCount) : undefined; - manager.create({ + return Engine.create({ tournamentId: virtualTournamentId, name: "Virtual", type: this.type, @@ -324,7 +322,7 @@ export abstract class Bracket { }); } - return manager.get.tournamentData(virtualTournamentId); + return { stage: [], group: [], round: [], match: [] }; } private abDivisionsForPreview( diff --git a/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts b/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts index 0991e962a..02118d753 100644 --- a/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts @@ -1,7 +1,9 @@ import * as R from "remeda"; import type { Tables } from "~/db/tables"; -import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; -import type { Round } from "~/modules/brackets-model"; +import type { + Round, + TournamentManagerDataSet, +} from "~/features/tournament-bracket/core/engine/types"; import invariant from "~/utils/invariant"; import type { BracketMapCounts } from "../toMapList"; import { Bracket, type Standing } from "./Bracket"; diff --git a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts index d32b411b8..806dd9a96 100644 --- a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts @@ -1,14 +1,13 @@ import * as R from "remeda"; import type { Tables } from "~/db/tables"; import * as Standings from "~/features/tournament/core/Standings"; -import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; +import type { TournamentManagerDataSet } from "~/features/tournament-bracket/core/engine/types"; import invariant from "~/utils/invariant"; -import { logger } from "~/utils/logger"; import type { BracketMapCounts } from "../toMapList"; import { Bracket, type Standing } from "./Bracket"; export class RoundRobinBracket extends Bracket { - get collectResultsWithPoints() { + get collectsKos() { return true; } @@ -136,7 +135,6 @@ export class RoundRobinBracket extends Bracket { mapWins: number; mapLosses: number; winsAgainstTied: number; - points: number; koCount: number; }[] = []; @@ -146,7 +144,6 @@ export class RoundRobinBracket extends Bracket { setLosses, mapWins, mapLosses, - points, koCount, }: { teamId: number; @@ -154,7 +151,6 @@ export class RoundRobinBracket extends Bracket { setLosses: number; mapWins: number; mapLosses: number; - points: number; koCount: number; }) => { const team = teams.find((team) => team.id === teamId); @@ -163,7 +159,6 @@ export class RoundRobinBracket extends Bracket { team.setLosses += setLosses; team.mapWins += mapWins; team.mapLosses += mapLosses; - team.points += points; team.koCount += koCount; } else { teams.push({ @@ -173,7 +168,6 @@ export class RoundRobinBracket extends Bracket { mapWins, mapLosses, winsAgainstTied: 0, - points, koCount, }); } @@ -212,15 +206,6 @@ export class RoundRobinBracket extends Bracket { "RoundRobinBracket.standings: winner or loser id not found", ); - if ( - typeof winner.totalPoints !== "number" || - typeof loser.totalPoints !== "number" - ) { - logger.warn( - "RoundRobinBracket.standings: winner or loser points not found", - ); - } - // note: score might be missing in the case the set was ended early. In the future we might want to handle this differently than defaulting both to 0. updateTeam({ @@ -229,7 +214,6 @@ export class RoundRobinBracket extends Bracket { setLosses: 0, mapWins: winner.score ?? 0, mapLosses: loser.score ?? 0, - points: winner.totalPoints ?? 0, koCount: winner.totalKos ?? 0, }); updateTeam({ @@ -238,7 +222,6 @@ export class RoundRobinBracket extends Bracket { setLosses: 1, mapWins: loser.score ?? 0, mapLosses: winner.score ?? 0, - points: loser.totalPoints ?? 0, koCount: loser.totalKos ?? 0, }); } @@ -275,7 +258,6 @@ export class RoundRobinBracket extends Bracket { mapWins: 0, mapLosses: 0, winsAgainstTied: 0, - points: 0, koCount: 0, }); } @@ -306,8 +288,8 @@ export class RoundRobinBracket extends Bracket { if (a.mapLosses < b.mapLosses) return -1; if (a.mapLosses > b.mapLosses) return 1; - if (a.points > b.points) return -1; - if (a.points < b.points) return 1; + if (a.koCount > b.koCount) return -1; + if (a.koCount < b.koCount) return 1; const aSeed = Number(this.tournament.teamById(a.id)?.seed); const bSeed = Number(this.tournament.teamById(b.id)?.seed); @@ -327,7 +309,6 @@ export class RoundRobinBracket extends Bracket { setLosses: team.setLosses, mapWins: team.mapWins, mapLosses: team.mapLosses, - points: team.points, koCount: team.koCount, winsAgainstTied: team.winsAgainstTied, }, diff --git a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts index 3cb528cf3..d43aab782 100644 --- a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts @@ -1,7 +1,9 @@ import * as R from "remeda"; import type { Tables } from "~/db/tables"; -import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; -import type { Round } from "~/modules/brackets-model"; +import type { + Round, + TournamentManagerDataSet, +} from "~/features/tournament-bracket/core/engine/types"; import invariant from "~/utils/invariant"; import type { BracketMapCounts } from "../toMapList"; import { Bracket, type Standing } from "./Bracket"; diff --git a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts index c4ec10613..1cccef429 100644 --- a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts @@ -2,19 +2,15 @@ import * as R from "remeda"; import type { Tables } from "~/db/tables"; import * as Standings from "~/features/tournament/core/Standings"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; -import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; +import type { TournamentManagerDataSet } from "~/features/tournament-bracket/core/engine/types"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; import { cutToNDecimalPlaces } from "../../../../utils/number"; -import { calculateTeamStatus } from "../Swiss"; +import { calculateTeamStatus } from "../engine/swiss/team-status"; import type { BracketMapCounts } from "../toMapList"; import { Bracket, type Standing, type TeamTrackRecord } from "./Bracket"; export class SwissBracket extends Bracket { - get collectResultsWithPoints() { - return false; - } - source({ placements, advanceThreshold, @@ -438,7 +434,6 @@ export class SwissBracket extends Bracket { opponentMapWinPercentage: this.trackRecordToWinPercentage( team.opponentMaps, ), - points: 0, }, }; }), diff --git a/app/features/tournament-bracket/core/Bracket/utils.ts b/app/features/tournament-bracket/core/Bracket/utils.ts index 234c5b61b..18855c3fd 100644 --- a/app/features/tournament-bracket/core/Bracket/utils.ts +++ b/app/features/tournament-bracket/core/Bracket/utils.ts @@ -1,5 +1,5 @@ import * as R from "remeda"; -import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; +import type { TournamentManagerDataSet } from "~/features/tournament-bracket/core/engine/types"; /** * Maps each round_id to the cumulative number of teams eliminated by the end of diff --git a/app/features/tournament-bracket/core/Swiss.test.ts b/app/features/tournament-bracket/core/Swiss.test.ts index ca9183463..fd4f1cbde 100644 --- a/app/features/tournament-bracket/core/Swiss.test.ts +++ b/app/features/tournament-bracket/core/Swiss.test.ts @@ -6,7 +6,25 @@ import { } from "~/features/tournament-bracket/core/tests/mocks-swiss"; import { ZONES_WEEKLY_38 } from "~/features/tournament-bracket/core/tests/mocks-zones-weekly"; import invariant from "~/utils/invariant"; -import * as Swiss from "./Swiss"; +import * as Engine from "./engine"; +import { pairUp } from "./engine/swiss/pairing"; +import * as TeamStatus from "./engine/swiss/team-status"; + +const Swiss = { + ...TeamStatus, + pairUp, + create: (args: { + tournamentId: number; + name: string; + seeding: number[]; + settings?: Engine.StageSettings; + }) => + Engine.create({ + ...args, + type: "swiss", + settings: args.settings ?? {}, + }), +}; describe("Swiss", () => { const createArgsWithDefaults = ( @@ -114,17 +132,18 @@ describe("Swiss", () => { const bracket = tournament.bracketByIdx(0)!; - const matches = Swiss.generateMatchUps({ - bracket, + const matches = Engine.generateRound(bracket.data as Engine.BracketData, { groupId: 4443, - })._unsafeUnwrap(); + standings: bracket.standings, + settings: bracket.settings, + })._unsafeUnwrap().matches; it("finds new opponents for each team in the last round", () => { for (const match of matches) { - if (match.opponentTwo === "null") continue; + if (match.opponent2 === null) continue; - const opponent1 = JSON.parse(match.opponentOne).id as number; - const opponent2 = JSON.parse(match.opponentTwo).id as number; + const opponent1 = match.opponent1!.id as number; + const opponent2 = match.opponent2.id as number; const existingMatch = bracket.data.match.find( (m) => @@ -138,16 +157,16 @@ describe("Swiss", () => { }); it("generates a bye", () => { - const byes = matches.filter((match) => match.opponentTwo === "null"); + const byes = matches.filter((match) => match.opponent2 === null); expect(byes).toHaveLength(1); }); it("every pair is max one set win from each other", () => { for (const match of matches) { - if (match.opponentTwo === "null") continue; + if (match.opponent2 === null) continue; - const opponent1 = JSON.parse(match.opponentOne).id as number; - const opponent2 = JSON.parse(match.opponentTwo).id as number; + const opponent1 = match.opponent1!.id as number; + const opponent2 = match.opponent2.id as number; const opponent1Stats = bracket.standings.find( (s) => s.team.id === opponent1, diff --git a/app/features/tournament-bracket/core/Swiss.ts b/app/features/tournament-bracket/core/Swiss.ts deleted file mode 100644 index ad4b40bf9..000000000 --- a/app/features/tournament-bracket/core/Swiss.ts +++ /dev/null @@ -1,533 +0,0 @@ -// separate from brackets-manager as this wasn't part of the original brackets-manager library - -import blossom from "edmonds-blossom-fixed"; -import { err, ok } from "neverthrow"; -import * as R from "remeda"; -import type { TournamentRepositoryInsertableMatch } from "~/features/tournament/TournamentRepository.server"; -import { TOURNAMENT } from "~/features/tournament/tournament-constants"; -import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; -import type { InputStage, Match } from "~/modules/brackets-model"; -import { nullFilledArray } from "~/utils/arrays"; -import invariant from "~/utils/invariant"; -import type { Bracket } from "./Bracket"; - -/** - * Creates a Swiss tournament data set (initial matches) based on the provided arguments. Mimics bracket-manager module's interfaces. - */ -export function create( - args: Omit & { seeding: number[] }, -): TournamentManagerDataSet { - const swissSettings = args.settings?.swiss; - - const groupCount = - swissSettings?.groupCount ?? TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT; - const roundCount = - swissSettings?.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT; - - const group = nullFilledArray(groupCount).map((_, i) => ({ - id: i, - stage_id: 0, - number: i + 1, - })); - - let roundId = 0; - return { - group, - match: firstRoundMatches({ seeding: args.seeding, groupCount, roundCount }), - round: group.flatMap((g) => - nullFilledArray(roundCount).map((_, i) => ({ - id: roundId++, - group_id: g.id, - number: i + 1, - stage_id: 0, - })), - ), - stage: [ - { - id: 0, - name: args.name, - number: 1, - settings: args.settings ?? {}, - tournament_id: args.tournamentId, - type: "swiss", - }, - ], - }; -} - -function firstRoundMatches({ - seeding, - groupCount, - roundCount, -}: { - seeding: InputStage["seeding"]; - groupCount: number; - roundCount: number; -}): Match[] { - // split the teams to one or more groups. For example with 16 teams and 3 groups this would result in - // group 1: 1, 4, 7, 10, 13, 16 - // group 2: 2, 5, 8, 11, 14 - // group 3: 3, 6, 9, 12, 15 - const groups = splitToGroups(); - - const result: Match[] = []; - - let matchId = 0; - for (const [groupIdx, participants] of groups.entries()) { - // if there is an uneven number of teams the last seed gets a bye - const bye = participants.length % 2 === 0 ? null : participants.pop(); - - const halfI = participants.length / 2; - const upperHalf = participants.slice(0, halfI); - const lowerHalf = participants.slice(halfI); - - invariant( - upperHalf.length === lowerHalf.length, - "firstRoundMatches: halfs not equal", - ); - - // first round every team plays the matching team "on the opposite side" - // so for example with 8 teams match ups look like this: - // seed 1 vs. seed 5 - // seed 2 vs. seed 6 - // seed 3 vs. seed 7 - // seed 4 vs. seed 8 - // --- - // this way each match has "equal distance" - const roundId = groupIdx * roundCount; - for (let i = 0; i < upperHalf.length; i++) { - const upper = upperHalf[i]; - const lower = lowerHalf[i]; - - result.push({ - id: matchId++, - group_id: groupIdx, - stage_id: 0, - round_id: roundId, - number: i + 1, - opponent1: { - id: upper, - }, - opponent2: { - id: lower, - }, - status: 2, - }); - } - - if (bye) { - result.push({ - id: matchId++, - group_id: groupIdx, - stage_id: 0, - round_id: roundId, - number: upperHalf.length + 1, - opponent1: { - id: bye, - }, - opponent2: null, - status: 2, - }); - } - } - - return result; - - function splitToGroups() { - if (!seeding) return []; - if (groupCount === 1) return [[...seeding]]; - - const groups: number[][] = nullFilledArray(groupCount).map(() => []); - - for (let i = 0; i < seeding.length; i++) { - const groupIndex = i % groupCount; - groups[groupIndex].push(seeding[i]!); - } - - return groups; - } -} - -function everyMatchOver(matches: Match[]) { - for (const match of matches) { - // bye - if (!match.opponent1 || !match.opponent2) continue; - - if (match.opponent1.result !== "win" && match.opponent2.result !== "win") { - return false; - } - } - - return true; -} - -/** - * Generates the next round of matchups for a Swiss tournament bracket within a specific group. - * - * Considers only the matches and teams within the specified group. Teams that have dropped out are excluded from the pairing process. - * If the group has an uneven number of teams, the lowest standing team that has not already received a bye is preferred to receive one. - * Matches are generated such that teams do not replay previous opponents if possible. - */ -export function generateMatchUps({ - bracket, - groupId, -}: { - bracket: Bracket; - groupId: number; -}) { - // lets consider only this groups matches - // in the case that there are more than one group - const groupsMatches = bracket.data.match.filter( - (m) => m.group_id === groupId, - ); - - if (groupsMatches.length === 0) return err("No matches found for group"); - if (bracket.type !== "swiss") return err("Bracket is not Swiss type"); - - // new matches can't be generated till old are over - if (!everyMatchOver(groupsMatches)) { - return err("Not all matches are over"); - } - - const groupsTeams = groupsMatches - .flatMap((match) => [match.opponent1, match.opponent2]) - .filter(Boolean); - const groupsStandings = bracket.standings.filter((standing) => { - return groupsTeams.some((team) => team?.id === standing.team.id); - }); - - // teams who have dropped out are not considered - let standingsWithoutDropouts = groupsStandings.filter( - (s) => !s.team.droppedOut, - ); - - // filter out teams that have advanced or been eliminated if early advance/elimination is enabled - if (typeof bracket.settings?.advanceThreshold === "number") { - const roundCount = - bracket.settings.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT; - const advanceThreshold = bracket.settings.advanceThreshold; - - standingsWithoutDropouts = standingsWithoutDropouts.filter((standing) => { - const wins = standing.stats?.setWins ?? 0; - const losses = standing.stats?.setLosses ?? 0; - const status = calculateTeamStatus({ - wins, - losses, - advanceThreshold, - roundCount, - }); - - return status === "active"; - }); - } - - // if there are fewer than 2 active teams, no more matches can be generated - if (standingsWithoutDropouts.length < 2) { - return err("Not enough active teams to generate matches"); - } - - const teamsThatHaveHadByes = groupsMatches - .filter((m) => m.opponent2 === null) - .map((m) => m.opponent1?.id); - - const pairs = pairUp( - standingsWithoutDropouts.map((standing) => ({ - id: standing.team.id, - score: standing.stats?.setWins ?? 0, - receivedBye: teamsThatHaveHadByes.includes(standing.team.id), - avoid: groupsMatches.flatMap((match) => { - if (match.opponent1?.id === standing.team.id) { - return match.opponent2?.id ? [match.opponent2.id] : []; - } - if (match.opponent2?.id === standing.team.id) { - return match.opponent1?.id ? [match.opponent1.id] : []; - } - return []; - }), - })), - ); - - let matchNumber = 1; - const newRoundId = bracket.data.round - .slice() - .sort((a, b) => a.id - b.id) - .filter((r) => r.group_id === groupId) - .find( - (r) => r.id > Math.max(...groupsMatches.map((match) => match.round_id)), - )?.id; - invariant(newRoundId, "newRoundId not found"); - const result: TournamentRepositoryInsertableMatch[] = pairs.map( - ({ opponentOne, opponentTwo }) => ({ - groupId, - number: matchNumber++, - roundId: newRoundId, - stageId: groupsMatches[0].stage_id, - opponentOne: JSON.stringify({ - id: opponentOne, - }), - opponentTwo: - typeof opponentTwo === "number" - ? JSON.stringify({ - id: opponentTwo, - }) - : JSON.stringify(null), - }), - ); - - return ok(result); -} - -interface SwissPairingTeam { - id: number; - /** How many matches has the team won */ - score: number; - /** List of tournament team ids this team already played */ - avoid: Array; - receivedBye?: boolean; -} - -// adapted from https://github.com/slashinfty/tournament-pairings -export function pairUp(players: SwissPairingTeam[]) { - if (players.length < 2) { - throw new Error("Need at least two players to pair up"); - } - if (players.length === 2) { - return [{ opponentOne: players[0].id, opponentTwo: players[1].id }]; - } - - // uncomment to add a new test case to PAIR_UP_TEST_CASES - // console.log(players); - - const matches = []; - const playerArray = R.shuffle(players).map((p, i) => ({ ...p, index: i })); - const scoreGroups = [...new Set(playerArray.map((p) => p.score))].sort( - (a, b) => a - b, - ); - const scoreSums = [ - ...new Set( - scoreGroups.flatMap((s, i, a) => { - const sums = []; - for (let j = i; j < a.length; j++) { - sums.push(s + a[j]); - } - return sums; - }), - ), - ].sort((a, b) => a - b); - - let pairs = generateWeightedPairs({ playerArray, scoreGroups, scoreSums }); - if (pairs.length === 0) { - // no possible pairs without rematches, try again allowing rematches - pairs = generateWeightedPairs({ - playerArray, - scoreGroups, - scoreSums, - considerAvoid: false, - }); - } - - const blossomPairs = blossom(pairs, true); - const playerCopy = [...playerArray]; - let byeArray = []; - do { - const indexA = playerCopy[0].index; - const indexB = blossomPairs[indexA]; - if (indexB === -1) { - byeArray.push(playerCopy.splice(0, 1)[0]); - continue; - } - playerCopy.splice(0, 1); - playerCopy.splice( - playerCopy.findIndex((p) => p.index === indexB), - 1, - ); - const playerA = playerArray.find((p) => p.index === indexA); - const playerB = playerArray.find((p) => p.index === indexB); - invariant(playerA, "Player A not found"); - invariant(playerB, "Player B not found"); - - matches.push({ - opponentOne: playerA.id, - opponentTwo: playerB.id, - }); - } while ( - playerCopy.length > - blossomPairs.reduce( - (sum: number, idx: number) => (idx === -1 ? sum + 1 : sum), - 0, - ) - ); - byeArray = [...byeArray, ...playerCopy]; - for (let i = 0; i < byeArray.length; i++) { - matches.push({ - opponentOne: byeArray[i].id, - opponentTwo: null, - }); - } - - return matches; -} - -function generateWeightedPairs({ - playerArray, - scoreGroups, - scoreSums, - considerAvoid = true, -}: { - playerArray: (SwissPairingTeam & { index: number })[]; - scoreGroups: number[]; - scoreSums: number[]; - considerAvoid?: boolean; -}) { - const pairs: [number, number, number][] = []; - for (let i = 0; i < playerArray.length; i++) { - const curr = playerArray[i]; - const next = playerArray.slice(i + 1); - for (let j = 0; j < next.length; j++) { - const opp = next[j]; - if ( - considerAvoid && - Object.hasOwn(curr, "avoid") && - curr.avoid.includes(opp.id) - ) { - continue; - } - let wt = - 75 - 75 / (scoreGroups.indexOf(Math.min(curr.score, opp.score)) + 2); - wt += - 5 - 5 / (scoreSums.findIndex((s) => s === curr.score + opp.score) + 1); - const scoreGroupDiff = Math.abs( - scoreGroups.indexOf(curr.score) - scoreGroups.indexOf(opp.score), - ); - - // TODO: consider "pairedUpDown" - // if ( - // scoreGroupDiff === 1 && - // curr.hasOwnProperty("pairedUpDown") && - // curr.pairedUpDown === false && - // opp.hasOwnProperty("pairedUpDown") && - // opp.pairedUpDown === false - // ) { - // scoreGroupDiff -= 0.65; - // } else if ( - // scoreGroupDiff > 0 && - // ((curr.hasOwnProperty("pairedUpDown") && curr.pairedUpDown === true) || - // (opp.hasOwnProperty("pairedUpDown") && opp.pairedUpDown === true)) - // ) { - // scoreGroupDiff += 0.2; - // } - - wt += 23 / (2 * (scoreGroupDiff + 2)); - - // Lower weight for larger score differences, we really want to avoid 2-0 playing 0-2 etc. - if (scoreGroupDiff >= 2) { - wt -= 10; - } - - if ( - (Object.hasOwn(curr, "receivedBye") && curr.receivedBye) || - (Object.hasOwn(opp, "receivedBye") && opp.receivedBye) - ) { - wt += 40; - } - pairs.push([curr.index, opp.index, wt]); - } - } - - return pairs; -} - -export type SwissTeamStatus = "active" | "advanced" | "eliminated"; - -/** - * Calculates whether a team should advance, be eliminated, or remain active - * in a Swiss tournament with early advance/elimination rules. - * - * @returns The team's status: "advanced" if they've secured advancement, - * "eliminated" if they can no longer mathematically advance, or "active" if still competing - * - * @example - * // In a 5-round Swiss where teams need 3 wins to advance: - * calculateTeamStatus({ wins: 3, losses: 1, advanceThreshold: 3, roundCount: 5 }) // "advanced" - * calculateTeamStatus({ wins: 2, losses: 3, advanceThreshold: 3, roundCount: 5 }) // "eliminated" - * calculateTeamStatus({ wins: 2, losses: 2, advanceThreshold: 3, roundCount: 5 }) // "active" - */ -export function calculateTeamStatus({ - wins, - losses, - advanceThreshold, - roundCount, -}: { - /** Number of matches the team has won */ - wins: number; - /** Number of matches the team has lost */ - losses: number; - /** Number of wins required to advance to the next stage */ - advanceThreshold: number; - /** Total number of rounds in the Swiss stage */ - roundCount: number; -}): SwissTeamStatus { - if (wins >= advanceThreshold) { - return "advanced"; - } - - if (losses >= eliminationThreshold({ roundCount, advanceThreshold })) { - return "eliminated"; - } - - return "active"; -} - -/** - * Calculates the maximum valid advance threshold for a given round count. - * The threshold must allow for meaningful play - teams need a chance to both advance and be eliminated. - */ -export function maxAdvanceThreshold({ roundCount }: { roundCount: number }) { - return Math.ceil(roundCount / 2) + 1; -} - -/** - * Calculates the maximum losses allowed before elimination given an advance threshold and round count. - */ -export function eliminationThreshold({ - roundCount, - advanceThreshold, -}: { - roundCount: number; - advanceThreshold: number; -}) { - return roundCount - advanceThreshold + 1; -} - -/** - * Validates if an advance threshold is valid for the given round count. - */ -export function isValidAdvanceThreshold({ - roundCount, - advanceThreshold, -}: { - roundCount: number; - advanceThreshold: number; -}) { - return validAdvanceThresholdOptions({ roundCount }).includes( - advanceThreshold, - ); -} - -/** - * Returns a list of valid advance threshold options for a given round count. - * Starts from 2 wins minimum up to the calculated maximum. - */ -export function validAdvanceThresholdOptions({ - roundCount, -}: { - roundCount: number; -}) { - const result: number[] = []; - - for (let i = 2; i <= maxAdvanceThreshold({ roundCount }); i++) { - result.push(i); - } - - return result; -} diff --git a/app/features/tournament-bracket/core/Tournament.server.ts b/app/features/tournament-bracket/core/Tournament.server.ts index ce92a69d8..fd166663f 100644 --- a/app/features/tournament-bracket/core/Tournament.server.ts +++ b/app/features/tournament-bracket/core/Tournament.server.ts @@ -2,27 +2,23 @@ import { sub } from "date-fns"; import { ServerConfig } from "~/config.server"; import { clearCombinedStreamsCache } from "~/features/core/streams/streams.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server"; +import type { TournamentManagerDataSet } from "~/features/tournament-bracket/core/engine/types"; import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server"; -import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; import { isAdmin } from "~/modules/permissions/utils"; import { databaseTimestampToDate } from "~/utils/dates"; import { notFoundIfFalsy } from "~/utils/remix.server"; import type { Unwrapped } from "~/utils/types"; -import { getServerTournamentManager } from "./brackets-manager/manager.server"; import { RunningTournaments } from "./RunningTournaments.server"; import { Tournament } from "./Tournament"; -const manager = getServerTournamentManager(); - -export const tournamentManagerData = (tournamentId: number) => - manager.get.tournamentData(tournamentId); const combinedTournamentData = async (tournamentId: number) => { const ctx = await TournamentRepository.findById(tournamentId); if (!ctx) return null; return { - data: tournamentManagerData(tournamentId), + data: await BracketRepository.findByTournamentId(tournamentId), ctx, }; }; diff --git a/app/features/tournament-bracket/core/Tournament.test.ts b/app/features/tournament-bracket/core/Tournament.test.ts index 43e18f40f..642bad46a 100644 --- a/app/features/tournament-bracket/core/Tournament.test.ts +++ b/app/features/tournament-bracket/core/Tournament.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, test } from "vitest"; -import type { Match } from "~/modules/brackets-model"; +import type { Match } from "~/features/tournament-bracket/core/engine/types"; import { Tournament } from "./Tournament"; import { IN_THE_ZONE_32, diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index dead58fa1..038a3caec 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -13,9 +13,12 @@ import { tournamentInWeaponReportingWindow, tournamentIsRanked, } from "~/features/tournament/tournament-utils"; +import type { + Match, + Stage, + TournamentManagerDataSet, +} from "~/features/tournament-bracket/core/engine/types"; import type * as Progression from "~/features/tournament-bracket/core/Progression"; -import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; -import type { Match, Stage } from "~/modules/brackets-model"; import type { ModeShort } from "~/modules/in-game-lists/types"; import { isAdmin } from "~/modules/permissions/utils"; import { @@ -31,9 +34,8 @@ import { groupNumberToLetters, } from "../tournament-bracket-utils"; import { type Bracket, createBracket } from "./Bracket"; -import { getTournamentManager } from "./brackets-manager"; +import * as Engine from "./engine"; import { getRounds } from "./rounds"; -import * as Swiss from "./Swiss"; import type { TournamentData, TournamentDataTeam } from "./Tournament.server"; export type OptionalIdObject = { id: number } | undefined; @@ -124,6 +126,7 @@ export class Tournament { type, }), ); + // xxx: lets aim to get rid of this else if } else if (type === "swiss") { const { teams, relevantMatchesFinished } = sources ? this.resolveTeamsFromSources(sources, bracketIdx) @@ -148,9 +151,10 @@ export class Tournament { requiresCheckIn, startTime: startTime ? databaseTimestampToDate(startTime) : null, settings: settings ?? null, - data: Swiss.create({ + data: Engine.create({ tournamentId: this.ctx.id, name, + type: "swiss", seeding: checkedInTeams, settings: this.bracketManagerSettings( settings, @@ -385,8 +389,7 @@ export class Tournament { ); const bracketReplays = (candidateTeams: number[]) => { - const manager = getTournamentManager(); - manager.create({ + const matches = Engine.create({ tournamentId: this.ctx.id, name: "X", type: bracket.type as Exclude< @@ -399,9 +402,7 @@ export class Tournament { bracket.type, candidateTeams.length, ), - }); - - const matches = manager.get.tournamentData(this.ctx.id).match; + }).match; const replays: [number, number][] = []; for (const match of matches) { if (!match.opponent1?.id || !match.opponent2?.id) continue; diff --git a/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts b/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts deleted file mode 100644 index efd74bc71..000000000 --- a/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts +++ /dev/null @@ -1,501 +0,0 @@ -// this file offers database functions specifically for the crud.server.ts file - -import { sql } from "~/db/sql"; -import type { Tables } from "~/db/tables"; -import type { - Group as GroupType, - Match as MatchType, - Round as RoundType, - Stage as StageType, -} from "~/modules/brackets-model"; -import { dateToDatabaseTimestamp } from "~/utils/dates"; -import { shortNanoid } from "~/utils/id"; - -const stage_getByIdStm = sql.prepare(/*sql*/ ` - select - * - from - "TournamentStage" - where - "TournamentStage"."id" = @id -`); - -const stage_getByTournamentIdStm = sql.prepare(/*sql*/ ` - select - * - from - "TournamentStage" - where - "TournamentStage"."tournamentId" = @tournamentId -`); - -const stage_insertStm = sql.prepare(/*sql*/ ` - insert into - "TournamentStage" - ("tournamentId", "number", "name", "type", "settings", "createdAt") - values - (@tournamentId, @number, @name, @type, @settings, @createdAt) - returning * -`); - -const stage_updateSettingsStm = sql.prepare(/*sql*/ ` - update - "TournamentStage" - set - "settings" = @settings - where - "TournamentStage"."id" = @id -`); - -export class Stage { - id?: Tables["TournamentStage"]["id"]; - tournamentId: Tables["TournamentStage"]["tournamentId"]; - number: Tables["TournamentStage"]["number"]; - name: Tables["TournamentStage"]["name"]; - type: StageType["type"]; - settings: Tables["TournamentStage"]["settings"]; - - constructor( - id: Tables["TournamentStage"]["id"] | undefined, - tournamentId: Tables["TournamentStage"]["tournamentId"], - number: Tables["TournamentStage"]["number"], - name: Tables["TournamentStage"]["name"], - type: StageType["type"], - settings: Tables["TournamentStage"]["settings"], - ) { - this.id = id; - this.tournamentId = tournamentId; - this.number = number; - this.name = name; - this.type = type; - this.settings = settings; - } - - insert() { - const stage = stage_insertStm.get({ - tournamentId: this.tournamentId, - number: this.number, - name: this.name, - type: this.type, - settings: this.settings, - createdAt: dateToDatabaseTimestamp(new Date()), - }) as any; - - this.id = stage.id; - - return true; - } - - static #convertStage(rawStage: Tables["TournamentStage"]): StageType { - return { - id: rawStage.id, - name: rawStage.name, - number: rawStage.number, - settings: JSON.parse(rawStage.settings), - tournament_id: rawStage.tournamentId, - type: rawStage.type, - createdAt: rawStage.createdAt, - }; - } - - static getById(id: Tables["TournamentStage"]["id"]): StageType { - const stage = stage_getByIdStm.get({ id }) as any; - if (!stage) return stage; - return Stage.#convertStage(stage); - } - - static getByTournamentId(tournamentId: number): StageType[] { - return (stage_getByTournamentIdStm.all({ tournamentId }) as any[]).map( - Stage.#convertStage, - ); - } - - static updateSettings( - id: Tables["TournamentStage"]["id"], - settings: Tables["TournamentStage"]["settings"], - ) { - stage_updateSettingsStm.run({ id, settings }); - - return true; - } -} - -const group_getByIdStm = sql.prepare(/*sql*/ ` - select * - from "TournamentGroup" - where "TournamentGroup"."id" = @id -`); - -const group_getByStageIdStm = sql.prepare(/*sql*/ ` - select * - from "TournamentGroup" - where "TournamentGroup"."stageId" = @stageId -`); - -const group_getByStageAndNumberStm = sql.prepare(/*sql*/ ` - select * - from "TournamentGroup" - where "TournamentGroup"."stageId" = @stageId - and "TournamentGroup"."number" = @number -`); - -const group_insertStm = sql.prepare(/*sql*/ ` - insert into - "TournamentGroup" - ("stageId", "number") - values - (@stageId, @number) - returning * -`); - -export class Group { - id?: Tables["TournamentGroup"]["id"]; - stageId: Tables["TournamentGroup"]["stageId"]; - number: Tables["TournamentGroup"]["number"]; - - constructor( - id: Tables["TournamentGroup"]["id"] | undefined, - stageId: Tables["TournamentGroup"]["stageId"], - number: Tables["TournamentGroup"]["number"], - ) { - this.id = id; - this.stageId = stageId; - this.number = number; - } - - static #convertGroup(rawGroup: Tables["TournamentGroup"]): GroupType { - return { - id: rawGroup.id, - number: rawGroup.number, - stage_id: rawGroup.stageId, - }; - } - - static getById(id: Tables["TournamentGroup"]["id"]): GroupType { - const group = group_getByIdStm.get({ id }) as any; - if (!group) return group; - return Group.#convertGroup(group); - } - - static getByStageId(stageId: Tables["TournamentStage"]["id"]): GroupType[] { - return (group_getByStageIdStm.all({ stageId }) as any[]).map( - Group.#convertGroup, - ); - } - - static getByStageAndNumber( - stageId: Tables["TournamentStage"]["id"], - number: Tables["TournamentGroup"]["number"], - ): GroupType { - const group = group_getByStageAndNumberStm.get({ stageId, number }) as any; - if (!group) return group; - return Group.#convertGroup(group_getByStageAndNumberStm.get(group) as any); - } - - insert() { - const group = group_insertStm.get({ - stageId: this.stageId, - number: this.number, - }) as any; - - this.id = group.id; - - return true; - } -} - -const round_getByIdStm = sql.prepare(/*sql*/ ` - select * - from "TournamentRound" - where "TournamentRound"."id" = @id -`); - -const round_getByGroupIdStm = sql.prepare(/*sql*/ ` - select * - from "TournamentRound" - where "TournamentRound"."groupId" = @groupId -`); - -const round_getByStageIdStm = sql.prepare(/*sql*/ ` - select * - from "TournamentRound" - where "TournamentRound"."stageId" = @stageId -`); - -const round_getByGroupAndNumberStm = sql.prepare(/*sql*/ ` - select * - from "TournamentRound" - where "TournamentRound"."groupId" = @groupId - and "TournamentRound"."number" = @number -`); - -const round_insertStm = sql.prepare(/*sql*/ ` - insert into - "TournamentRound" - ("stageId", "groupId", "number") - values - (@stageId, @groupId, @number) - returning * -`); - -export class Round { - id?: Tables["TournamentRound"]["id"]; - stageId: Tables["TournamentRound"]["stageId"]; - groupId: Tables["TournamentRound"]["groupId"]; - number: Tables["TournamentRound"]["number"]; - - constructor( - id: Tables["TournamentRound"]["id"] | undefined, - stageId: Tables["TournamentRound"]["stageId"], - groupId: Tables["TournamentRound"]["groupId"], - number: Tables["TournamentRound"]["number"], - ) { - this.id = id; - this.stageId = stageId; - this.groupId = groupId; - this.number = number; - } - - insert() { - const round = round_insertStm.get({ - stageId: this.stageId, - groupId: this.groupId, - number: this.number, - }) as any; - - this.id = round.id; - - return true; - } - - static #convertRound( - rawRound: Tables["TournamentRound"] & { maps?: string | null }, - ): RoundType { - const parsedMaps = rawRound.maps ? JSON.parse(rawRound.maps) : null; - - return { - id: rawRound.id, - group_id: rawRound.groupId, - number: rawRound.number, - stage_id: rawRound.stageId, - maps: parsedMaps - ? { - count: parsedMaps.count, - type: parsedMaps.type, - pickBan: parsedMaps.pickBan, - } - : null, - }; - } - - static getByStageId(stageId: Tables["TournamentStage"]["id"]): RoundType[] { - return (round_getByStageIdStm.all({ stageId }) as any[]).map( - Round.#convertRound, - ); - } - - static getByGroupId(groupId: Tables["TournamentGroup"]["id"]): RoundType[] { - return (round_getByGroupIdStm.all({ groupId }) as any[]).map( - Round.#convertRound, - ); - } - - static getByGroupAndNumber( - groupId: Tables["TournamentGroup"]["id"], - number: Tables["TournamentRound"]["number"], - ): RoundType { - const round = round_getByGroupAndNumberStm.get({ groupId, number }) as any; - if (!round) return round; - return Round.#convertRound(round); - } - - static getById(id: Tables["TournamentRound"]["id"]): RoundType { - const round = round_getByIdStm.get({ id }) as any; - if (!round) return round; - return Round.#convertRound(round); - } -} - -const match_getByIdStm = sql.prepare(/*sql*/ ` - select * - from "TournamentMatch" - where "TournamentMatch"."id" = @id -`); - -const match_getByRoundIdStm = sql.prepare(/*sql*/ ` - select * - from "TournamentMatch" - where "TournamentMatch"."roundId" = @roundId -`); - -const match_getByStageIdStm = sql.prepare(/*sql*/ ` - select - "TournamentMatch".*, - sum("TournamentMatchGameResult"."opponentOnePoints") as "opponentOnePointsTotal", - sum("TournamentMatchGameResult"."opponentTwoPoints") as "opponentTwoPointsTotal", - sum(case when "TournamentMatchGameResult"."opponentOnePoints" = 100 and "TournamentMatchGameResult"."opponentTwoPoints" = 0 then 1 else 0 end) as "opponentOneKosTotal", - sum(case when "TournamentMatchGameResult"."opponentTwoPoints" = 100 and "TournamentMatchGameResult"."opponentOnePoints" = 0 then 1 else 0 end) as "opponentTwoKosTotal" - from "TournamentMatch" - left join "TournamentMatchGameResult" on "TournamentMatch"."id" = "TournamentMatchGameResult"."matchId" - where "TournamentMatch"."stageId" = @stageId - group by "TournamentMatch"."id" -`); - -const match_getByRoundAndNumberStm = sql.prepare(/*sql*/ ` - select * - from "TournamentMatch" - where "TournamentMatch"."roundId" = @roundId - and "TournamentMatch"."number" = @number -`); - -const match_insertStm = sql.prepare(/*sql*/ ` - insert into - "TournamentMatch" - ("roundId", "stageId", "groupId", "number", "opponentOne", "opponentTwo", "status", "chatCode", "startedAt") - values - (@roundId, @stageId, @groupId, @number, @opponentOne, @opponentTwo, @status, @chatCode, @startedAt) - returning * -`); - -const match_updateStm = sql.prepare(/*sql*/ ` - update "TournamentMatch" - set - "roundId" = @roundId, - "stageId" = @stageId, - "groupId" = @groupId, - "number" = @number, - "opponentOne" = @opponentOne, - "opponentTwo" = @opponentTwo, - "status" = @status - where - "TournamentMatch"."id" = @id -`); - -export class Match { - id?: Tables["TournamentMatch"]["id"]; - roundId: Tables["TournamentMatch"]["roundId"]; - stageId: Tables["TournamentMatch"]["stageId"]; - groupId: Tables["TournamentMatch"]["groupId"]; - number: Tables["TournamentMatch"]["number"]; - opponentOne: string; - opponentTwo: string; - status: Tables["TournamentMatch"]["status"]; - - constructor( - id: Tables["TournamentMatch"]["id"] | undefined, - status: Tables["TournamentMatch"]["status"], - stageId: Tables["TournamentMatch"]["stageId"], - groupId: Tables["TournamentMatch"]["groupId"], - roundId: Tables["TournamentMatch"]["roundId"], - number: Tables["TournamentMatch"]["number"], - opponentOne: string, - opponentTwo: string, - ) { - this.id = id; - this.roundId = roundId; - this.stageId = stageId; - this.groupId = groupId; - this.number = number; - this.opponentOne = opponentOne; - this.opponentTwo = opponentTwo; - this.status = status; - } - - static #convertMatch( - rawMatch: Tables["TournamentMatch"] & { - opponentOne: string; - opponentTwo: string; - opponentOnePointsTotal: number | null; - opponentTwoPointsTotal: number | null; - opponentOneKosTotal: number | null; - opponentTwoKosTotal: number | null; - startedAt: number | null; - }, - ): MatchType { - return { - id: rawMatch.id, - group_id: rawMatch.groupId, - number: rawMatch.number, - opponent1: - rawMatch.opponentOne === "null" - ? null - : { - ...JSON.parse(rawMatch.opponentOne), - totalPoints: rawMatch.opponentOnePointsTotal ?? undefined, - totalKos: rawMatch.opponentOneKosTotal ?? undefined, - }, - opponent2: - rawMatch.opponentTwo === "null" - ? null - : { - ...JSON.parse(rawMatch.opponentTwo), - totalPoints: rawMatch.opponentTwoPointsTotal ?? undefined, - totalKos: rawMatch.opponentTwoKosTotal ?? undefined, - }, - round_id: rawMatch.roundId, - stage_id: rawMatch.stageId, - status: rawMatch.status, - startedAt: rawMatch.startedAt, - }; - } - - static getById(id: Tables["TournamentMatch"]["id"]): MatchType { - const match = match_getByIdStm.get({ id }) as any; - if (!match) return match; - return Match.#convertMatch(match); - } - - static getByRoundId(roundId: Tables["TournamentRound"]["id"]): MatchType[] { - return (match_getByRoundIdStm.all({ roundId }) as any[]).map( - Match.#convertMatch, - ); - } - - static getByStageId(stageId: Tables["TournamentStage"]["id"]): MatchType[] { - return (match_getByStageIdStm.all({ stageId }) as any[]).map( - Match.#convertMatch, - ); - } - - static getByRoundAndNumber( - roundId: Tables["TournamentRound"]["id"], - number: Tables["TournamentMatch"]["number"], - ): MatchType { - const match = match_getByRoundAndNumberStm.get({ roundId, number }) as any; - if (!match) return match; - return Match.#convertMatch(match); - } - - insert() { - const match = match_insertStm.get({ - roundId: this.roundId, - stageId: this.stageId, - groupId: this.groupId, - number: this.number, - opponentOne: this.opponentOne ?? "null", - opponentTwo: this.opponentTwo ?? "null", - status: this.status, - chatCode: shortNanoid(), - startedAt: null, - }) as any; - - this.id = match.id; - - return true; - } - - update() { - match_updateStm.run({ - id: this.id ?? null, - roundId: this.roundId, - stageId: this.stageId, - groupId: this.groupId, - number: this.number, - opponentOne: this.opponentOne ?? "null", - opponentTwo: this.opponentTwo ?? "null", - status: this.status, - }); - - return true; - } -} diff --git a/app/features/tournament-bracket/core/brackets-manager/crud.server.ts b/app/features/tournament-bracket/core/brackets-manager/crud.server.ts deleted file mode 100644 index 31dad6068..000000000 --- a/app/features/tournament-bracket/core/brackets-manager/crud.server.ts +++ /dev/null @@ -1,218 +0,0 @@ -import type { - CrudInterface, - DataTypes, - OmitId, - Table, -} from "~/modules/brackets-manager/types"; -import { Group, Match, Round, Stage } from "./crud-db.server"; - -export class SqlDatabase implements CrudInterface { - insert(table: T, value: OmitId): number; - insert(table: T, values: OmitId[]): boolean; - insert( - table: T, - arg: OmitId | OmitId[], - ): number | boolean { - switch (table) { - case "stage": { - const value = arg as OmitId; - const stage = new Stage( - undefined, - value.tournament_id, - value.number, - value.name, - value.type, - JSON.stringify(value.settings), - ); - stage.insert(); - return stage.id!; - } - - case "group": { - const value = arg as OmitId; - const group = new Group(undefined, value.stage_id, value.number); - group.insert(); - return group.id!; - } - - case "round": { - const value = arg as OmitId; - const round = new Round( - undefined, - value.stage_id, - value.group_id, - value.number, - ); - round.insert(); - return round.id!; - } - - case "match": { - const value = arg as OmitId; - const match = new Match( - undefined, - value.status, - value.stage_id, - value.group_id, - value.round_id, - value.number, - JSON.stringify(value.opponent1), - JSON.stringify(value.opponent2), - ); - match.insert(); - return match.id!; - } - } - } - - select(table: T): Array | null; - select(table: T, id: number): DataTypes[T] | null; - select( - table: T, - filter: Partial, - ): Array | null; - select( - table: T, - arg?: number | Partial, - ): DataTypes[T] | Array | null { - switch (table) { - case "stage": { - if (typeof arg === "number") { - return Stage.getById(arg) as DataTypes[T]; - } - - const filter = arg as Partial | undefined; - if (filter?.tournament_id) { - return Stage.getByTournamentId(filter.tournament_id) as Array< - DataTypes[T] - >; - } - - break; - } - - case "group": { - if (typeof arg === "number") { - return Group.getById(arg) as DataTypes[T]; - } - - const filter = arg as Partial | undefined; - if (filter?.stage_id && filter.number) { - const group = Group.getByStageAndNumber( - filter.stage_id, - filter.number, - ); - return group ? ([group] as Array) : null; - } - - if (filter?.stage_id) { - return Group.getByStageId(filter.stage_id) as Array; - } - - break; - } - - case "round": { - if (typeof arg === "number") { - return Round.getById(arg) as DataTypes[T]; - } - - const filter = arg as Partial | undefined; - if (filter?.group_id && filter.number) { - const round = Round.getByGroupAndNumber( - filter.group_id, - filter.number, - ); - return round ? ([round] as Array) : null; - } - - if (filter?.group_id) { - return Round.getByGroupId(filter.group_id) as Array; - } - - if (filter?.stage_id) { - return Round.getByStageId(filter.stage_id) as Array; - } - - break; - } - - case "match": { - if (typeof arg === "number") { - return Match.getById(arg) as DataTypes[T]; - } - - const filter = arg as Partial | undefined; - if (filter?.round_id && filter.number) { - const match = Match.getByRoundAndNumber( - filter.round_id, - filter.number, - ); - return match ? ([match] as Array) : null; - } - - if (filter?.stage_id) { - return Match.getByStageId(filter.stage_id) as Array; - } - - if (filter?.round_id) { - return Match.getByRoundId(filter.round_id) as Array; - } - - break; - } - } - - return null; - } - - update(table: T, id: number, value: DataTypes[T]): boolean; - update( - table: T, - filter: Partial, - value: Partial, - ): boolean; - update( - table: T, - query: number | Partial, - value: DataTypes[T] | Partial, - ): boolean { - switch (table) { - case "stage": { - if (typeof query === "number") { - const update = value as Partial; - return Stage.updateSettings(query, JSON.stringify(update.settings)); - } - - break; - } - - case "match": { - if (typeof query === "number") { - const update = value as DataTypes["match"]; - const match = new Match( - query, - update.status, - update.stage_id, - update.group_id, - update.round_id, - update.number, - JSON.stringify(update.opponent1), - JSON.stringify(update.opponent2), - ); - return match.update(); - } - - break; - } - } - - return false; - } - - delete(table: T): boolean; - delete(table: T, filter: Partial): boolean; - delete(): boolean { - throw new Error("not implemented"); - } -} diff --git a/app/features/tournament-bracket/core/brackets-manager/index.ts b/app/features/tournament-bracket/core/brackets-manager/index.ts deleted file mode 100644 index 5daec5533..000000000 --- a/app/features/tournament-bracket/core/brackets-manager/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { getTournamentManager } from "./manager"; diff --git a/app/features/tournament-bracket/core/brackets-manager/manager.server.ts b/app/features/tournament-bracket/core/brackets-manager/manager.server.ts deleted file mode 100644 index ce23cce3d..000000000 --- a/app/features/tournament-bracket/core/brackets-manager/manager.server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BracketsManager } from "~/modules/brackets-manager"; -import { SqlDatabase } from "./crud.server"; - -export function getServerTournamentManager() { - const storage = new SqlDatabase(); - const manager = new BracketsManager(storage); - - return manager; -} diff --git a/app/features/tournament-bracket/core/brackets-manager/manager.ts b/app/features/tournament-bracket/core/brackets-manager/manager.ts deleted file mode 100644 index 8139a09b6..000000000 --- a/app/features/tournament-bracket/core/brackets-manager/manager.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BracketsManager } from "~/modules/brackets-manager"; -import { InMemoryDatabase } from "~/modules/brackets-memory-db"; - -export function getTournamentManager() { - const storage = new InMemoryDatabase(); - const manager = new BracketsManager(storage); - - return manager; -} diff --git a/app/features/tournament-bracket/core/engine/create/builder.ts b/app/features/tournament-bracket/core/engine/create/builder.ts new file mode 100644 index 000000000..169231f91 --- /dev/null +++ b/app/features/tournament-bracket/core/engine/create/builder.ts @@ -0,0 +1,502 @@ +import * as helpers from "../helpers"; +import type { + BracketData, + CreateBracketInput, + Duel, + GroupData, + MatchData, + ParticipantSlot, + RoundData, + Seeding, + SeedOrdering, + StageData, + StageSettings, + StandardBracketResults, +} from "../types"; +import { MatchStatus } from "../types"; +import { balanceByes, defaultMinorOrdering, ordering } from "./seeding"; + +/** + * Accumulates the rows of a stage being created. Pure port of the old + * brackets-manager `Create` class: same call order, same inserted values, but + * rows are collected into arrays with local ids (0..n-1 per table) instead of + * being written to storage. + */ +export class StageCreator { + readonly input: CreateBracketInput; + settings: StageSettings; + seeding: Seeding | undefined; + readonly seedOrdering: SeedOrdering[]; + readonly data: BracketData; + + constructor(input: CreateBracketInput) { + this.input = input; + this.settings = structuredClone(input.settings) ?? {}; + this.seeding = input.seeding ? [...input.seeding] : undefined; + this.seedOrdering = this.settings.seedOrdering || []; + this.data = { stage: [], group: [], round: [], match: [] }; + + if (!input.name) throw Error("You must provide a name for the stage."); + + if (!Number.isInteger(input.tournamentId)) + throw Error("You must provide a tournament id for the stage."); + + if (input.type === "round_robin") + this.settings.roundRobinMode = this.settings.roundRobinMode || "simple"; + + if (input.type === "single_elimination") + this.settings.consolationFinal = this.settings.consolationFinal || false; + + if (input.type === "double_elimination") + this.settings.grandFinal = this.settings.grandFinal || "none"; + } + + insertGroup(group: Omit): number { + const id = this.data.group.length; + this.data.group.push({ id, ...group }); + return id; + } + + insertRound(round: Omit): number { + const id = this.data.round.length; + this.data.round.push({ id, ...round }); + return id; + } + + insertMatch(match: Omit): number { + const id = this.data.match.length; + this.data.match.push({ id, ...match }); + return id; + } + + /** + * Creates a round-robin group. + * + * This will make as many rounds as needed to let each participant match every other once. + */ + createRoundRobinGroup( + stageId: number, + number: number, + slots: ParticipantSlot[], + ): void { + const groupId = this.insertGroup({ + stage_id: stageId, + number, + }); + + // Groups can be padded with empty slots when teams don't divide evenly + // (`null`) or by the seed ordering (`undefined`). An empty slot is just an + // absent team, so drop it to round-robin only the present teams — otherwise + // the padding becomes BYE rounds that strand real matches in later rounds. + // TBD slots (`{ id: null }`) are kept; only nullish placeholders are removed. + const presentSlots = slots.filter( + (slot) => slot !== null && slot !== undefined, + ); + + const rounds = helpers.makeRoundRobinMatches( + presentSlots, + this.settings.roundRobinMode, + ); + + for (let i = 0; i < rounds.length; i++) + this.createRound(stageId, groupId, i + 1, rounds[0].length, rounds[i]); + } + + /** + * Creates a bipartite round-robin group where every A team plays every B team exactly once. + */ + createAbDivisionRoundRobinGroup( + stageId: number, + number: number, + slotsA: ParticipantSlot[], + slotsB: ParticipantSlot[], + ): void { + const groupId = this.insertGroup({ + stage_id: stageId, + number, + }); + + const rounds = helpers.makeAbDivisionRoundRobinMatches(slotsA, slotsB); + + for (let i = 0; i < rounds.length; i++) + this.createRound(stageId, groupId, i + 1, rounds[0].length, rounds[i]); + } + + /** + * Creates a standard bracket, which is the only one in single elimination and the upper one in double elimination. + * + * This will make as many rounds as needed to end with one winner. + */ + createStandardBracket( + stageId: number, + number: number, + slots: ParticipantSlot[], + ): StandardBracketResults { + const roundCount = helpers.getUpperBracketRoundCount(slots.length); + const groupId = this.insertGroup({ + stage_id: stageId, + number, + }); + + let duels = helpers.makePairs(slots); + let roundNumber = 1; + + const losers: ParticipantSlot[][] = []; + + for (let i = roundCount - 1; i >= 0; i--) { + const matchCount = 2 ** i; + duels = this.getCurrentDuels(duels, matchCount); + losers.push(duels.map(helpers.byeLoser)); + this.createRound(stageId, groupId, roundNumber++, matchCount, duels); + } + + return { losers, winner: helpers.byeWinner(duels[0]) }; + } + + /** + * Creates a lower bracket, alternating between major and minor rounds. + * + * - A major round is a regular round. + * - A minor round matches the previous (major) round's winners against upper bracket losers of the corresponding round. + */ + createLowerBracket( + stageId: number, + number: number, + losers: ParticipantSlot[][], + ): ParticipantSlot { + const participantCount = this.settings.size!; + const roundPairCount = helpers.getRoundPairCount(participantCount); + + let losersId = 0; + + const method = this.getMajorOrdering(participantCount); + const ordered = ordering[method](losers[losersId++]); + + const groupId = this.insertGroup({ + stage_id: stageId, + number, + }); + + let duels = helpers.makePairs(ordered); + let roundNumber = 1; + + for (let i = 0; i < roundPairCount; i++) { + const matchCount = 2 ** (roundPairCount - i - 1); + + // Major round. + duels = this.getCurrentDuels(duels, matchCount, true); + this.createRound(stageId, groupId, roundNumber++, matchCount, duels); + + // Minor round. + const minorOrdering = this.getMinorOrdering( + participantCount, + i, + roundPairCount, + ); + duels = this.getCurrentDuels( + duels, + matchCount, + false, + losers[losersId++], + minorOrdering, + ); + this.createRound(stageId, groupId, roundNumber++, matchCount, duels); + } + + return helpers.byeWinnerToGrandFinal(duels[0]); + } + + /** + * Creates a bracket with rounds that only have 1 match each. Used for finals. + */ + createUniqueMatchBracket( + stageId: number, + number: number, + duels: Duel[], + ): void { + const groupId = this.insertGroup({ + stage_id: stageId, + number, + }); + + for (let i = 0; i < duels.length; i++) + this.createRound(stageId, groupId, i + 1, 1, [duels[i]]); + } + + /** + * Creates a round, which contain matches. + */ + createRound( + stageId: number, + groupId: number, + roundNumber: number, + matchCount: number, + duels: Duel[], + ): void { + const roundId = this.insertRound({ + number: roundNumber, + stage_id: stageId, + group_id: groupId, + }); + + for (let i = 0; i < matchCount; i++) { + this.createMatch(stageId, groupId, roundId, i + 1, roundNumber, duels[i]); + } + } + + /** + * Creates a match of the stage. + */ + createMatch( + stageId: number, + groupId: number, + roundId: number, + matchNumber: number, + roundNumber: number, + opponents: Duel, + ): void { + const opponent1 = helpers.toResultWithPosition(opponents[0]); + const opponent2 = helpers.toResultWithPosition(opponents[1]); + + // Round-robin matches can easily be removed. Prevent BYE vs. BYE matches. + if ( + this.input.type === "round_robin" && + opponent1 === null && + opponent2 === null + ) + return; + + let status = helpers.getMatchStatus(opponents); + + // In round-robin, only the first round is ready to play at the beginning. + // other matches have teams set but they are busy playing the first round. + if ( + this.input.type === "round_robin" && + roundNumber > 1 && + !this.settings.independentRounds + ) { + status = MatchStatus.Locked; + } + + this.insertMatch({ + number: matchNumber, + stage_id: stageId, + group_id: groupId, + round_id: roundId, + status, + opponent1, + opponent2, + }); + } + + /** + * Gets the duels for the current round based on the previous one. No ordering is done for + * major rounds, it must be done beforehand for the first round. Ordering is done for LB + * minor rounds via the given method. + */ + getCurrentDuels(previousDuels: Duel[], currentDuelCount: number): Duel[]; + getCurrentDuels( + previousDuels: Duel[], + currentDuelCount: number, + major: true, + ): Duel[]; + getCurrentDuels( + previousDuels: Duel[], + currentDuelCount: number, + major: false, + losers: ParticipantSlot[], + method?: SeedOrdering, + ): Duel[]; + getCurrentDuels( + previousDuels: Duel[], + currentDuelCount: number, + major?: boolean, + losers?: ParticipantSlot[], + method?: SeedOrdering, + ): Duel[] { + if ( + (major === undefined || major) && + previousDuels.length === currentDuelCount + ) { + // First round. + return previousDuels; + } + + if (major === undefined || major) { + // From major to major (WB) or minor to major (LB). + return helpers.transitionToMajor(previousDuels); + } + + // From major to minor (LB). + // Losers and method won't be undefined. + return helpers.transitionToMinor(previousDuels, losers!, method); + } + + /** + * Returns a list of slots. + * - If `seeding` was given, uses it. + * - If `size` was given, only returns a list of empty slots. + * + * @param positions An optional list of positions (seeds) for a manual ordering. + */ + getSlots(positions?: number[]): ParticipantSlot[] { + const size = this.settings.size || this.seeding?.length || 0; + helpers.ensureValidSize(this.input.type, size); + + if (size && !this.seeding) + return Array.from(Array(size), (_: ParticipantSlot, i) => ({ + id: null, + position: i + 1, + })); + + if (!this.seeding) throw Error("Either size or seeding must be given."); + + this.settings = { + ...this.settings, + size, // Always set the size. + }; + + helpers.ensureNoDuplicates(this.seeding); + this.seeding = helpers.fixSeeding(this.seeding, size); + + if (this.input.type !== "round_robin" && this.settings.balanceByes) + this.seeding = balanceByes(this.seeding, this.settings.size); + + return this.getSlotsUsingIds(this.seeding, positions); + } + + /** + * Returns the list of slots with a seeding containing IDs. + */ + private getSlotsUsingIds( + seeding: Seeding, + positions?: number[], + ): ParticipantSlot[] { + if (positions && positions.length !== seeding.length) { + throw Error( + "Not enough seeds in at least one group of the manual ordering.", + ); + } + + const slots = seeding.map((slot, i) => { + if (slot === null) return null; // BYE. + + return { id: slot, position: i + 1 }; + }); + + if (!positions) return slots; + + return positions.map((position) => slots[position - 1]); + } + + /** + * Safely gets an ordering by its index in the stage input settings. + */ + getOrdering( + orderingIndex: number, + stageType: "elimination" | "groups", + defaultMethod: SeedOrdering, + ): SeedOrdering { + if (!this.settings.seedOrdering) { + this.seedOrdering.push(defaultMethod); + return defaultMethod; + } + + const method = this.settings.seedOrdering[orderingIndex]; + if (!method) { + this.seedOrdering.push(defaultMethod); + return defaultMethod; + } + + if (stageType === "elimination" && method.match(/^groups\./)) + throw Error( + "You must specify a seed ordering method without a 'groups' prefix", + ); + + if ( + stageType === "groups" && + method !== "natural" && + !method.match(/^groups\./) + ) + throw Error( + "You must specify a seed ordering method with a 'groups' prefix", + ); + + return method; + } + + /** + * Returns the ordering method for the groups in a round-robin stage. + */ + getRoundRobinOrdering(): SeedOrdering { + return this.getOrdering(0, "groups", "groups.effort_balanced"); + } + + /** + * Returns the ordering method for the first round of the upper bracket of an elimination stage. + */ + getStandardBracketFirstRoundOrdering(): SeedOrdering { + return this.getOrdering(0, "elimination", "space_between"); + } + + /** + * Safely gets the only major ordering for the lower bracket. + */ + private getMajorOrdering(participantCount: number): SeedOrdering { + return this.getOrdering( + 1, + "elimination", + defaultMinorOrdering[participantCount]?.[0] || "natural", + ); + } + + /** + * Safely gets a minor ordering for the lower bracket by its index. + */ + private getMinorOrdering( + participantCount: number, + index: number, + minorRoundCount: number, + ): SeedOrdering | undefined { + // No ordering for the last minor round. There is only one participant to order. + if (index === minorRoundCount - 1) return undefined; + + return this.getOrdering( + 2 + index, + "elimination", + defaultMinorOrdering[participantCount]?.[1 + index] || "natural", + ); + } + + /** + * Creates the stage row. + */ + createStage(): StageData { + const stage: StageData = { + id: 0, + tournament_id: this.input.tournamentId, + name: this.input.name, + type: this.input.type, + number: this.input.number ?? 1, + settings: this.settings, + }; + + this.data.stage.push(stage); + + return stage; + } + + /** + * Ensures that the seed ordering list is stored even if it was not given in the first place. + */ + ensureSeedOrdering(): void { + if (this.settings.seedOrdering?.length === this.seedOrdering.length) return; + + const stage = this.data.stage[0]; + + stage.settings = { + ...stage.settings, + seedOrdering: this.seedOrdering, + }; + } +} diff --git a/app/features/tournament-bracket/core/engine/create/double-elimination.test.ts b/app/features/tournament-bracket/core/engine/create/double-elimination.test.ts new file mode 100644 index 000000000..92890084b --- /dev/null +++ b/app/features/tournament-bracket/core/engine/create/double-elimination.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { EngineBracket } from "../test-utils"; + +const bracket = new EngineBracket(); + +describe("Create double elimination stage", () => { + beforeEach(() => { + bracket.reset(); + }); + + test("should create a double elimination stage", () => { + bracket.create({ + name: "Amateur", + tournamentId: 0, + type: "double_elimination", + seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + settings: { seedOrdering: ["natural"], grandFinal: "simple" }, + }); + + const stage = bracket.stage(); + expect(stage.name).toBe("Amateur"); + expect(stage.type).toBe("double_elimination"); + + expect(bracket.groups().length).toBe(3); + expect(bracket.rounds().length).toBe(4 + 6 + 1); + expect(bracket.matches().length).toBe(30); + }); + + test("should create a tournament with 256+ tournaments", () => { + bracket.create({ + name: "Example with 256 participants", + tournamentId: 0, + type: "double_elimination", + settings: { size: 256 }, + }); + }); + + test("should create a tournament with a double grand final", () => { + bracket.create({ + name: "Example with double grand final", + tournamentId: 0, + type: "double_elimination", + seeding: [1, 2, 3, 4, 5, 6, 7, 8], + settings: { grandFinal: "double", seedOrdering: ["natural"] }, + }); + + expect(bracket.groups().length).toBe(3); + expect(bracket.rounds().length).toBe(3 + 4 + 2); + expect(bracket.matches().length).toBe(15); + }); +}); diff --git a/app/features/tournament-bracket/core/engine/create/double-elimination.ts b/app/features/tournament-bracket/core/engine/create/double-elimination.ts new file mode 100644 index 000000000..bf9186f79 --- /dev/null +++ b/app/features/tournament-bracket/core/engine/create/double-elimination.ts @@ -0,0 +1,93 @@ +import * as helpers from "../helpers"; +import type { Duel, ParticipantSlot } from "../types"; +import type { StageCreator } from "./builder"; +import { ordering } from "./seeding"; + +/** + * Creates a double elimination stage. + * + * One upper bracket (winner bracket, WB), one lower bracket (loser bracket, LB) and optionally a grand final + * between the winner of both bracket, which can be simple or double. + */ +export function createDoubleElimination(creator: StageCreator): void { + if ( + Array.isArray(creator.settings.seedOrdering) && + creator.settings.seedOrdering.length < 1 + ) + throw Error("You must specify at least one seed ordering method."); + + const slots = creator.getSlots(); + const stage = creator.createStage(); + const method = creator.getStandardBracketFirstRoundOrdering(); + const ordered = ordering[method](slots); + + if (creator.settings.skipFirstRound) + createWithSkipFirstRound(creator, stage.id, ordered); + else createWithoutSkipFirstRound(creator, stage.id, ordered); +} + +/** + * Creates a double elimination stage with skip first round option. + */ +function createWithSkipFirstRound( + creator: StageCreator, + stageId: number, + slots: ParticipantSlot[], +): void { + const { even: directInWb, odd: directInLb } = helpers.splitByParity(slots); + const { losers: losersWb, winner: winnerWb } = creator.createStandardBracket( + stageId, + 1, + directInWb, + ); + + if (helpers.isDoubleEliminationNecessary(creator.settings.size!)) { + const winnerLb = creator.createLowerBracket(stageId, 2, [ + directInLb, + ...losersWb, + ]); + createGrandFinal(creator, stageId, winnerWb, winnerLb); + } +} + +/** + * Creates a double elimination stage without skip first round option. + */ +function createWithoutSkipFirstRound( + creator: StageCreator, + stageId: number, + slots: ParticipantSlot[], +): void { + const { losers: losersWb, winner: winnerWb } = creator.createStandardBracket( + stageId, + 1, + slots, + ); + + if (helpers.isDoubleEliminationNecessary(creator.settings.size!)) { + const winnerLb = creator.createLowerBracket(stageId, 2, losersWb); + createGrandFinal(creator, stageId, winnerWb, winnerLb); + } +} + +/** + * Creates a grand final (none, simple or double) for winners of both bracket in a double elimination stage. + */ +function createGrandFinal( + creator: StageCreator, + stageId: number, + winnerWb: ParticipantSlot, + winnerLb: ParticipantSlot, +): void { + // No Grand Final by default. + const grandFinal = creator.settings.grandFinal; + if (grandFinal === "none") return; + + // One duel by default. + const finalDuels: Duel[] = [[winnerWb, winnerLb]]; + + // Second duel. + if (grandFinal === "double") finalDuels.push([{ id: null }, { id: null }]); + + creator.createUniqueMatchBracket(stageId, 3, finalDuels); +} diff --git a/app/features/tournament-bracket/core/engine/create/index.ts b/app/features/tournament-bracket/core/engine/create/index.ts new file mode 100644 index 000000000..5ab41e306 --- /dev/null +++ b/app/features/tournament-bracket/core/engine/create/index.ts @@ -0,0 +1,37 @@ +import type { CreateBracketInput, CreatedBracket } from "../types"; +import { StageCreator } from "./builder"; +import { createDoubleElimination } from "./double-elimination"; +import { createRoundRobin } from "./round-robin"; +import { createSingleElimination } from "./single-elimination"; +import { createSwiss } from "./swiss"; + +/** + * Generates the full structure for a new bracket of any type. Pure function: + * returns rows with local ids (0..n-1 per table); the repository maps them to + * real row ids on insert. For swiss this includes the empty future rounds + + * round 1 matches. + */ +export function create(input: CreateBracketInput): CreatedBracket { + if (input.type === "swiss") return createSwiss(input); + + const creator = new StageCreator(input); + + switch (input.type) { + case "round_robin": + createRoundRobin(creator); + break; + case "single_elimination": + createSingleElimination(creator); + break; + case "double_elimination": + createDoubleElimination(creator); + break; + default: + throw Error("Unknown stage type."); + } + + // xxx: hmm, what? + creator.ensureSeedOrdering(); + + return creator.data; +} diff --git a/app/features/tournament-bracket/core/engine/create/round-robin.test.ts b/app/features/tournament-bracket/core/engine/create/round-robin.test.ts new file mode 100644 index 000000000..ebdc04d13 --- /dev/null +++ b/app/features/tournament-bracket/core/engine/create/round-robin.test.ts @@ -0,0 +1,298 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { EngineBracket } from "../test-utils"; + +const bracket = new EngineBracket(); + +describe("Create a round-robin stage", () => { + beforeEach(() => { + bracket.reset(); + }); + + test("should create a round-robin stage", () => { + const example = { + name: "Example", + tournamentId: 0, + type: "round_robin" as const, + seeding: [1, 2, 3, 4, 5, 6, 7, 8], + settings: { groupCount: 2 }, + }; + + bracket.create(example); + + const stage = bracket.stage(); + expect(stage.name).toBe(example.name); + expect(stage.type).toBe(example.type); + + expect(bracket.groups().length).toBe(2); + expect(bracket.rounds().length).toBe(6); + expect(bracket.matches().length).toBe(12); + }); + + test("should create a round-robin stage with a manual seeding", () => { + const manualOrdering = [ + [1, 4, 6, 7], + [2, 3, 5, 8], + ]; + + bracket.create({ + name: "Example", + tournamentId: 0, + type: "round_robin", + seeding: [1, 2, 3, 4, 5, 6, 7, 8], + settings: { + groupCount: 2, + manualOrdering, + }, + }); + + for (let groupIndex = 0; groupIndex < 2; groupIndex++) { + const matches = bracket.matches({ group_id: groupIndex }); + const participants = [ + matches[0].opponent1?.position, + matches[1].opponent2?.position, + matches[1].opponent1?.position, + matches[0].opponent2?.position, + ]; + + expect(participants).toEqual(manualOrdering[groupIndex]); + } + }); + + test("should throw if manual ordering has invalid counts", () => { + expect(() => + bracket.create({ + name: "Example", + tournamentId: 0, + type: "round_robin", + seeding: [1, 2, 3, 4, 5, 6, 7, 8], + settings: { + groupCount: 2, + manualOrdering: [[1, 4, 6, 7]], + }, + }), + ).toThrow( + "Group count in the manual ordering does not correspond to the given group count.", + ); + + expect(() => + bracket.create({ + name: "Example", + tournamentId: 0, + type: "round_robin", + seeding: [1, 2, 3, 4, 5, 6, 7, 8], + settings: { + groupCount: 2, + manualOrdering: [ + [1, 4], + [2, 3], + ], + }, + }), + ).toThrow("Not enough seeds in at least one group of the manual ordering."); + }); + + test("should drop empty slots instead of creating BYE matches", () => { + bracket.create({ + name: "Example", + tournamentId: 0, + type: "round_robin", + seeding: [1, 2, 3, 4, 5, null, null, null], + settings: { groupCount: 2 }, + }); + + // 5 teams in 2 groups -> groups of 3 and 2 with no BYE matches at all. + const matches = bracket.matches(); + expect(matches.length).toBe(4); + for (const match of matches) { + expect(match.opponent1?.id).not.toBeNull(); + expect(match.opponent2?.id).not.toBeNull(); + } + }); + + test("should not pad a short group with empty rounds when teams divide unevenly", () => { + // 5 teams in 2 groups -> groups of 3 and 2. The 2-team group must be a + // clean single-round single-match group, not padded with BYE-only rounds + // that strand the real match in a later round. + bracket.create({ + name: "Uneven groups", + tournamentId: 0, + type: "round_robin", + seeding: [1, 2, 3, 4, 5], + settings: { groupCount: 2, seedOrdering: ["groups.seed_optimized"] }, + }); + + const isRealMatch = (match: { + opponent1: { id: number | null } | null; + opponent2: { id: number | null } | null; + }) => match.opponent1?.id != null && match.opponent2?.id != null; + + const shortGroup = bracket.groups().find((group) => { + const matches = bracket.matches({ group_id: group.id }); + return matches.filter(isRealMatch).length === 1; + })!; + + const rounds = bracket.rounds({ group_id: shortGroup.id }); + const matches = bracket.matches({ group_id: shortGroup.id }); + const realMatch = matches.find(isRealMatch)!; + const realMatchRound = rounds.find( + (round) => round.id === realMatch.round_id, + )!; + + // No BYE matches and no empty rounds, just the single match in round 1. + expect(matches.length).toBe(1); + expect(rounds.length).toBe(1); + expect(realMatchRound.number).toBe(1); + }); + + test("should create a round-robin stage with to be determined participants", () => { + bracket.create({ + name: "Example", + tournamentId: 0, + type: "round_robin", + settings: { + groupCount: 4, + size: 16, + }, + }); + + expect(bracket.groups().length).toBe(4); + expect(bracket.rounds().length).toBe(4 * 3); + expect(bracket.matches().length).toBe(4 * 3 * 2); + }); + + test("should create a round-robin stage with effort balanced", () => { + bracket.create({ + name: "Example with effort balanced", + tournamentId: 0, + type: "round_robin", + seeding: [1, 2, 3, 4, 5, 6, 7, 8], + settings: { + groupCount: 2, + seedOrdering: ["groups.seed_optimized"], + }, + }); + + expect(bracket.match(0).opponent1?.id).toBe(1); + expect(bracket.match(0).opponent2?.id).toBe(8); + }); + + test("should throw if no group count given", () => { + expect(() => + bracket.create({ + name: "Example", + tournamentId: 0, + type: "round_robin", + }), + ).toThrow("You must specify a group count for round-robin stages."); + }); + + test("should throw if the group count is not strictly positive", () => { + expect(() => + bracket.create({ + name: "Example", + tournamentId: 0, + type: "round_robin", + settings: { + groupCount: 0, + size: 4, + seedOrdering: ["groups.seed_optimized"], + }, + }), + ).toThrow("You must provide a strictly positive group count."); + }); + + test("creates an A/B divisions round-robin where every A team plays every B team once", () => { + const seeding = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + // alternate A (0) / B (1) so that seed order 1..12 gives A=[1,3,5,7,9,11], B=[2,4,6,8,10,12] + const abDivisions = seeding.map((_, i) => (i % 2 === 0 ? 0 : 1)) as ( + | 0 + | 1 + )[]; + + bracket.create({ + name: "AB Example", + tournamentId: 0, + type: "round_robin", + seeding, + abDivisions, + settings: { + groupCount: 1, + hasAbDivisions: true, + seedOrdering: ["groups.seed_optimized"], + }, + }); + + expect(bracket.groups().length).toBe(1); + expect(bracket.rounds().length).toBe(6); + expect(bracket.matches().length).toBe(36); + + const divisionAIds = new Set([1, 3, 5, 7, 9, 11]); + const divisionBIds = new Set([2, 4, 6, 8, 10, 12]); + const pairings = new Set(); + + for (const match of bracket.matches()) { + const aId = match.opponent1?.id; + const bId = match.opponent2?.id; + + expect(divisionAIds.has(aId!)).toBe(true); + expect(divisionBIds.has(bId!)).toBe(true); + + const key = `${aId}-${bId}`; + expect(pairings.has(key)).toBe(false); + pairings.add(key); + } + + expect(pairings.size).toBe(36); + }); + + test("throws when A/B divisions are requested but abDivisions is missing", () => { + expect(() => + bracket.create({ + name: "Missing AB", + tournamentId: 0, + type: "round_robin", + seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + settings: { + groupCount: 1, + hasAbDivisions: true, + }, + }), + ).toThrow("abDivisions must be provided when hasAbDivisions is enabled."); + }); + + test("creates an A/B divisions round-robin with uneven (±1) divisions and a single group", () => { + const seeding = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + bracket.create({ + name: "Uneven AB", + tournamentId: 0, + type: "round_robin", + seeding, + abDivisions: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], + settings: { + groupCount: 1, + hasAbDivisions: true, + seedOrdering: ["groups.seed_optimized"], + }, + }); + + expect(bracket.groups().length).toBe(1); + expect(bracket.rounds().length).toBe(6); + expect(bracket.matches().length).toBe(30); + }); + + test("throws when A/B divisions are uneven with multiple groups", () => { + expect(() => + bracket.create({ + name: "Uneven AB multi-group", + tournamentId: 0, + type: "round_robin", + seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + abDivisions: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], + settings: { + groupCount: 2, + hasAbDivisions: true, + }, + }), + ).toThrow("Uneven A/B divisions are only supported with a single group."); + }); +}); diff --git a/app/features/tournament-bracket/core/engine/create/round-robin.ts b/app/features/tournament-bracket/core/engine/create/round-robin.ts new file mode 100644 index 000000000..e4d87a9ca --- /dev/null +++ b/app/features/tournament-bracket/core/engine/create/round-robin.ts @@ -0,0 +1,128 @@ +import * as helpers from "../helpers"; +import type { ParticipantSlot } from "../types"; +import type { StageCreator } from "./builder"; +import { ordering } from "./seeding"; + +/** + * Creates a round-robin stage. + * + * Group count must be given. It will distribute participants in groups and rounds. + */ +export function createRoundRobin(creator: StageCreator): void { + if (creator.settings.hasAbDivisions) { + createAbDivisionRoundRobin(creator); + return; + } + + const groups = getRoundRobinGroups(creator); + const stage = creator.createStage(); + + for (let i = 0; i < groups.length; i++) + creator.createRoundRobinGroup(stage.id, i + 1, groups[i]); +} + +/** + * Creates a bipartite (A/B divisions) round-robin stage. + * + * Participants are partitioned into two pools by `abDivisions` (parallel to the seeding). + * Each group receives equal A and B teams, and matches only pair A against B. + */ +function createAbDivisionRoundRobin(creator: StageCreator): void { + const groups = getAbDivisionGroups(creator); + const stage = creator.createStage(); + + for (let i = 0; i < groups.length; i++) + creator.createAbDivisionRoundRobinGroup( + stage.id, + i + 1, + groups[i].a, + groups[i].b, + ); +} + +/** + * Gets the slots in groups for a round-robin stage. + */ +function getRoundRobinGroups(creator: StageCreator): ParticipantSlot[][] { + if ( + creator.settings.groupCount === undefined || + !Number.isInteger(creator.settings.groupCount) + ) + throw Error("You must specify a group count for round-robin stages."); + + if (creator.settings.groupCount <= 0) + throw Error("You must provide a strictly positive group count."); + + if (creator.settings.manualOrdering) { + if (creator.settings.manualOrdering.length !== creator.settings.groupCount) + throw Error( + "Group count in the manual ordering does not correspond to the given group count.", + ); + + const positions = creator.settings.manualOrdering.flat(); + const slots = creator.getSlots(positions); + + return helpers.makeGroups(slots, creator.settings.groupCount); + } + + if ( + Array.isArray(creator.settings.seedOrdering) && + creator.settings.seedOrdering.length !== 1 + ) + throw Error("You must specify one seed ordering method."); + + const method = creator.getRoundRobinOrdering(); + const slots = creator.getSlots(); + const ordered = ordering[method](slots, creator.settings.groupCount); + return helpers.makeGroups(ordered, creator.settings.groupCount); +} + +/** + * Partitions the seeded slots into A and B pools then distributes them into groups + * such that each group has an equal number of A and B participants. + */ +function getAbDivisionGroups(creator: StageCreator): { + a: ParticipantSlot[]; + b: ParticipantSlot[]; +}[] { + if ( + creator.settings.groupCount === undefined || + !Number.isInteger(creator.settings.groupCount) + ) + throw Error("You must specify a group count for round-robin stages."); + + if (creator.settings.groupCount <= 0) + throw Error("You must provide a strictly positive group count."); + + const abDivisions = creator.input.abDivisions; + if (!abDivisions) + throw Error("abDivisions must be provided when hasAbDivisions is enabled."); + + const slots = creator.getSlots(); + + if (abDivisions.length !== slots.length) + throw Error("abDivisions length must match the seeding length."); + + const divisionA: ParticipantSlot[] = []; + const divisionB: ParticipantSlot[] = []; + + for (let i = 0; i < slots.length; i++) { + const slot = slots[i]; + if (slot === null) + throw Error("BYEs are not supported with A/B divisions."); + + const division = abDivisions[i]; + if (division === 0) divisionA.push(slot); + else if (division === 1) divisionB.push(slot); + else + throw Error( + `Participant at seed ${i + 1} is missing an A/B division assignment.`, + ); + } + + return helpers.makeAbDivisionGroups( + divisionA, + divisionB, + creator.settings.groupCount, + ); +} diff --git a/app/modules/brackets-manager/ordering.ts b/app/features/tournament-bracket/core/engine/create/seeding.ts similarity index 69% rename from app/modules/brackets-manager/ordering.ts rename to app/features/tournament-bracket/core/engine/create/seeding.ts index aed1c1a08..cdc92a478 100644 --- a/app/modules/brackets-manager/ordering.ts +++ b/app/features/tournament-bracket/core/engine/create/seeding.ts @@ -1,8 +1,9 @@ // https://web.archive.org/web/20200601102344/https://tl.net/forum/sc2-tournaments/202139-superior-double-elimination-losers-bracket-seeding -import type { SeedOrdering } from "~/modules/brackets-model"; +// xxx: some can probably be removed + import invariant from "~/utils/invariant"; -import type { OrderingMap } from "./types"; +import type { OrderingMap, Seeding, SeedOrdering } from "../types"; export const ordering: OrderingMap = { natural: (array: T[]) => [...array], @@ -141,3 +142,55 @@ export const defaultMinorOrdering: { [key: number]: SeedOrdering[] } = { "natural", ], }; + +/** + * Balances BYEs to prevents having BYE against BYE in matches. + * + * @param seeding The seeding of the stage. + * @param participantCount The number of participants in the stage. + */ +export function balanceByes( + seeding: Seeding, + participantCount?: number, +): Seeding { + const nonNullSeeding = seeding.filter((v) => v !== null); + const size = participantCount || getNearestPowerOfTwo(nonNullSeeding.length); + + if (nonNullSeeding.length < size / 2) { + const flat = nonNullSeeding.flatMap((v) => [v, null]); + return setArraySize(flat, size, null); + } + + const nonNullCount = nonNullSeeding.length; + const nullCount = size - nonNullCount; + const againstEachOther = nonNullSeeding + .slice(0, nonNullCount - nullCount) + .filter((_, i) => i % 2 === 0) + .map((_, i) => [nonNullSeeding[2 * i], nonNullSeeding[2 * i + 1]]); + const againstNull = nonNullSeeding + .slice(nonNullCount - nullCount, nonNullCount) + .map((v) => [v, null]); + const flat = [...againstEachOther.flat(), ...againstNull.flat()]; + + return setArraySize(flat, size, null); +} + +/** + * Sets the size of an array with a placeholder if the size is bigger. + * + * @param array The original array. + * @param length The new length. + * @param placeholder A placeholder to use to fill the empty space. + */ +function setArraySize(array: T[], length: number, placeholder: T): T[] { + return Array.from(Array(length), (_, i) => array[i] || placeholder); +} + +/** + * Returns the nearest power of two **greater than** or equal to the given number. + * + * @param input The input number. + */ +function getNearestPowerOfTwo(input: number): number { + return 2 ** Math.ceil(Math.log2(input)); +} diff --git a/app/features/tournament-bracket/core/engine/create/single-elimination.test.ts b/app/features/tournament-bracket/core/engine/create/single-elimination.test.ts new file mode 100644 index 000000000..57ad2db2e --- /dev/null +++ b/app/features/tournament-bracket/core/engine/create/single-elimination.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { EngineBracket } from "../test-utils"; + +const bracket = new EngineBracket(); + +describe("Create single elimination stage", () => { + beforeEach(() => { + bracket.reset(); + }); + + test("should create a single elimination stage", () => { + const example = { + name: "Example", + tournamentId: 0, + type: "single_elimination" as const, + seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + settings: { seedOrdering: ["natural" as const] }, + }; + + bracket.create(example); + + const stage = bracket.stage(); + expect(stage.name).toBe(example.name); + expect(stage.type).toBe(example.type); + + expect(bracket.groups().length).toBe(1); + expect(bracket.rounds().length).toBe(4); + expect(bracket.matches().length).toBe(15); + }); + + test("should create a single elimination stage with BYEs", () => { + bracket.create({ + name: "Example with BYEs", + tournamentId: 0, + type: "single_elimination", + seeding: [1, null, 3, 4, null, null, 7, 8], + settings: { seedOrdering: ["natural"] }, + }); + + expect(bracket.match(4).opponent1?.id).toBe(1); + expect(bracket.match(4).opponent2?.id).toBe(null); + expect(bracket.match(5).opponent1).toBe(null); + expect(bracket.match(5).opponent2?.id).toBe(null); + }); + + test("should create a single elimination stage with consolation final", () => { + bracket.create({ + name: "Example with consolation final", + tournamentId: 0, + type: "single_elimination", + seeding: [1, 2, 3, 4, 5, 6, 7, 8], + settings: { consolationFinal: true, seedOrdering: ["natural"] }, + }); + + expect(bracket.groups().length).toBe(2); + expect(bracket.rounds().length).toBe(4); + expect(bracket.matches().length).toBe(8); + }); + + test("should create a single elimination stage with consolation final and BYEs", () => { + bracket.create({ + name: "Example with consolation final and BYEs", + tournamentId: 0, + type: "single_elimination", + seeding: [null, null, null, 4, 5, 6, 7, 8], + settings: { consolationFinal: true, seedOrdering: ["natural"] }, + }); + + expect(bracket.match(4).opponent1).toBe(null); + expect(bracket.match(4).opponent2?.id).toBe(4); + + // Consolation final + expect(bracket.match(7).opponent1).toBe(null); + expect(bracket.match(7).opponent2?.id).toBe(null); + }); + + test("should create a single elimination stage with Bo3 matches", () => { + bracket.create({ + name: "Example with Bo3 matches", + tournamentId: 0, + type: "single_elimination", + seeding: [1, 2, 3, 4, 5, 6, 7, 8], + settings: { seedOrdering: ["natural"] }, + }); + + expect(bracket.groups().length).toBe(1); + expect(bracket.rounds().length).toBe(3); + expect(bracket.matches().length).toBe(7); + }); + + test("should throw if the seeding has duplicate participants", () => { + expect(() => + bracket.create({ + name: "Example", + tournamentId: 0, + type: "single_elimination", + seeding: [ + 1, + 1, // Duplicate + 3, + 4, + ], + }), + ).toThrow("The seeding has a duplicate participant."); + }); +}); diff --git a/app/features/tournament-bracket/core/engine/create/single-elimination.ts b/app/features/tournament-bracket/core/engine/create/single-elimination.ts new file mode 100644 index 000000000..b1cd98906 --- /dev/null +++ b/app/features/tournament-bracket/core/engine/create/single-elimination.ts @@ -0,0 +1,38 @@ +import type { Duel, ParticipantSlot } from "../types"; +import type { StageCreator } from "./builder"; +import { ordering } from "./seeding"; + +/** + * Creates a single elimination stage. + * + * One bracket and optionally a consolation final between semi-final losers. + */ +export function createSingleElimination(creator: StageCreator): void { + if ( + Array.isArray(creator.settings.seedOrdering) && + creator.settings.seedOrdering.length !== 1 + ) + throw Error("You must specify one seed ordering method."); + + const slots = creator.getSlots(); + const stage = creator.createStage(); + const method = creator.getStandardBracketFirstRoundOrdering(); + const ordered = ordering[method](slots); + + const { losers } = creator.createStandardBracket(stage.id, 1, ordered); + createConsolationFinal(creator, stage.id, losers); +} + +/** + * Creates a consolation final for the semi final losers of a single elimination stage. + */ +function createConsolationFinal( + creator: StageCreator, + stageId: number, + losers: ParticipantSlot[][], +): void { + if (!creator.settings.consolationFinal) return; + + const semiFinalLosers = losers[losers.length - 2] as Duel; + creator.createUniqueMatchBracket(stageId, 2, [semiFinalLosers]); +} diff --git a/app/features/tournament-bracket/core/engine/create/swiss.ts b/app/features/tournament-bracket/core/engine/create/swiss.ts new file mode 100644 index 000000000..5b4d1c72b --- /dev/null +++ b/app/features/tournament-bracket/core/engine/create/swiss.ts @@ -0,0 +1,145 @@ +import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import { nullFilledArray } from "~/utils/arrays"; +import invariant from "~/utils/invariant"; +import type { CreateBracketInput, CreatedBracket, MatchData } from "../types"; +import { MatchStatus } from "../types"; + +/** + * Creates a Swiss bracket data set: all rounds up front, matches for round 1 only. + * Ported from the old core/Swiss.ts create()/firstRoundMatches(). + */ +export function createSwiss(input: CreateBracketInput): CreatedBracket { + const swissSettings = input.settings?.swiss; + + const groupCount = + swissSettings?.groupCount ?? TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT; + const roundCount = + swissSettings?.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT; + + const group = nullFilledArray(groupCount).map((_, i) => ({ + id: i, + stage_id: 0, + number: i + 1, + })); + + let roundId = 0; + return { + group, + match: firstRoundMatches({ + seeding: input.seeding, + groupCount, + roundCount, + }), + round: group.flatMap((g) => + nullFilledArray(roundCount).map((_, i) => ({ + id: roundId++, + group_id: g.id, + number: i + 1, + stage_id: 0, + })), + ), + stage: [ + { + id: 0, + name: input.name, + number: 1, + settings: input.settings ?? {}, + tournament_id: input.tournamentId, + type: "swiss", + }, + ], + }; +} + +function firstRoundMatches({ + seeding, + groupCount, + roundCount, +}: { + seeding: CreateBracketInput["seeding"]; + groupCount: number; + roundCount: number; +}): MatchData[] { + // split the teams to one or more groups. For example with 16 teams and 3 groups this would result in + // group 1: 1, 4, 7, 10, 13, 16 + // group 2: 2, 5, 8, 11, 14 + // group 3: 3, 6, 9, 12, 15 + const groups = splitToGroups(); + + const result: MatchData[] = []; + + let matchId = 0; + for (const [groupIdx, participants] of groups.entries()) { + // if there is an uneven number of teams the last seed gets a bye + const bye = participants.length % 2 === 0 ? null : participants.pop(); + + const halfI = participants.length / 2; + const upperHalf = participants.slice(0, halfI); + const lowerHalf = participants.slice(halfI); + + invariant( + upperHalf.length === lowerHalf.length, + "firstRoundMatches: halfs not equal", + ); + + // first round every team plays the matching team "on the opposite side" + // so for example with 8 teams match ups look like this: + // seed 1 vs. seed 5 + // seed 2 vs. seed 6 + // seed 3 vs. seed 7 + // seed 4 vs. seed 8 + // --- + // this way each match has "equal distance" + const roundId = groupIdx * roundCount; + for (let i = 0; i < upperHalf.length; i++) { + const upper = upperHalf[i]; + const lower = lowerHalf[i]; + + result.push({ + id: matchId++, + group_id: groupIdx, + stage_id: 0, + round_id: roundId, + number: i + 1, + opponent1: { + id: upper, + }, + opponent2: { + id: lower, + }, + status: MatchStatus.Ready, + }); + } + + if (bye) { + result.push({ + id: matchId++, + group_id: groupIdx, + stage_id: 0, + round_id: roundId, + number: upperHalf.length + 1, + opponent1: { + id: bye, + }, + opponent2: null, + status: MatchStatus.Ready, + }); + } + } + + return result; + + function splitToGroups() { + if (!seeding) return []; + if (groupCount === 1) return [seeding.map((id) => id!)]; + + const groups: number[][] = nullFilledArray(groupCount).map(() => []); + + for (let i = 0; i < seeding.length; i++) { + const groupIndex = i % groupCount; + groups[groupIndex].push(seeding[i]!); + } + + return groups; + } +} diff --git a/app/features/tournament-bracket/core/engine/general.test.ts b/app/features/tournament-bracket/core/engine/general.test.ts new file mode 100644 index 000000000..5acdc2aa1 --- /dev/null +++ b/app/features/tournament-bracket/core/engine/general.test.ts @@ -0,0 +1,368 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import * as Engine from "./index"; +import { EngineBracket } from "./test-utils"; + +const bracket = new EngineBracket(); + +describe("BYE handling", () => { + beforeEach(() => { + bracket.reset(); + }); + + test("should propagate BYEs through the brackets", () => { + bracket.create({ + name: "Example with BYEs", + tournamentId: 0, + type: "double_elimination", + seeding: [1, null, null, null], + settings: { seedOrdering: ["natural"], grandFinal: "simple" }, + }); + + expect(bracket.match(2).opponent1?.id).toBe(1); + expect(bracket.match(2).opponent2).toBe(null); + + expect(bracket.match(3).opponent1).toBe(null); + expect(bracket.match(3).opponent2).toBe(null); + + expect(bracket.match(4).opponent1).toBe(null); + expect(bracket.match(4).opponent2).toBe(null); + + expect(bracket.match(5).opponent1?.id).toBe(1); + expect(bracket.match(5).opponent2).toBe(null); + }); + + test("should handle incomplete seeding during creation", () => { + bracket.create({ + name: "Example with BYEs", + tournamentId: 0, + type: "double_elimination", + seeding: [1, 2], + settings: { + seedOrdering: ["natural"], + balanceByes: false, // Default value. + size: 4, + }, + }); + + expect(bracket.match(0).opponent1?.id).toBe(1); + expect(bracket.match(0).opponent2?.id).toBe(2); + + expect(bracket.match(1).opponent1).toBe(null); + expect(bracket.match(1).opponent2).toBe(null); + }); + + test("should balance BYEs in the seeding", () => { + bracket.create({ + name: "Example with BYEs", + tournamentId: 0, + type: "double_elimination", + seeding: [1, 2], + settings: { + seedOrdering: ["natural"], + balanceByes: true, + size: 4, + }, + }); + + expect(bracket.match(0).opponent1?.id).toBe(1); + expect(bracket.match(0).opponent2).toBe(null); + + expect(bracket.match(1).opponent1?.id).toBe(2); + expect(bracket.match(1).opponent2).toBe(null); + }); +}); + +describe("Position checks", () => { + beforeEach(() => { + bracket.reset(); + + bracket.create({ + name: "Example with double grand final", + tournamentId: 0, + type: "double_elimination", + settings: { + size: 8, + grandFinal: "simple", + seedOrdering: ["natural"], + }, + }); + }); + + test("should not have a position when we don't need the origin of a participant", () => { + const matchFromWbRound2 = bracket.match(4); + expect(matchFromWbRound2.opponent1?.position).toBe(undefined); + expect(matchFromWbRound2.opponent2?.position).toBe(undefined); + + const matchFromLbRound2 = bracket.match(9); + expect(matchFromLbRound2.opponent2?.position).toBe(undefined); + + const matchFromGrandFinal = bracket.match(13); + expect(matchFromGrandFinal.opponent1?.position).toBe(undefined); + }); + + test("should have a position where we need the origin of a participant", () => { + const matchFromWbRound1 = bracket.match(0); + expect(matchFromWbRound1.opponent1?.position).toBe(1); + expect(matchFromWbRound1.opponent2?.position).toBe(2); + + const matchFromLbRound1 = bracket.match(7); + expect(matchFromLbRound1.opponent1?.position).toBe(1); + expect(matchFromLbRound1.opponent2?.position).toBe(2); + + const matchFromLbRound2 = bracket.match(9); + expect(matchFromLbRound2.opponent1?.position).toBe(2); + + const matchFromGrandFinal = bracket.match(13); + expect(matchFromGrandFinal.opponent2?.position).toBe(1); + }); +}); + +describe("Special cases", () => { + beforeEach(() => { + bracket.reset(); + }); + + test("should throw if the name of the stage is not provided", () => { + expect(() => + Engine.create({ + tournamentId: 0, + type: "single_elimination", + settings: {}, + } as Engine.CreateBracketInput), + ).toThrow("You must provide a name for the stage."); + }); + + test("should throw if the tournament id of the stage is not provided", () => { + expect(() => + Engine.create({ + name: "Example", + type: "single_elimination", + settings: {}, + } as Engine.CreateBracketInput), + ).toThrow("You must provide a tournament id for the stage."); + }); + + test("should throw if the participant count of a stage is not a power of two", () => { + expect(() => + bracket.create({ + name: "Example", + tournamentId: 0, + type: "single_elimination", + seeding: [1, 2, 3, 4, 5, 6, 7], + }), + ).toThrow( + "The library only supports a participant count which is a power of two.", + ); + + expect(() => + bracket.create({ + name: "Example", + tournamentId: 0, + type: "single_elimination", + settings: { size: 3 }, + }), + ).toThrow( + "The library only supports a participant count which is a power of two.", + ); + }); + + test("should throw if the participant count of a stage is less than two", () => { + expect(() => + bracket.create({ + name: "Example", + tournamentId: 0, + type: "single_elimination", + settings: { size: 0 }, + }), + ).toThrow( + "Impossible to create an empty stage. If you want an empty seeding, just set the size of the stage.", + ); + + expect(() => + bracket.create({ + name: "Example", + tournamentId: 0, + type: "single_elimination", + settings: { size: 1 }, + }), + ).toThrow("Impossible to create a stage with less than 2 participants."); + }); +}); + +describe("Seeding and ordering in elimination", () => { + beforeEach(() => { + bracket.reset(); + + bracket.create({ + name: "Amateur", + tournamentId: 0, + type: "double_elimination", + seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + settings: { + seedOrdering: [ + "inner_outer", + "reverse", + "pair_flip", + "half_shift", + "reverse", + ], + }, + }); + }); + + test("should have the good orderings everywhere", () => { + const firstRoundMatchWB = bracket.match(0); + expect(firstRoundMatchWB.opponent1?.position).toBe(1); + expect(firstRoundMatchWB.opponent2?.position).toBe(16); + + const firstRoundMatchLB = bracket.match(15); + expect(firstRoundMatchLB.opponent1?.position).toBe(8); + expect(firstRoundMatchLB.opponent2?.position).toBe(7); + + const secondRoundMatchLB = bracket.match(19); + expect(secondRoundMatchLB.opponent1?.position).toBe(2); + + const secondRoundSecondMatchLB = bracket.match(20); + expect(secondRoundSecondMatchLB.opponent1?.position).toBe(1); + + const fourthRoundMatchLB = bracket.match(25); + expect(fourthRoundMatchLB.opponent1?.position).toBe(2); + + const finalRoundMatchLB = bracket.match(28); + expect(finalRoundMatchLB.opponent1?.position).toBe(1); + }); +}); + +describe("Reset match", () => { + beforeEach(() => { + bracket.reset(); + }); + + test("should reset results of a match", () => { + bracket.create({ + name: "Example", + tournamentId: 0, + type: "single_elimination", + seeding: [1, 2], + settings: { + seedOrdering: ["natural"], + size: 8, + }, + }); + + bracket.updateMatch({ + id: 0, + opponent1: { score: 16, result: "win" }, + opponent2: { score: 12 }, + }); + + let match = bracket.match(0); + expect(match.opponent1?.score).toBe(16); + expect(match.opponent2?.score).toBe(12); + expect(match.opponent1?.result).toBe("win"); + + let semi1 = bracket.match(4); + expect(semi1.opponent1?.result).toBe("win"); + expect(semi1.opponent2).toBe(null); + + let final = bracket.match(6); + expect(final.opponent1?.result).toBe("win"); + expect(final.opponent2).toBe(null); + + bracket.resetMatchResults(0); // Score stays as is. + + match = bracket.match(0); + expect(match.opponent1?.score).toBe(16); + expect(match.opponent2?.score).toBe(12); + expect(match.opponent1?.result).toBe(undefined); + + semi1 = bracket.match(4); + expect(semi1.opponent1?.result).toBe(undefined); + expect(semi1.opponent2).toBe(null); + + final = bracket.match(6); + expect(final.opponent1?.result).toBe(undefined); + expect(final.opponent2).toBe(null); + }); + + test("should throw when at least one of the following match is locked", () => { + bracket.create({ + name: "Example", + tournamentId: 0, + type: "single_elimination", + seeding: [1, 2, 3, 4], + settings: { + seedOrdering: ["natural"], + }, + }); + + bracket.updateMatch({ + id: 0, + opponent1: { score: 16, result: "win" }, + opponent2: { score: 12 }, + }); + + bracket.updateMatch({ + id: 1, + opponent1: { score: 16, result: "win" }, + opponent2: { score: 12 }, + }); + + bracket.updateMatch({ + id: 2, + opponent1: { score: 16, result: "win" }, + opponent2: { score: 12 }, + }); + + expect(() => bracket.resetMatchResults(0)).toThrow("The match is locked."); + }); +}); + +describe("Engine data immutability", () => { + test("engine operations return new data and leave the input untouched", () => { + const initial = Engine.create({ + name: "Example", + tournamentId: 0, + type: "single_elimination", + seeding: [1, 2, 3, 4], + settings: { seedOrdering: ["natural"] }, + }); + + const snapshot = structuredClone(initial); + + const afterReport = Engine.reportResult(initial, { + matchId: 0, + opponent1: { score: 16, result: "win" }, + opponent2: { score: 12 }, + }); + + expect(initial).toEqual(snapshot); + expect(afterReport.data.match[0].opponent1?.result).toBe("win"); + + const afterReset = Engine.resetMatchResults(afterReport.data, 0); + + expect(afterReport.data.match[0].opponent1?.result).toBe("win"); + expect(afterReset.data.match[0].opponent1?.result).toBe(undefined); + }); + + test("changedMatches contains only genuinely changed rows", () => { + const initial = Engine.create({ + name: "Example", + tournamentId: 0, + type: "single_elimination", + seeding: [1, 2, 3, 4], + settings: { seedOrdering: ["natural"] }, + }); + + const afterReport = Engine.reportResult(initial, { + matchId: 0, + opponent1: { score: 16, result: "win" }, + opponent2: { score: 12 }, + }); + + const changedIds = afterReport.changedMatches.map((match) => match.id); + expect(changedIds).toContain(0); // The reported match. + expect(changedIds).toContain(2); // The final receiving the winner. + expect(changedIds).not.toContain(1); // The other semi is untouched. + }); +}); diff --git a/app/modules/brackets-manager/test/helpers.test.ts b/app/features/tournament-bracket/core/engine/helpers.test.ts similarity index 99% rename from app/modules/brackets-manager/test/helpers.test.ts rename to app/features/tournament-bracket/core/engine/helpers.test.ts index 9a8de5d86..25d49f3f0 100644 --- a/app/modules/brackets-manager/test/helpers.test.ts +++ b/app/features/tournament-bracket/core/engine/helpers.test.ts @@ -1,14 +1,13 @@ import { describe, expect, test } from "vitest"; +import { balanceByes, ordering } from "./create/seeding"; import { assertAbDivisionRoundRobin, assertRoundRobin, - balanceByes, makeAbDivisionGroups, makeAbDivisionRoundRobinMatches, makeGroups, makeRoundRobinMatches, -} from "../helpers"; -import { ordering } from "../ordering"; +} from "./helpers"; describe("Round-robin groups", () => { test("should place participants in groups", () => { diff --git a/app/modules/brackets-manager/helpers.ts b/app/features/tournament-bracket/core/engine/helpers.ts similarity index 83% rename from app/modules/brackets-manager/helpers.ts rename to app/features/tournament-bracket/core/engine/helpers.ts index f77801cbd..6813a48ec 100644 --- a/app/modules/brackets-manager/helpers.ts +++ b/app/features/tournament-bracket/core/engine/helpers.ts @@ -1,28 +1,22 @@ +import { ordering } from "./create/seeding"; import type { + DeepPartial, + Duel, GroupType, - Match, + MatchData, MatchResults, + ParitySplit, ParticipantResult, + ParticipantSlot, Result, RoundRobinMode, Seeding, SeedOrdering, - Stage, - StageType, -} from "~/modules/brackets-model"; -import { Status } from "~/modules/brackets-model"; - -import { ordering } from "./ordering"; -import type { - Database, - DeepPartial, - Duel, - IdMapping, - Nullable, - ParitySplit, - ParticipantSlot, Side, + StageData, + StageType, } from "./types"; +import { MatchStatus } from "./types"; /** * Splits an array in two parts: one with even indices and the other with odd indices. @@ -306,113 +300,6 @@ export function makeGroups(elements: T[], groupCount: number): T[][] { return result; } -/** - * Balances BYEs to prevents having BYE against BYE in matches. - * - * @param seeding The seeding of the stage. - * @param participantCount The number of participants in the stage. - */ -export function balanceByes( - seeding: Seeding, - participantCount?: number, -): Seeding { - // biome-ignore lint/style/noParameterAssign: biome migration - seeding = seeding.filter((v) => v !== null); - - // biome-ignore lint/style/noParameterAssign: biome migration - participantCount = participantCount || getNearestPowerOfTwo(seeding.length); - - if (seeding.length < participantCount / 2) { - const flat = seeding.flatMap((v) => [v, null]); - return setArraySize(flat, participantCount, null); - } - - const nonNullCount = seeding.length; - const nullCount = participantCount - nonNullCount; - const againstEachOther = seeding - .slice(0, nonNullCount - nullCount) - .filter((_, i) => i % 2 === 0) - .map((_, i) => [seeding[2 * i], seeding[2 * i + 1]]); - const againstNull = seeding - .slice(nonNullCount - nullCount, nonNullCount) - .map((v) => [v, null]); - const flat = [...againstEachOther.flat(), ...againstNull.flat()]; - - return setArraySize(flat, participantCount, null); -} - -/** - * Normalizes IDs in a database. - * - * All IDs (and references to them) are remapped to consecutive IDs starting from 0. - * - * @param data Data to normalize. - */ -export function normalizeIds(data: Database): Database { - const mappings = { - stage: makeNormalizedIdMapping(data.stage), - group: makeNormalizedIdMapping(data.group), - round: makeNormalizedIdMapping(data.round), - match: makeNormalizedIdMapping(data.match), - }; - - return { - stage: data.stage.map((value) => ({ - ...value, - id: mappings.stage[value.id], - })), - group: data.group.map((value) => ({ - ...value, - id: mappings.group[value.id], - stage_id: mappings.stage[value.stage_id], - })), - round: data.round.map((value) => ({ - ...value, - id: mappings.round[value.id], - stage_id: mappings.stage[value.stage_id], - group_id: mappings.group[value.group_id], - })), - match: data.match.map((value) => ({ - ...value, - id: mappings.match[value.id], - stage_id: mappings.stage[value.stage_id], - group_id: mappings.group[value.group_id], - round_id: mappings.round[value.round_id], - opponent1: value.opponent1, - opponent2: value.opponent2, - })), - }; -} - -/** - * Makes a mapping between old IDs and new normalized IDs. - * - * @param elements A list of elements with IDs. - */ -function makeNormalizedIdMapping(elements: { id: number }[]): IdMapping { - let currentId = 0; - - return elements.reduce( - (acc, current) => ({ - // biome-ignore lint/performance/noAccumulatingSpread: biome migration - ...acc, - [current.id]: currentId++, - }), - {}, - ) as IdMapping; -} - -/** - * Sets the size of an array with a placeholder if the size is bigger. - * - * @param array The original array. - * @param length The new length. - * @param placeholder A placeholder to use to fill the empty space. - */ -function setArraySize(array: T[], length: number, placeholder: T): T[] { - return Array.from(Array(length), (_, i) => array[i] || placeholder); -} - /** * Makes pairs with each element and its next one. * @@ -430,7 +317,7 @@ export function makePairs(array: T[]): [T, T][] { * * @param array A list of elements. */ -export function ensureNoDuplicates(array: Nullable[]): void { +export function ensureNoDuplicates(array: (T | null)[]): void { const nonNull = getNonNull(array); const unique = nonNull.filter((item, index) => { const stringifiedItem = JSON.stringify(item); @@ -460,7 +347,7 @@ export function fixSeeding( ); if (seeding.length < participantCount) - return setArraySize(seeding, participantCount, null); + return Array.from(Array(participantCount), (_, i) => seeding[i] || null); return seeding; } @@ -614,16 +501,7 @@ function isMatchStarted(match: DeepPartial): boolean { * @param match Partial match results. */ function isMatchCompleted(match: DeepPartial): boolean { - return isMatchByeCompleted(match) || isMatchResultCompleted(match); -} - -/** - * Checks if a match is completed because of a either a draw or a win. - * - * @param match Partial match results. - */ -function isMatchResultCompleted(match: DeepPartial): boolean { - return isMatchWinCompleted(match); + return isMatchByeCompleted(match) || isMatchWinCompleted(match); } /** @@ -661,7 +539,9 @@ export function isMatchByeCompleted(match: DeepPartial): boolean { * @param match The match to check. */ export function isMatchUpdateLocked(match: MatchResults): boolean { - return match.status === Status.Locked || match.status === Status.Waiting; + return ( + match.status === MatchStatus.Locked || match.status === MatchStatus.Waiting + ); } /** @@ -678,21 +558,21 @@ export function hasBye(match: DeepPartial): boolean { * * @param opponents The opponents of a match. */ -export function getMatchStatus(opponents: Duel): Status; +export function getMatchStatus(opponents: Duel): MatchStatus; /** * Returns the status of a match based on the results of a match. * * @param match Partial match results. */ -export function getMatchStatus(match: MatchResults): Status; +export function getMatchStatus(match: MatchResults): MatchStatus; /** * Returns the status of a match based on information about it. * * @param arg The opponents or partial results of the match. */ -export function getMatchStatus(arg: Duel | MatchResults): Status { +export function getMatchStatus(arg: Duel | MatchResults): MatchStatus { const match = Array.isArray(arg) ? { opponent1: arg[0], @@ -702,21 +582,21 @@ export function getMatchStatus(arg: Duel | MatchResults): Status { if (hasBye(match)) // At least one BYE. - return Status.Locked; + return MatchStatus.Locked; if (match.opponent1?.id === null && match.opponent2?.id === null) // Two TBD opponents. - return Status.Locked; + return MatchStatus.Locked; if (match.opponent1?.id === null || match.opponent2?.id === null) // One TBD opponent. - return Status.Waiting; + return MatchStatus.Waiting; - if (isMatchCompleted(match)) return Status.Completed; + if (isMatchCompleted(match)) return MatchStatus.Completed; - if (isMatchStarted(match)) return Status.Running; + if (isMatchStarted(match)) return MatchStatus.Running; - return Status.Ready; + return MatchStatus.Ready; } /** @@ -724,7 +604,6 @@ export function getMatchStatus(arg: Duel | MatchResults): Status { * * @param stored A reference to what will be updated in the storage. * @param match Input of the update. - * @param inRoundRobin Indicates whether the match is in a round-robin stage. */ export function setMatchResults( stored: MatchResults, @@ -805,7 +684,7 @@ function setExtraFields( } }; - const ignoredKeys: Array = [ + const ignoredKeys: Array = [ "id", "number", "stage_id", @@ -846,7 +725,7 @@ function getOpponentId(match: MatchResults, side: Side): number | null { * @param match The match. * @param side The side. */ -export function getOriginPosition(match: Match, side: Side): number { +export function getOriginPosition(match: MatchData, side: Side): number { const matchNumber = match[side]?.position; if (matchNumber === undefined) throw Error("Position is undefined."); @@ -887,7 +766,7 @@ export function getNextSide( */ export function getNextSideLoserBracket( matchNumber: number, - nextMatch: Match, + nextMatch: MatchData, roundNumber: number, ): Side { // The nextSide comes from the WB. @@ -900,9 +779,9 @@ export function getNextSideLoserBracket( } export type SetNextOpponent = ( - nextMatch: Match, + nextMatch: MatchData, nextSide: Side, - match?: Match, + match?: MatchData, currentSide?: Side, ) => void; @@ -915,9 +794,9 @@ export type SetNextOpponent = ( * @param currentSide The side the opponent is currently on. */ export function setNextOpponent( - nextMatch: Match, + nextMatch: MatchData, nextSide: Side, - match?: Match, + match?: MatchData, currentSide?: Side, ): void { nextMatch[nextSide] = match![currentSide!] && { @@ -935,14 +814,14 @@ export function setNextOpponent( * @param nextMatch A match which follows the current one. * @param nextSide The side the opponent will be on in the next match. */ -export function resetNextOpponent(nextMatch: Match, nextSide: Side): void { +export function resetNextOpponent(nextMatch: MatchData, nextSide: Side): void { nextMatch[nextSide] = nextMatch[nextSide] && { // Keep BYE. id: null, position: nextMatch[nextSide]?.position, // Keep position. }; - nextMatch.status = Status.Locked; + nextMatch.status = MatchStatus.Locked; } /** @@ -1002,7 +881,7 @@ function setScores( return false; const oldStatus = stored.status; - stored.status = Status.Running; + stored.status = MatchStatus.Running; if (match.opponent1 && stored.opponent1) stored.opponent1.score = match.opponent1.score; @@ -1023,7 +902,7 @@ function setCompleted( stored: MatchResults, match: DeepPartial, ): void { - stored.status = Status.Completed; + stored.status = MatchStatus.Completed; setResults(stored, match, "win", "loss"); setResults(stored, match, "loss", "win"); @@ -1079,7 +958,7 @@ function setResults( * * @param array The array to process. */ -function getNonNull(array: Nullable[]): T[] { +function getNonNull(array: (T | null)[]): T[] { // Use a TS type guard to exclude null from the resulting type. const nonNull = array.filter((element): element is T => element !== null); return nonNull; @@ -1132,60 +1011,6 @@ export function transitionToMinor( return currentDuels; } -/** - * Gets the values which need to be updated in a match when it's updated on insertion. - * - * @param match The up to date match. - * @param existing The base match. - * @param enableByes Whether to use BYEs or TBDs for `null` values in an input seeding. - */ -export function getUpdatedMatchResults( - match: T, - existing: T, - enableByes: boolean, -): T { - return { - ...existing, - ...match, - ...(enableByes - ? { - opponent1: - match.opponent1 === null - ? null - : { ...existing.opponent1, ...match.opponent1 }, - opponent2: - match.opponent2 === null - ? null - : { ...existing.opponent2, ...match.opponent2 }, - } - : { - opponent1: - match.opponent1 === null - ? { id: null } - : { ...existing.opponent1, ...match.opponent1 }, - opponent2: - match.opponent2 === null - ? { id: null } - : { ...existing.opponent2, ...match.opponent2 }, - }), - }; -} - -/** - * Indicates whether the ordering is supported in loser bracket, given the round number. - * - * @param roundNumber The number of the round. - * @param roundCount The count of rounds. - */ -export function isOrderingSupportedLoserBracket( - roundNumber: number, - roundCount: number, -): boolean { - return ( - roundNumber === 1 || (roundNumber % 2 === 0 && roundNumber < roundCount) - ); -} - /** * Returns the number of rounds an upper bracket has given the number of participants in the stage. * @@ -1299,32 +1124,28 @@ export function getDiagonalMatchNumber(matchNumber: number): number { return Math.ceil(matchNumber / 2); } -/** - * Returns the nearest power of two **greater than** or equal to the given number. - * - * @param input The input number. - */ -function getNearestPowerOfTwo(input: number): number { - return 2 ** Math.ceil(Math.log2(input)); -} - /** * Checks if a stage is a round-robin stage. * * @param stage The stage to check. */ -export function isRoundRobin(stage: Stage): boolean { +export function isRoundRobin(stage: StageData): boolean { return stage.type === "round_robin"; } -export function isSwiss(stage: Stage): boolean { +/** + * Checks if a stage is a swiss stage. + * + * @param stage The stage to check. + */ +export function isSwiss(stage: StageData): boolean { return stage.type === "swiss"; } /** * Checks if a group is a winner bracket. * - * It's not always the opposite of `inLoserBracket()`: it could be the only bracket of a single elimination stage. + * It's not always the opposite of `isLoserBracket()`: it could be the only bracket of a single elimination stage. * * @param stageType Type of the stage. * @param groupNumber Number of the group. diff --git a/app/features/tournament-bracket/core/engine/index.ts b/app/features/tournament-bracket/core/engine/index.ts new file mode 100644 index 000000000..a248567b2 --- /dev/null +++ b/app/features/tournament-bracket/core/engine/index.ts @@ -0,0 +1,12 @@ +/** + * Pure bracket engine. No I/O anywhere in this module tree — callers hydrate + * BracketData via BracketRepository, call engine functions, persist the + * returned delta via BracketRepository. + */ + +export { create } from "./create"; +export { endDroppedTeamMatches } from "./propagation/dropped-teams"; +export { reportResult } from "./propagation/report-result"; +export { resetMatchResults } from "./propagation/reset-result"; +export { generateRound } from "./swiss/pairing"; +export * from "./types"; diff --git a/app/features/tournament-bracket/core/engine/propagation/double-elimination.test.ts b/app/features/tournament-bracket/core/engine/propagation/double-elimination.test.ts new file mode 100644 index 000000000..c98b4c7ee --- /dev/null +++ b/app/features/tournament-bracket/core/engine/propagation/double-elimination.test.ts @@ -0,0 +1,334 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { TournamentMatchStatus } from "~/db/tables"; +import { EngineBracket } from "../test-utils"; + +const bracket = new EngineBracket(); + +describe("Previous and next match update in double elimination stage", () => { + beforeEach(() => { + bracket.reset(); + }); + + test("should end a match and determine next matches", () => { + bracket.create({ + name: "Amateur", + tournamentId: 0, + type: "double_elimination", + seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + settings: { seedOrdering: ["natural"], grandFinal: "simple" }, + }); + + const before = bracket.match(8); // First match of WB round 2 + expect(before.opponent2?.id).toBeNull(); + + bracket.updateMatch({ + id: 0, // First match of WB round 1 + opponent1: { score: 16, result: "win" }, + opponent2: { score: 12 }, + }); + + bracket.updateMatch({ + id: 1, // Second match of WB round 1 + opponent1: { score: 13 }, + opponent2: { score: 16, result: "win" }, + }); + + bracket.updateMatch({ + id: 15, // First match of LB round 1 + opponent1: { score: 16, result: "win" }, + opponent2: { score: 10 }, + }); + + expect( + bracket.match(8).opponent1?.id, // Determined opponent for WB round 2 + ).toBe(bracket.match(0).opponent1?.id); // Winner of first match round 1 + + expect( + bracket.match(8).opponent2?.id, // Determined opponent for WB round 2 + ).toBe(bracket.match(1).opponent2?.id); // Winner of second match round 1 + + expect( + bracket.match(15).opponent2?.id, // Determined opponent for LB round 1 + ).toBe(bracket.match(1).opponent1?.id); // Loser of second match round 1 + + expect( + bracket.match(19).opponent2?.id, // Determined opponent for LB round 2 + ).toBe(bracket.match(0).opponent2?.id); // Loser of first match round 1 + }); + + test("should propagate winner when BYE is already in next match in loser bracket", () => { + bracket.create({ + name: "Example", + tournamentId: 0, + type: "double_elimination", + seeding: [1, 2, 3, null], + settings: { grandFinal: "simple" }, + }); + + bracket.updateMatch({ + id: 1, // Second match of WB round 1 + opponent1: { score: 16, result: "win" }, + opponent2: { score: 12 }, + }); + + const loserId = bracket.match(1).opponent2?.id; + let matchSemiLB = bracket.match(3); + + expect(matchSemiLB.opponent2?.id).toBe(loserId); + expect(matchSemiLB.opponent2?.result).toBe("win"); + expect(matchSemiLB.status).toBe(TournamentMatchStatus.Completed); + + expect( + bracket.match(4).opponent2?.id, // Propagated winner in LB Final because of the BYE. + ).toBe(loserId); + + bracket.resetMatchResults(1); // Second match of WB round 1 + + matchSemiLB = bracket.match(3); + expect(matchSemiLB.opponent2?.id).toBeNull(); + expect(matchSemiLB.opponent2?.result).toBeUndefined(); + expect(matchSemiLB.status).toBe(TournamentMatchStatus.Locked); + + expect(bracket.match(4).opponent2?.id).toBeNull(); // Propagated winner is removed. + }); + + test("should determine matches in grand final", () => { + bracket.create({ + name: "Example", + tournamentId: 0, + type: "double_elimination", + seeding: [1, 2, 3, 4], + settings: { grandFinal: "double" }, + }); + + bracket.updateMatch({ + id: 0, // First match of WB round 1 + opponent1: { score: 16, result: "win" }, + opponent2: { score: 12 }, + }); + + bracket.updateMatch({ + id: 1, // Second match of WB round 1 + opponent1: { score: 13 }, + opponent2: { score: 16, result: "win" }, + }); + + bracket.updateMatch({ + id: 2, // WB Final + opponent1: { score: 16, result: "win" }, + opponent2: { score: 9 }, + }); + + expect( + bracket.match(5).opponent1?.id, // Determined opponent for the grand final (round 1) + ).toBe(bracket.match(0).opponent1?.id); // Winner of WB Final + + bracket.updateMatch({ + id: 3, // Only match of LB round 1 + opponent1: { score: 12, result: "win" }, // Team 4 + opponent2: { score: 8 }, + }); + + bracket.updateMatch({ + id: 4, // LB Final + opponent1: { score: 14, result: "win" }, // Team 3 + opponent2: { score: 7 }, + }); + + expect( + bracket.match(5).opponent2?.id, // Determined opponent for the grand final (round 1) + ).toBe(bracket.match(1).opponent2?.id); // Winner of LB Final + + bracket.updateMatch({ + id: 5, // Grand Final round 1 + opponent1: { score: 10 }, + opponent2: { score: 16, result: "win" }, // Team 3 + }); + + expect( + bracket.match(6).opponent2?.id, // Determined opponent for the grand final (round 2) + ).toBe(bracket.match(1).opponent2?.id); // Winner of LB Final + + expect(bracket.match(5).status).toBe(TournamentMatchStatus.Completed); // Grand final (round 1) + expect(bracket.match(6).status).toBe(TournamentMatchStatus.Ready); // Grand final (round 2) + + bracket.updateMatch({ + id: 6, // Grand Final round 2 + opponent1: { score: 16, result: "win" }, + opponent2: { score: 10 }, + }); + }); + + test("should determine next matches and reset them", () => { + bracket.create({ + name: "Example", + tournamentId: 0, + type: "double_elimination", + seeding: [1, 2, 3, 4], + settings: { grandFinal: "double" }, + }); + + bracket.updateMatch({ + id: 0, // First match of WB round 1 + opponent1: { score: 16, result: "win" }, + opponent2: { score: 12 }, + }); + + const beforeReset = bracket.match(3); // Determined opponent for LB round 1 + expect(beforeReset.opponent1?.id).toBe(bracket.match(0).opponent2?.id); + expect(beforeReset.opponent1?.position).toBe(1); // Must be set. + + bracket.resetMatchResults(0); // First match of WB round 1 + + const afterReset = bracket.match(3); // Determined opponent for LB round 1 + expect(afterReset.opponent1?.id).toBeNull(); + expect(afterReset.opponent1?.position).toBe(1); // It must stay. + }); + + test("should choose the correct previous and next matches based on losers ordering", () => { + bracket.create({ + name: "Amateur", + tournamentId: 0, + type: "double_elimination", + seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + settings: { + seedOrdering: ["natural", "reverse", "reverse"], + grandFinal: "simple", + }, + }); + + bracket.updateMatch({ id: 0, opponent1: { result: "win" } }); // WB 1.1 + expect( + bracket.match(18).opponent2?.id, // Determined opponent for last match of LB round 1 (reverse ordering for losers) + ).toBe(bracket.match(0).opponent2?.id); // Loser of first match round 1 + + bracket.updateMatch({ id: 1, opponent1: { result: "win" } }); // WB 1.2 + expect( + bracket.match(18).opponent1?.id, // Determined opponent for last match of LB round 1 (reverse ordering for losers) + ).toBe(bracket.match(1).opponent2?.id); // Loser of second match round 1 + + bracket.updateMatch({ id: 8, opponent1: { result: "win" } }); // WB 2.1 + expect( + bracket.match(22).opponent1?.id, // Determined opponent for last match of LB round 2 (reverse ordering for losers) + ).toBe(bracket.match(8).opponent2?.id); // Loser of first match round 2 + + bracket.updateMatch({ id: 6, opponent1: { result: "win" } }); // WB 1.7 + bracket.updateMatch({ id: 7, opponent1: { result: "win" } }); // WB 1.8 + bracket.updateMatch({ id: 11, opponent1: { result: "win" } }); // WB 2.4 + bracket.updateMatch({ id: 15, opponent1: { result: "win" } }); // LB 1.1 + bracket.updateMatch({ id: 19, opponent1: { result: "win" } }); // LB 2.1 + + expect(bracket.match(8).status).toBe(TournamentMatchStatus.Completed); // WB 2.1 + }); + + test("should send the losers to the right LB matches in round 1", () => { + bracket.create({ + name: "Example with inner_outer loser ordering", + tournamentId: 0, + type: "double_elimination", + seeding: [1, 2, 3, 4, 5, 6, 7, 8], + settings: { + seedOrdering: ["inner_outer", "inner_outer"], + }, + }); + + expect(bracket.match(7).opponent1?.position).toBe(1); + expect(bracket.match(7).opponent2?.position).toBe(4); + expect(bracket.match(8).opponent1?.position).toBe(2); + expect(bracket.match(8).opponent2?.position).toBe(3); + + // Match of position 1. + bracket.updateMatch({ + id: 0, + opponent1: { result: "win" }, // Loser id: 7. + }); + + expect(bracket.match(7).opponent1?.id).toBe(8); + + // Match of position 2. + bracket.updateMatch({ + id: 1, + opponent1: { result: "win" }, // Loser id: 4. + }); + + expect(bracket.match(8).opponent1?.id).toBe(5); + + // Match of position 3. + bracket.updateMatch({ + id: 2, + opponent1: { result: "win" }, // Loser id: 6. + }); + + expect(bracket.match(8).opponent2?.id).toBe(7); + + // Match of position 4. + bracket.updateMatch({ + id: 3, + opponent1: { result: "win" }, // Loser id: 5. + }); + + expect(bracket.match(7).opponent2?.id).toBe(6); + }); +}); + +describe("Skip first round", () => { + beforeEach(() => { + bracket.reset(); + + bracket.create({ + name: "Example with double grand final", + tournamentId: 0, + type: "double_elimination", + seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + settings: { + seedOrdering: ["natural"], + skipFirstRound: true, + grandFinal: "double", + }, + }); + }); + + test("should create a double elimination stage with skip first round option", () => { + expect(bracket.groups().length).toBe(3); + expect(bracket.rounds().length).toBe(3 + 6 + 2); // One round less in WB. + expect(bracket.matches().length).toBe( + 4 + 2 + 1 + (4 + 4 + 2 + 2 + 1 + 1) + (1 + 1), + ); + + expect(bracket.round(0).number).toBe(1); // Even though the "real" first round is skipped, the stored first round's number should be 1. + + expect(bracket.match(0).opponent1?.id).toBe(1); // First match of WB. + expect(bracket.match(7).opponent1?.id).toBe(2); // First match of LB. + }); + + test("should choose the correct previous and next matches", () => { + bracket.updateMatch({ id: 0, opponent1: { result: "win" } }); + expect(bracket.match(7).opponent1?.id).toBe(2); // First match of LB Round 1 (must stay). + expect(bracket.match(12).opponent1?.id).toBe(3); // First match of LB Round 2 (must be updated). + + bracket.updateMatch({ id: 1, opponent1: { result: "win" } }); + expect(bracket.match(7).opponent2?.id).toBe(4); // First match of LB Round 1 (must stay). + expect(bracket.match(11).opponent1?.id).toBe(7); // Second match of LB Round 2 (must be updated). + + bracket.updateMatch({ id: 4, opponent1: { result: "win" } }); // First match of WB Round 2. + expect(bracket.match(18).opponent1?.id).toBe(5); // First match of LB Round 4. + + bracket.updateMatch({ id: 7, opponent1: { result: "win" } }); // First match of LB Round 1. + expect(bracket.match(11).opponent2?.id).toBe(2); // First match of LB Round 2. + + for (let i = 2; i < 21; i++) + bracket.updateMatch({ id: i, opponent1: { result: "win" } }); + + expect(bracket.match(15).opponent1?.id).toBe(7); // First match of LB Round 3. + + expect(bracket.match(21).opponent1?.id).toBe(1); // GF Round 1. + expect(bracket.match(21).opponent2?.id).toBe(9); // GF Round 1. + + bracket.updateMatch({ id: 21, opponent2: { result: "win" } }); + + expect(bracket.match(21).opponent1?.id).toBe(1); // GF Round 2. + expect(bracket.match(22).opponent2?.id).toBe(9); // GF Round 2. + + bracket.updateMatch({ id: 22, opponent2: { result: "win" } }); + }); +}); diff --git a/app/features/tournament-bracket/core/engine/propagation/dropped-teams.ts b/app/features/tournament-bracket/core/engine/propagation/dropped-teams.ts new file mode 100644 index 000000000..c405c74d5 --- /dev/null +++ b/app/features/tournament-bracket/core/engine/propagation/dropped-teams.ts @@ -0,0 +1,70 @@ +import type { BracketData, DroppedTeamsResult } from "../types"; +import { Store } from "./store"; +import { Propagator } from "./traversal"; + +/** + * Ends all unfinished matches involving dropped teams by awarding wins to + * their opponents. Ported from tournament-utils.server.ts. `randomPick` is + * injected so the engine stays deterministic/testable (caller passes a + * Math.random-backed impl in production). + * + * Matches that only gain a dropped opponent through propagation caused by this + * call are not ended (same as the old implementation, which iterated a + * snapshot); they are picked up by the next call. + */ +export function endDroppedTeamMatches( + data: BracketData, + args: { + droppedTeamIds: number[]; + randomPick: (a: number, b: number) => number; + }, +): DroppedTeamsResult { + const store = new Store(data); + const propagator = new Propagator(store); + const droppedTeamIds = new Set(args.droppedTeamIds); + + const endedMatchIds: number[] = []; + + for (const match of data.match) { + if (!match.opponent1?.id || !match.opponent2?.id) continue; + if (match.opponent1.result === "win" || match.opponent2.result === "win") + continue; + + const team1Dropped = droppedTeamIds.has(match.opponent1.id); + const team2Dropped = droppedTeamIds.has(match.opponent2.id); + + if (!team1Dropped && !team2Dropped) continue; + + const winnerTeamId = (() => { + if (team1Dropped && !team2Dropped) return match.opponent2.id; + if (!team1Dropped && team2Dropped) return match.opponent1.id; + return args.randomPick(match.opponent1.id, match.opponent2.id); + })(); + + const stored = store.select("match", match.id); + if (!stored) throw Error("Match not found."); + + propagator.updateMatch( + stored, + { + opponent1: { + score: match.opponent1.score, + result: winnerTeamId === match.opponent1.id ? "win" : "loss", + }, + opponent2: { + score: match.opponent2.score, + result: winnerTeamId === match.opponent2.id ? "win" : "loss", + }, + }, + true, + ); + + endedMatchIds.push(match.id); + } + + return { + data: store.data, + changedMatches: store.changedMatches(), + endedMatchIds, + }; +} diff --git a/app/features/tournament-bracket/core/engine/propagation/report-result.ts b/app/features/tournament-bracket/core/engine/propagation/report-result.ts new file mode 100644 index 000000000..b7ead3e49 --- /dev/null +++ b/app/features/tournament-bracket/core/engine/propagation/report-result.ts @@ -0,0 +1,29 @@ +import type { BracketData, EngineResult, ReportResultInput } from "../types"; +import { Store } from "./store"; +import { Propagator } from "./traversal"; + +/** + * Applies a result to a match and propagates: + * winner/loser advancement (SE/DE), BYE cascades, forfeit wins, grand final + * + bracket reset, next-round unlocking (RR), no downstream propagation for + * swiss/RR completed matches. Throws on locked/completed matches unless + * input.force. + */ +export function reportResult( + data: BracketData, + input: ReportResultInput, +): EngineResult { + const store = new Store(data); + const propagator = new Propagator(store); + + const stored = store.select("match", input.matchId); + if (!stored) throw Error("Match not found."); // xxx: use invariant + + propagator.updateMatch( + stored, + { opponent1: input.opponent1, opponent2: input.opponent2 }, + input.force, + ); + + return { data: store.data, changedMatches: store.changedMatches() }; +} diff --git a/app/features/tournament-bracket/core/engine/propagation/reset-result.ts b/app/features/tournament-bracket/core/engine/propagation/reset-result.ts new file mode 100644 index 000000000..6f3277c15 --- /dev/null +++ b/app/features/tournament-bracket/core/engine/propagation/reset-result.ts @@ -0,0 +1,60 @@ +import * as helpers from "../helpers"; +import type { BracketData, EngineResult } from "../types"; +import { MatchStatus } from "../types"; +import { Store } from "./store"; +import { Propagator } from "./traversal"; + +/** + * Clears a match's results and rolls back everything that was propagated + * from it. 1:1 with the old reset.ts, including the swiss/round_robin early + * return. + */ +export function resetMatchResults( + data: BracketData, + matchId: number, +): EngineResult { + const store = new Store(data); + const propagator = new Propagator(store); + + const stored = store.select("match", matchId); + if (!stored) throw Error("Match not found."); + + const stage = store.select("stage", stored.stage_id); + if (!stage) throw Error("Stage not found."); + + const group = store.select("group", stored.group_id); + if (!group) throw Error("Group not found."); + + const { roundNumber, roundCount } = propagator.getRoundPositionalInfo( + stored.round_id, + ); + const matchLocation = helpers.getMatchLocation(stage.type, group.number); + const nextMatches = + stage.type !== "round_robin" && stage.type !== "swiss" + ? propagator.getNextMatches( + stored, + matchLocation, + stage, + roundNumber, + roundCount, + ) + : []; + + if ( + nextMatches.some( + (match) => + match && + match.status >= MatchStatus.Running && + !helpers.isMatchByeCompleted(match), + ) + ) + throw Error("The match is locked."); + + helpers.resetMatchResults(stored); + propagator.applyMatchUpdate(stored); + + if (!helpers.isRoundRobin(stage) && !helpers.isSwiss(stage)) + propagator.updateRelatedMatches(stored, true, true); + + return { data: store.data, changedMatches: store.changedMatches() }; +} diff --git a/app/features/tournament-bracket/core/engine/propagation/round-robin.test.ts b/app/features/tournament-bracket/core/engine/propagation/round-robin.test.ts new file mode 100644 index 000000000..a96417341 --- /dev/null +++ b/app/features/tournament-bracket/core/engine/propagation/round-robin.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { EngineBracket } from "../test-utils"; + +const bracket = new EngineBracket(); + +describe("Update scores in a round-robin stage", () => { + beforeEach(() => { + bracket.reset(); + bracket.create({ + name: "Example scores", + tournamentId: 0, + type: "round_robin", + seeding: [1, 2, 3, 4], + settings: { groupCount: 1 }, + }); + }); + + test("should set all the scores", () => { + bracket.updateMatch({ + id: 0, + opponent1: { score: 16, result: "win" }, // POCEBLO + opponent2: { score: 9 }, // AQUELLEHEURE?! + }); + + bracket.updateMatch({ + id: 1, + opponent1: { score: 3 }, // Ballec Squad + opponent2: { score: 16, result: "win" }, // twitch.tv/mrs_fly + }); + + bracket.updateMatch({ + id: 2, + opponent1: { score: 16, result: "win" }, // twitch.tv/mrs_fly + opponent2: { score: 0 }, // AQUELLEHEURE?! + }); + + bracket.updateMatch({ + id: 3, + opponent1: { score: 16, result: "win" }, // POCEBLO + opponent2: { score: 2 }, // Ballec Squad + }); + + bracket.updateMatch({ + id: 4, + opponent1: { score: 16, result: "win" }, // Ballec Squad + opponent2: { score: 12 }, // AQUELLEHEURE?! + }); + + bracket.updateMatch({ + id: 5, + opponent1: { score: 4 }, // twitch.tv/mrs_fly + opponent2: { score: 16, result: "win" }, // POCEBLO + }); + }); + + test("should unlock next round matches as soon as both participants are ready", () => { + // Round robin with 4 teams: [1, 2, 3, 4] + // Round 1: Match 0 (1 vs 2), Match 1 (3 vs 4) + // Round 2: Match 2 (1 vs 3), Match 3 (2 vs 4) + // Round 3: Match 4 (1 vs 4), Match 5 (2 vs 3) + + // Initially, only round 1 matches should be ready + expect(bracket.match(0).status).toBe(2); // Ready (1 vs 2) + expect(bracket.match(1).status).toBe(2); // Ready (3 vs 4) + expect(bracket.match(2).status).toBe(0); // Locked (1 vs 3) + expect(bracket.match(3).status).toBe(0); // Locked (2 vs 4) + + // Complete first match of round 1 (1 vs 2) + bracket.updateMatch({ + id: 0, + opponent1: { score: 16, result: "win" }, // Team 1 wins + opponent2: { score: 9 }, // Team 2 loses + }); + + // Round 2 Match 1 (1 vs 3) should still be locked because team 3 hasn't finished + // Round 2 Match 2 (2 vs 4) should still be locked because team 4 hasn't finished + expect(bracket.match(2).status).toBe(0); // Still Locked + expect(bracket.match(3).status).toBe(0); // Still Locked + + // Complete second match of round 1 (3 vs 4) + bracket.updateMatch({ + id: 1, + opponent1: { score: 3 }, // Team 3 loses + opponent2: { score: 16, result: "win" }, // Team 4 wins + }); + + // Now both matches in round 2 should be unlocked + // Match 2 (1 vs 3): both team 1 and team 3 have finished round 1 + // Match 3 (2 vs 4): both team 2 and team 4 have finished round 1 + expect(bracket.match(2).status).toBe(2); // Ready + expect(bracket.match(3).status).toBe(2); // Ready + }); + + test("should leave every match Ready when independentRounds is set", () => { + bracket.reset(); + bracket.create({ + name: "Independent rounds", + tournamentId: 0, + type: "round_robin", + seeding: [1, 2, 3, 4], + settings: { groupCount: 1, independentRounds: true }, + }); + + for (const match of bracket.matches()) { + expect(match.status).toBe(2); + } + + // reporting a round-2 match before round-1 finishes must not throw + const round2Match = bracket.match(2); + expect(() => + bracket.updateMatch({ + id: round2Match.id, + opponent1: { score: 16, result: "win" }, + opponent2: { score: 4 }, + }), + ).not.toThrow(); + }); + + test("should let the only real match be played in a group with fewer teams than slots", () => { + bracket.reset(); + // Group sized for 3 but only 2 teams placed (the 3rd slot is a BYE). + // The two real teams only meet in round 3, preceded by two BYE rounds + // that can never be reported. The real match must still be playable. + bracket.create({ + name: "Two teams in a three-slot group", + tournamentId: 0, + type: "round_robin", + seeding: [1, 2, null], + settings: { groupCount: 1 }, + }); + + const realMatch = bracket + .matches() + .find((m) => m.opponent1?.id && m.opponent2?.id)!; + + expect(realMatch.status).toBe(2); // Ready + + expect(() => + bracket.updateMatch({ + id: realMatch.id, + opponent1: { score: 16, result: "win" }, + opponent2: { score: 9 }, + }), + ).not.toThrow(); + }); + + test("should unlock next round matches with BYE participants", () => { + bracket.reset(); + // Create a round robin with 3 teams (odd number creates rounds where one team doesn't play) + bracket.create({ + name: "Example with BYEs", + tournamentId: 0, + type: "round_robin", + seeding: [1, 2, 3], + settings: { groupCount: 1 }, + }); + + // With 3 teams, the rounds look like: + // Round 1: Match (teams 3 vs 2) - Team 1 doesn't play + // Round 2: Match (teams 1 vs 3) - Team 2 doesn't play + // Round 3: Match (teams 2 vs 1) - Team 3 doesn't play + + const allMatches = bracket.matches(); + const allRounds = bracket.rounds(); + + // Find the actual match (not BYE vs BYE which doesn't exist) + const round1RealMatch = allMatches.find( + (m) => m.round_id === allRounds[0].id && m.opponent1 && m.opponent2, + )!; + const round2RealMatch = allMatches.find( + (m) => m.round_id === allRounds[1].id && m.opponent1 && m.opponent2, + )!; + + expect(round1RealMatch.status).toBe(2); // Ready + expect(round2RealMatch.status).toBe(0); // Locked initially + + // Complete the only real match in round 1 (teams 3 vs 2) + // Team 1 didn't play in round 1 + bracket.updateMatch({ + id: round1RealMatch.id, + opponent1: { score: 16, result: "win" }, + opponent2: { score: 9 }, + }); + + // The real match in round 2 (teams 1 vs 3) should now be unlocked + // because team 1 didn't play in round 1 (considered ready) + // and team 3 just finished their match + expect(bracket.match(round2RealMatch.id).status).toBe(2); // Ready + }); +}); diff --git a/app/features/tournament-bracket/core/engine/propagation/single-elimination.test.ts b/app/features/tournament-bracket/core/engine/propagation/single-elimination.test.ts new file mode 100644 index 000000000..68a09e119 --- /dev/null +++ b/app/features/tournament-bracket/core/engine/propagation/single-elimination.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { TournamentMatchStatus } from "~/db/tables"; +import { EngineBracket } from "../test-utils"; + +const bracket = new EngineBracket(); + +describe("Previous and next match update", () => { + beforeEach(() => { + bracket.reset(); + }); + + test("should determine matches in consolation final", () => { + bracket.create({ + name: "Example", + tournamentId: 0, + type: "single_elimination", + seeding: [1, 2, 3, 4], + settings: { consolationFinal: true }, + }); + + bracket.updateMatch({ + id: 0, // First match of round 1 + opponent1: { score: 16, result: "win" }, + opponent2: { score: 12 }, + }); + + bracket.updateMatch({ + id: 1, // Second match of round 1 + opponent1: { score: 13 }, + opponent2: { score: 16, result: "win" }, + }); + + expect(bracket.match(3).opponent1?.id).toBe(bracket.match(0).opponent2?.id); + expect(bracket.match(3).opponent2?.id).toBe(bracket.match(1).opponent1?.id); + expect(bracket.match(2).status).toBe(TournamentMatchStatus.Ready); + expect(bracket.match(3).status).toBe(TournamentMatchStatus.Ready); + }); + + test("should play both the final and consolation final in parallel", () => { + bracket.create({ + name: "Example", + tournamentId: 0, + type: "single_elimination", + seeding: [1, 2, 3, 4], + settings: { consolationFinal: true }, + }); + + bracket.updateMatch({ + id: 0, // First match of round 1 + opponent1: { score: 16, result: "win" }, + opponent2: { score: 12 }, + }); + + bracket.updateMatch({ + id: 1, // Second match of round 1 + opponent1: { score: 13 }, + opponent2: { score: 16, result: "win" }, + }); + + bracket.updateMatch({ + id: 2, // Final + opponent1: { score: 12 }, + opponent2: { score: 9 }, + }); + + expect(bracket.match(2).status).toBe(TournamentMatchStatus.Running); + expect(bracket.match(3).status).toBe(TournamentMatchStatus.Ready); + + bracket.updateMatch({ + id: 3, // Consolation final + opponent1: { score: 12 }, + opponent2: { score: 9 }, + }); + + expect(bracket.match(2).status).toBe(TournamentMatchStatus.Running); + expect(bracket.match(3).status).toBe(TournamentMatchStatus.Running); + + bracket.updateMatch({ + id: 3, // Consolation final + opponent1: { score: 16, result: "win" }, + opponent2: { score: 9 }, + }); + + expect(bracket.match(2).status).toBe(TournamentMatchStatus.Running); + + bracket.updateMatch({ + id: 2, // Final + opponent1: { score: 16, result: "win" }, + opponent2: { score: 9 }, + }); + }); +}); diff --git a/app/features/tournament-bracket/core/engine/propagation/store.ts b/app/features/tournament-bracket/core/engine/propagation/store.ts new file mode 100644 index 000000000..ece6f410c --- /dev/null +++ b/app/features/tournament-bracket/core/engine/propagation/store.ts @@ -0,0 +1,83 @@ +import type { + BracketData, + GroupData, + MatchData, + RoundData, + StageData, +} from "../types"; + +interface TableTypes { + stage: StageData; + group: GroupData; + round: RoundData; + match: MatchData; +} + +type Table = keyof TableTypes; + +/** + * A working copy of BracketData with the same select/update semantics the old + * storage had: selects return clones (in table order), updates replace the row + * by id. Tracks which match rows were written so callers can emit a delta. + */ +export class Store { + readonly data: BracketData; + private readonly touchedMatchIds = new Set(); + + constructor(data: BracketData) { + this.data = structuredClone(data); + } + + select(table: T, id: number): TableTypes[T] | null { + const row = (this.data[table] as TableTypes[T][]).find((r) => r.id === id); + return row ? structuredClone(row) : null; + } + + selectAll( + table: T, + filter: Partial, + ): TableTypes[T][] { + return (this.data[table] as TableTypes[T][]) + .filter(makeFilter(filter)) + .map((row) => structuredClone(row)); + } + + selectFirst( + table: T, + filter: Partial, + ): TableTypes[T] | null { + const results = this.selectAll(table, filter); + return results.length > 0 ? results[0] : null; + } + + selectLast( + table: T, + filter: Partial, + ): TableTypes[T] | null { + const results = this.selectAll(table, filter); + return results.length > 0 ? results[results.length - 1] : null; + } + + updateMatch(match: MatchData): void { + const index = this.data.match.findIndex((m) => m.id === match.id); + if (index === -1) throw Error("Could not update the match."); + + this.data.match[index] = structuredClone(match); + this.touchedMatchIds.add(match.id); + } + + /** Returns the final version of every match row that was written during this operation. */ + changedMatches(): MatchData[] { + return this.data.match.filter((match) => + this.touchedMatchIds.has(match.id), + ); + } +} + +function makeFilter( + partial: Partial, +): (row: T) => boolean { + const entries = Object.entries(partial); + return (row) => + entries.every(([key, value]) => row[key as keyof T] === value); +} diff --git a/app/modules/brackets-manager/base/getter.ts b/app/features/tournament-bracket/core/engine/propagation/traversal.ts similarity index 56% rename from app/modules/brackets-manager/base/getter.ts rename to app/features/tournament-bracket/core/engine/propagation/traversal.ts index dcb2918f1..a2309482f 100644 --- a/app/modules/brackets-manager/base/getter.ts +++ b/app/features/tournament-bracket/core/engine/propagation/traversal.ts @@ -1,85 +1,375 @@ -import type { - Group, - GroupType, - Match, - Round, - Stage, - StageType, -} from "~/modules/brackets-model"; +import type { SetNextOpponent } from "../helpers"; import * as helpers from "../helpers"; -import type { RoundPositionalInfo, Storage } from "../types"; +import type { + DeepPartial, + GroupData, + GroupType, + MatchData, + RoundData, + Side, + StageData, + StageType, +} from "../types"; +import { MatchStatus } from "../types"; +import type { Store } from "./store"; -export class BaseGetter { - protected readonly storage: Storage; +interface RoundPositionalInfo { + roundNumber: number; + roundCount: number; +} - /** - * Creates an instance of a Storage getter. - * - * @param storage The implementation of Storage. - */ - constructor(storage: Storage) { - this.storage = storage; +/** + * Resolves matches related to another match (previous and next) and applies + * result propagation to them. 1:1 port of the old base/getter.ts + + * base/updater.ts, reading and writing a Store instead of storage. + */ +export class Propagator { + readonly store: Store; + + constructor(store: Store) { + this.store = store; } - /** - * Gets all the rounds that contain ordered participants. - * - * @param stage The stage to get rounds from. - */ - protected getOrderedRounds(stage: Stage): Round[] { - if (!stage?.settings.size) throw Error("The stage has no size."); - - if (stage.type === "single_elimination") - return this.getOrderedRoundsSingleElimination(stage.id); - - return this.getOrderedRoundsDoubleElimination(stage.id); - } + /* ------------------------------------------------------------------ */ + /* Updater (base/updater.ts) */ + /* ------------------------------------------------------------------ */ /** - * Gets all the rounds that contain ordered participants in a single elimination stage. + * Updates the matches related (previous and next) to a match. * - * @param stageId ID of the stage. + * @param match A match. + * @param updatePrevious Whether to update the previous matches. + * @param updateNext Whether to update the next matches. */ - private getOrderedRoundsSingleElimination(stageId: number): Round[] { - return [this.getUpperBracketFirstRound(stageId)]; - } - - /** - * Gets all the rounds that contain ordered participants in a double elimination stage. - * - * @param stageId ID of the stage. - */ - private getOrderedRoundsDoubleElimination(stageId: number): Round[] { - // Getting all rounds instead of cherry-picking them is the least expensive. - const rounds = this.storage.select("round", { stage_id: stageId }); - if (!rounds) throw Error("Error getting rounds."); - - const loserBracket = this.getLoserBracket(stageId); - if (!loserBracket) throw Error("Loser bracket not found."); - - const firstRoundWB = rounds[0]; - - const roundsLB = rounds.filter((r) => r.group_id === loserBracket.id); - const orderedRoundsLB = roundsLB.filter((r) => - helpers.isOrderingSupportedLoserBracket(r.number, roundsLB.length), + updateRelatedMatches( + match: MatchData, + updatePrevious: boolean, + updateNext: boolean, + ): void { + const { roundNumber, roundCount } = this.getRoundPositionalInfo( + match.round_id, ); - return [firstRoundWB, ...orderedRoundsLB]; + const stage = this.store.select("stage", match.stage_id); + if (!stage) throw Error("Stage not found."); + + const group = this.store.select("group", match.group_id); + if (!group) throw Error("Group not found."); + + const matchLocation = helpers.getMatchLocation(stage.type, group.number); + + updatePrevious && + this.updatePrevious(match, matchLocation, stage, roundNumber); + updateNext && + this.updateNext(match, matchLocation, stage, roundNumber, roundCount); } + /** + * Updates a match based on a partial match. + * + * @param stored A reference to what will be updated in the storage. + * @param match Input of the update. + * @param force Whether to force update locked matches. + */ + updateMatch( + stored: MatchData, + match: DeepPartial, + force?: boolean, + ): void { + if (!force && helpers.isMatchUpdateLocked(stored)) + throw Error("The match is locked."); + + const stage = this.store.select("stage", stored.stage_id); + if (!stage) throw Error("Stage not found."); + + const { statusChanged, resultChanged } = helpers.setMatchResults( + stored, + match, + ); + this.applyMatchUpdate(stored); + + // Don't update related matches if it's a simple score update. + if (!statusChanged && !resultChanged) return; + + if (!helpers.isRoundRobin(stage) && !helpers.isSwiss(stage)) { + this.updateRelatedMatches(stored, statusChanged, resultChanged); + } else if (helpers.isRoundRobin(stage) && resultChanged) { + this.unlockNextRoundRobinRound(stored); + } + } + + /** + * Updates the opponents and status of a match. + * + * @param match A match. + */ + applyMatchUpdate(match: MatchData): void { + this.store.updateMatch(match); + } + + /** + * Updates the match(es) leading to the current match based on this match results. + * + * @param match Input of the update. + * @param matchLocation Location of the current match. + * @param stage The parent stage. + * @param roundNumber Number of the round. + */ + private updatePrevious( + match: MatchData, + matchLocation: GroupType, + stage: StageData, + roundNumber: number, + ): void { + const previousMatches = this.getPreviousMatches( + match, + matchLocation, + stage, + roundNumber, + ); + if (previousMatches.length === 0) return; + + if (match.status < MatchStatus.Running) { + this.resetMatchesStatus(previousMatches); + } + } + + /** + * Resets the status of a list of matches to what it should currently be. + * + * @param matches The matches to update. + */ + private resetMatchesStatus(matches: MatchData[]): void { + for (const match of matches) { + match.status = helpers.getMatchStatus(match); + this.applyMatchUpdate(match); + } + } + + /** + * Updates the match(es) following the current match based on this match results. + * + * @param match Input of the update. + * @param matchLocation Location of the current match. + * @param stage The parent stage. + * @param roundNumber Number of the round. + * @param roundCount Count of rounds. + */ + private updateNext( + match: MatchData, + matchLocation: GroupType, + stage: StageData, + roundNumber: number, + roundCount: number, + ): void { + const nextMatches = this.getNextMatches( + match, + matchLocation, + stage, + roundNumber, + roundCount, + ); + if (nextMatches.length === 0) { + return; + } + + const winnerSide = helpers.getMatchResult(match); + const actualRoundNumber = + stage.settings.skipFirstRound && matchLocation === "winner_bracket" + ? roundNumber + 1 + : roundNumber; + + if (winnerSide) + this.applyToNextMatches( + helpers.setNextOpponent, + match, + matchLocation, + actualRoundNumber, + roundCount, + nextMatches, + winnerSide, + ); + else + this.applyToNextMatches( + helpers.resetNextOpponent, + match, + matchLocation, + actualRoundNumber, + roundCount, + nextMatches, + ); + } + + /** + * Applies a SetNextOpponent function to matches following the current match. + * + * @param setNextOpponent The SetNextOpponent function. + * @param match The current match. + * @param matchLocation Location of the current match. + * @param roundNumber Number of the current round. + * @param roundCount Count of rounds. + * @param nextMatches The matches following the current match. + * @param winnerSide Side of the winner in the current match. + */ + private applyToNextMatches( + setNextOpponent: SetNextOpponent, + match: MatchData, + matchLocation: GroupType, + roundNumber: number, + roundCount: number, + nextMatches: (MatchData | null)[], + winnerSide?: Side, + ): void { + if (matchLocation === "final_group") { + if (!nextMatches[0]) throw Error("First next match is null."); + setNextOpponent(nextMatches[0], "opponent1", match, "opponent1"); + setNextOpponent(nextMatches[0], "opponent2", match, "opponent2"); + this.applyMatchUpdate(nextMatches[0]); + return; + } + + const nextSide = helpers.getNextSide( + match.number, + roundNumber, + roundCount, + matchLocation, + ); + + if (nextMatches[0]) { + setNextOpponent(nextMatches[0], nextSide, match, winnerSide); + this.propagateByeWinners(nextMatches[0]); + } + + if (nextMatches.length !== 2) return; + if (!nextMatches[1]) throw Error("Second next match is null."); + + // The second match is either the consolation final (single elimination) or a loser bracket match (double elimination). + + if (matchLocation === "single_bracket") { + setNextOpponent( + nextMatches[1], + nextSide, + match, + winnerSide && helpers.getOtherSide(winnerSide), + ); + this.applyMatchUpdate(nextMatches[1]); + } else { + const nextSideLB = helpers.getNextSideLoserBracket( + match.number, + nextMatches[1], + roundNumber, + ); + setNextOpponent( + nextMatches[1], + nextSideLB, + match, + winnerSide && helpers.getOtherSide(winnerSide), + ); + this.propagateByeWinners(nextMatches[1]); + } + } + + /** + * Propagates winner against BYEs in related matches. + * + * @param match The current match. + */ + propagateByeWinners(match: MatchData): void { + helpers.setMatchResults(match, match); // BYE propagation is only in non round-robin stages. + this.applyMatchUpdate(match); + + if (helpers.hasBye(match)) this.updateRelatedMatches(match, true, true); + } + + /** + * Unlocks matches in the next round of a round-robin group if both participants are ready. + * + * @param match The match that was just completed. + */ + private unlockNextRoundRobinRound(match: MatchData): void { + const round = this.store.select("round", match.round_id); + if (!round) throw Error("Round not found."); + + const nextRound = this.store.selectFirst("round", { + group_id: round.group_id, + number: round.number + 1, + }); + if (!nextRound) return; + + const currentRoundMatches = this.store.selectAll("match", { + round_id: round.id, + }); + + const nextRoundMatches = this.store.selectAll("match", { + round_id: nextRound.id, + }); + + for (const nextMatch of nextRoundMatches) { + if (nextMatch.status !== MatchStatus.Locked) continue; + + const participant1Id = nextMatch.opponent1?.id; + const participant2Id = nextMatch.opponent2?.id; + + if (!participant1Id || !participant2Id) continue; + + const participant1Ready = this.isParticipantReadyForNextRound( + participant1Id, + currentRoundMatches, + ); + const participant2Ready = this.isParticipantReadyForNextRound( + participant2Id, + currentRoundMatches, + ); + + if (participant1Ready && participant2Ready) { + nextMatch.status = MatchStatus.Ready; + this.applyMatchUpdate(nextMatch); + } + } + } + + /** + * Checks if a participant has completed their match in the current round. + * + * @param participantId The participant to check. + * @param roundMatches All matches in the round. + */ + private isParticipantReadyForNextRound( + participantId: number, + roundMatches: MatchData[], + ): boolean { + const participantMatch = roundMatches.find( + (m) => + m.opponent1?.id === participantId || m.opponent2?.id === participantId, + ); + + // If the participant doesn't have a match in this round, they had a bye/didn't play + // and are considered ready + if (!participantMatch) return true; + + // If the match has a BYE (one opponent is null), it's considered completed + if (!participantMatch.opponent1?.id || !participantMatch.opponent2?.id) + return true; + + return participantMatch.status >= MatchStatus.Completed; + } + + /* ------------------------------------------------------------------ */ + /* Getter (base/getter.ts) */ + /* ------------------------------------------------------------------ */ + /** * Gets the positional information (number in group and total number of rounds in group) of a round based on its id. * * @param roundId ID of the round. */ - protected getRoundPositionalInfo(roundId: number): RoundPositionalInfo { - const round = this.storage.select("round", roundId); + getRoundPositionalInfo(roundId: number): RoundPositionalInfo { + const round = this.store.select("round", roundId); if (!round) throw Error("Round not found."); - const rounds = this.storage.select("round", { + const rounds = this.store.selectAll("round", { group_id: round.group_id, }); - if (!rounds) throw Error("Error getting rounds."); return { roundNumber: round.number, @@ -95,12 +385,12 @@ export class BaseGetter { * @param stage The parent stage. * @param roundNumber Number of the round. */ - protected getPreviousMatches( - match: Match, + getPreviousMatches( + match: MatchData, matchLocation: GroupType, - stage: Stage, + stage: StageData, roundNumber: number, - ): Match[] { + ): MatchData[] { if (matchLocation === "loser_bracket") return this.getPreviousMatchesLB(match, stage, roundNumber); @@ -120,10 +410,10 @@ export class BaseGetter { * @param roundNumber Number of the current round. */ private getPreviousMatchesFinal( - match: Match, - stage: Stage, + match: MatchData, + stage: StageData, roundNumber: number, - ): Match[] { + ): MatchData[] { if (stage.type === "single_elimination") return this.getPreviousMatchesFinalSingleElimination(match, stage); @@ -137,27 +427,25 @@ export class BaseGetter { * @param stage The parent stage. */ private getPreviousMatchesFinalSingleElimination( - match: Match, - stage: Stage, - ): Match[] { + match: MatchData, + stage: StageData, + ): MatchData[] { const upperBracket = this.getUpperBracket(match.stage_id); const upperBracketRoundCount = helpers.getUpperBracketRoundCount( stage.settings.size!, ); - const semiFinalsRound = this.storage.selectFirst("round", { + const semiFinalsRound = this.store.selectFirst("round", { group_id: upperBracket.id, number: upperBracketRoundCount - 1, // Second to last round }); if (!semiFinalsRound) throw Error("Semi finals round not found."); - const semiFinalMatches = this.storage.select("match", { + const semiFinalMatches = this.store.selectAll("match", { round_id: semiFinalsRound.id, }); - if (!semiFinalMatches) throw Error("Error getting semi final matches."); - // In single elimination, both the final and consolation final have the same previous matches. return semiFinalMatches; } @@ -166,13 +454,12 @@ export class BaseGetter { * Gets the matches leading to the given match, which is in a final group (grand final). * * @param match The current match. - * @param stage The parent stage. * @param roundNumber Number of the current round. */ private getPreviousMatchesFinalDoubleElimination( - match: Match, + match: MatchData, roundNumber: number, - ): Match[] { + ): MatchData[] { if (roundNumber > 1) // Double grand final return [this.findMatch(match.group_id, roundNumber - 1, 1)]; @@ -180,7 +467,7 @@ export class BaseGetter { const winnerBracket = this.getUpperBracket(match.stage_id); const lastRoundWB = this.getLastRound(winnerBracket.id); - const winnerBracketFinalMatch = this.storage.selectFirst("match", { + const winnerBracketFinalMatch = this.store.selectFirst("match", { round_id: lastRoundWB.id, number: 1, }); @@ -191,7 +478,7 @@ export class BaseGetter { if (!loserBracket) throw Error("Loser bracket not found."); const lastRoundLB = this.getLastRound(loserBracket.id); - const loserBracketFinalMatch = this.storage.selectFirst("match", { + const loserBracketFinalMatch = this.store.selectFirst("match", { round_id: lastRoundLB.id, number: 1, }); @@ -209,10 +496,10 @@ export class BaseGetter { * @param roundNumber Number of the round. */ private getPreviousMatchesLB( - match: Match, - stage: Stage, + match: MatchData, + stage: StageData, roundNumber: number, - ): Match[] { + ): MatchData[] { if (stage.settings.skipFirstRound && roundNumber === 1) return []; if (helpers.hasBye(match)) return []; // Shortcut because we are coming from propagateByes(). @@ -249,9 +536,9 @@ export class BaseGetter { * @param roundNumber Number of the round. */ private getMatchesBeforeMajorRound( - match: Match, + match: MatchData, roundNumber: number, - ): Match[] { + ): MatchData[] { return [ this.findMatch(match.group_id, roundNumber - 1, match.number * 2 - 1), this.findMatch(match.group_id, roundNumber - 1, match.number * 2), @@ -266,10 +553,10 @@ export class BaseGetter { * @param roundNumberWB The number of the previous round in the winner bracket. */ private getMatchesBeforeFirstRoundLB( - match: Match, + match: MatchData, winnerBracketId: number, roundNumberWB: number, - ): Match[] { + ): MatchData[] { return [ this.findMatch( winnerBracketId, @@ -293,11 +580,11 @@ export class BaseGetter { * @param roundNumberWB The number of the previous round in the winner bracket. */ private getMatchesBeforeMinorRoundLB( - match: Match, + match: MatchData, winnerBracketId: number, roundNumber: number, roundNumberWB: number, - ): Match[] { + ): MatchData[] { const matchNumber = helpers.getOriginPosition(match, "opponent1"); return [ @@ -315,13 +602,13 @@ export class BaseGetter { * @param roundNumber The number of the current round. * @param roundCount Count of rounds. */ - protected getNextMatches( - match: Match, + getNextMatches( + match: MatchData, matchLocation: GroupType, - stage: Stage, + stage: StageData, roundNumber: number, roundCount: number, - ): (Match | null)[] { + ): (MatchData | null)[] { switch (matchLocation) { case "single_bracket": return this.getNextMatchesUpperBracket( @@ -355,11 +642,11 @@ export class BaseGetter { * @param roundCount Count of rounds. */ private getNextMatchesWB( - match: Match, - stage: Stage, + match: MatchData, + stage: StageData, roundNumber: number, roundCount: number, - ): (Match | null)[] { + ): (MatchData | null)[] { const loserBracket = this.getLoserBracket(match.stage_id); if (loserBracket === null) // Only one match in the stage, there is no loser bracket. @@ -403,11 +690,11 @@ export class BaseGetter { * @param roundCount Count of rounds. */ private getNextMatchesUpperBracket( - match: Match, + match: MatchData, stageType: StageType, roundNumber: number, roundCount: number, - ): (Match | null)[] { + ): (MatchData | null)[] { if (stageType === "single_elimination") return this.getNextMatchesUpperBracketSingleElimination( match, @@ -431,11 +718,11 @@ export class BaseGetter { * @param roundCount Count of rounds. */ private getNextMatchesUpperBracketSingleElimination( - match: Match, + match: MatchData, stageType: StageType, roundNumber: number, roundCount: number, - ): Match[] { + ): MatchData[] { if (roundNumber === roundCount - 1) { const final = this.getFirstMatchFinal(match, stageType); return [ @@ -458,11 +745,11 @@ export class BaseGetter { * @param roundCount Count of rounds. */ private getNextMatchesLB( - match: Match, + match: MatchData, stageType: StageType, roundNumber: number, roundCount: number, - ): Match[] { + ): MatchData[] { if (roundNumber === roundCount) { const final = this.getFirstMatchFinal(match, stageType); return final ? [final] : []; @@ -480,7 +767,10 @@ export class BaseGetter { * @param match The current match. * @param stageType Type of the stage. */ - private getFirstMatchFinal(match: Match, stageType: StageType): Match | null { + private getFirstMatchFinal( + match: MatchData, + stageType: StageType, + ): MatchData | null { const finalGroupId = this.getFinalGroupId(match.stage_id, stageType); if (finalGroupId === null) return null; @@ -495,10 +785,10 @@ export class BaseGetter { * @param roundCount The count of rounds. */ private getNextMatchesFinal( - match: Match, + match: MatchData, roundNumber: number, roundCount: number, - ): Match[] { + ): MatchData[] { if ( roundNumber === roundCount || // avoid putting teams to bracket reset if tournament is over @@ -517,9 +807,9 @@ export class BaseGetter { * @param roundNumber The number of the current round. */ private getMatchAfterMajorRoundLB( - match: Match, + match: MatchData, roundNumber: number, - ): Match[] { + ): MatchData[] { return [this.getParallelMatch(match.group_id, roundNumber, match.number)]; } @@ -530,34 +820,19 @@ export class BaseGetter { * @param roundNumber The number of the current round. */ private getMatchAfterMinorRoundLB( - match: Match, + match: MatchData, roundNumber: number, - ): Match[] { + ): MatchData[] { return [this.getDiagonalMatch(match.group_id, roundNumber, match.number)]; } - /** - * Gets the first round of the upper bracket. - * - * @param stageId ID of the stage. - */ - private getUpperBracketFirstRound(stageId: number): Round { - // Considering the database is ordered, this round will always be the first round of the upper bracket. - const firstRound = this.storage.selectFirst("round", { - stage_id: stageId, - number: 1, - }); - if (!firstRound) throw Error("Round not found."); - return firstRound; - } - /** * Gets the last round of a group. * * @param groupId ID of the group. */ - private getLastRound(groupId: number): Round { - const round = this.storage.selectLast("round", { group_id: groupId }); + private getLastRound(groupId: number): RoundData { + const round = this.store.selectLast("round", { group_id: groupId }); if (!round) throw Error("Error getting rounds."); return round; } @@ -576,7 +851,7 @@ export class BaseGetter { stageType === "single_elimination" ? 2 /* Consolation final */ : 3; /* Grand final */ - const finalGroup = this.storage.selectFirst("group", { + const finalGroup = this.store.selectFirst("group", { stage_id: stageId, number: groupNumber, }); @@ -589,8 +864,8 @@ export class BaseGetter { * * @param stageId ID of the stage. */ - protected getUpperBracket(stageId: number): Group { - const winnerBracket = this.storage.selectFirst("group", { + private getUpperBracket(stageId: number): GroupData { + const winnerBracket = this.store.selectFirst("group", { stage_id: stageId, number: 1, }); @@ -603,8 +878,8 @@ export class BaseGetter { * * @param stageId ID of the stage. */ - protected getLoserBracket(stageId: number): Group | null { - return this.storage.selectFirst("group", { stage_id: stageId, number: 2 }); + private getLoserBracket(stageId: number): GroupData | null { + return this.store.selectFirst("group", { stage_id: stageId, number: 2 }); } /** @@ -620,7 +895,7 @@ export class BaseGetter { groupId: number, roundNumber: number, matchNumber: number, - ): Match { + ): MatchData { return this.findMatch( groupId, roundNumber + 1, @@ -641,32 +916,30 @@ export class BaseGetter { groupId: number, roundNumber: number, matchNumber: number, - ): Match { + ): MatchData { return this.findMatch(groupId, roundNumber + 1, matchNumber); } /** * Finds a match in a given group. The match must have the given number in a round of which the number in group is given. * - * **Example:** In group of id 1, give me the 4th match in the 3rd round. - * * @param groupId ID of the group. * @param roundNumber Number of the round in its parent group. * @param matchNumber Number of the match in its parent round. */ - protected findMatch( + findMatch( groupId: number, roundNumber: number, matchNumber: number, - ): Match { - const round = this.storage.selectFirst("round", { + ): MatchData { + const round = this.store.selectFirst("round", { group_id: groupId, number: roundNumber, }); if (!round) throw Error("Round not found."); - const match = this.storage.selectFirst("match", { + const match = this.store.selectFirst("match", { round_id: round.id, number: matchNumber, }); diff --git a/app/modules/brackets-manager/test/update.test.ts b/app/features/tournament-bracket/core/engine/propagation/update.test.ts similarity index 56% rename from app/modules/brackets-manager/test/update.test.ts rename to app/features/tournament-bracket/core/engine/propagation/update.test.ts index 5dce89d92..3692fdbe3 100644 --- a/app/modules/brackets-manager/test/update.test.ts +++ b/app/features/tournament-bracket/core/engine/propagation/update.test.ts @@ -1,194 +1,188 @@ import { beforeEach, describe, expect, test } from "vitest"; import { TournamentMatchStatus } from "~/db/tables"; -import { InMemoryDatabase } from "~/modules/brackets-memory-db"; -import { BracketsManager } from "../manager"; +import { EngineBracket } from "../test-utils"; -const storage = new InMemoryDatabase(); -const manager = new BracketsManager(storage); +const bracket = new EngineBracket(); const example = { name: "Amateur", tournamentId: 0, - type: "double_elimination", + type: "double_elimination" as const, seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], - settings: { seedOrdering: ["natural"] }, -} as any; + settings: { seedOrdering: ["natural" as const] }, +}; describe("Update matches", () => { beforeEach(() => { - storage.reset(); - manager.create(example); + bracket.reset(); + bracket.create(example); }); test("should start a match", () => { - const before = storage.select("match", 0); + const before = bracket.match(0); expect(before.status).toBe(TournamentMatchStatus.Ready); - manager.update.match({ + bracket.updateMatch({ id: 0, opponent1: { score: 0 }, opponent2: { score: 0 }, }); - const after = storage.select("match", 0); + const after = bracket.match(0); expect(after.status).toBe(TournamentMatchStatus.Running); }); test("should update the scores for a match and set it to running", () => { - manager.update.match({ + bracket.updateMatch({ id: 0, opponent1: { score: 2 }, opponent2: { score: 1 }, }); - const after = storage.select("match", 0); + const after = bracket.match(0); expect(after.status).toBe(TournamentMatchStatus.Running); - expect(after.opponent1.score).toBe(2); + expect(after.opponent1?.score).toBe(2); - // Name should stay. It shouldn't be overwritten. - expect(after.opponent1.id).toBe(1); + // Id should stay. It shouldn't be overwritten. + expect(after.opponent1?.id).toBe(1); }); test("should end the match by only setting the winner", () => { - const before = storage.select("match", 0); - expect(before.opponent1.result).toBeFalsy(); + const before = bracket.match(0); + expect(before.opponent1?.result).toBeFalsy(); - manager.update.match({ + bracket.updateMatch({ id: 0, opponent1: { result: "win" }, }); - const after = storage.select("match", 0); + const after = bracket.match(0); expect(after.status).toBe(TournamentMatchStatus.Completed); - expect(after.opponent1.result).toBe("win"); - expect(after.opponent2.result).toBe("loss"); + expect(after.opponent1?.result).toBe("win"); + expect(after.opponent2?.result).toBe("loss"); }); test("should change the winner of the match and update in the next match", () => { - manager.update.match({ + bracket.updateMatch({ id: 0, opponent1: { result: "win" }, }); - expect(storage.select("match", 8).opponent1.id).toBe(1); + expect(bracket.match(8).opponent1?.id).toBe(1); - manager.update.match({ + bracket.updateMatch({ id: 0, opponent2: { result: "win" }, }); - const after = storage.select("match", 0); + const after = bracket.match(0); expect(after.status).toBe(TournamentMatchStatus.Completed); - expect(after.opponent1.result).toBe("loss"); - expect(after.opponent2.result).toBe("win"); + expect(after.opponent1?.result).toBe("loss"); + expect(after.opponent2?.result).toBe("win"); - const nextMatch = storage.select("match", 8); + const nextMatch = bracket.match(8); expect(nextMatch.status).toBe(TournamentMatchStatus.Waiting); - expect(nextMatch.opponent1.id).toBe(2); + expect(nextMatch.opponent1?.id).toBe(2); }); test("should update the status of the next match", () => { - manager.update.match({ + bracket.updateMatch({ id: 0, opponent1: { result: "win" }, }); - expect(storage.select("match", 8).status).toBe( - TournamentMatchStatus.Waiting, - ); + expect(bracket.match(8).status).toBe(TournamentMatchStatus.Waiting); - manager.update.match({ + bracket.updateMatch({ id: 1, opponent1: { result: "win" }, }); - expect(storage.select("match", 8).status).toBe( - TournamentMatchStatus.Ready, - ); + expect(bracket.match(8).status).toBe(TournamentMatchStatus.Ready); }); test("should end the match by setting winner and loser", () => { - manager.update.match({ + bracket.updateMatch({ id: 0, status: TournamentMatchStatus.Running, }); - manager.update.match({ + bracket.updateMatch({ id: 0, opponent1: { result: "win" }, opponent2: { result: "loss" }, }); - const after = storage.select("match", 0); + const after = bracket.match(0); expect(after.status).toBe(TournamentMatchStatus.Completed); - expect(after.opponent1.result).toBe("win"); - expect(after.opponent2.result).toBe("loss"); + expect(after.opponent1?.result).toBe("win"); + expect(after.opponent2?.result).toBe("loss"); }); test("should remove results from a match without score", () => { - manager.update.match({ + bracket.updateMatch({ id: 0, opponent1: { result: "win" }, opponent2: { result: "loss" }, }); - manager.reset.matchResults(0); + bracket.resetMatchResults(0); - const after = storage.select("match", 0); + const after = bracket.match(0); expect(after.status).toBe(TournamentMatchStatus.Ready); - expect(after.opponent1.result).toBeFalsy(); - expect(after.opponent2.result).toBeFalsy(); + expect(after.opponent1?.result).toBeFalsy(); + expect(after.opponent2?.result).toBeFalsy(); }); test("should remove results from a match with score", () => { - manager.update.match({ + bracket.updateMatch({ id: 0, opponent1: { score: 16, result: "win" }, opponent2: { score: 12, result: "loss" }, }); - manager.reset.matchResults(0); + bracket.resetMatchResults(0); - const after = storage.select("match", 0); + const after = bracket.match(0); expect(after.status).toBe(TournamentMatchStatus.Running); - expect(after.opponent1.result).toBeFalsy(); - expect(after.opponent2.result).toBeFalsy(); + expect(after.opponent1?.result).toBeFalsy(); + expect(after.opponent2?.result).toBeFalsy(); }); test("should not set the other score to 0 if only one given", () => { // It shouldn't be our decision to set the other score to 0. - manager.update.match({ + bracket.updateMatch({ id: 1, opponent1: { score: 1 }, }); - const after = storage.select("match", 1); + const after = bracket.match(1); expect(after.status).toBe(TournamentMatchStatus.Running); - expect(after.opponent1.score).toBe(1); - expect(after.opponent2.score).toBeFalsy(); + expect(after.opponent1?.score).toBe(1); + expect(after.opponent2?.score).toBeFalsy(); }); test("should end the match by setting the winner and the scores", () => { - manager.update.match({ + bracket.updateMatch({ id: 1, opponent1: { score: 6 }, opponent2: { result: "win", score: 3 }, }); - const after = storage.select("match", 1); + const after = bracket.match(1); expect(after.status).toBe(TournamentMatchStatus.Completed); - expect(after.opponent1.result).toBe("loss"); - expect(after.opponent1.score).toBe(6); + expect(after.opponent1?.result).toBe("loss"); + expect(after.opponent1?.score).toBe(6); - expect(after.opponent2.result).toBe("win"); - expect(after.opponent2.score).toBe(3); + expect(after.opponent2?.result).toBe("win"); + expect(after.opponent2?.score).toBe(3); }); test("should throw if two winners", () => { expect(() => - manager.update.match({ + bracket.updateMatch({ id: 3, opponent1: { result: "win" }, opponent2: { result: "win" }, @@ -196,7 +190,7 @@ describe("Update matches", () => { ).toThrow("There are two winners."); expect(() => - manager.update.match({ + bracket.updateMatch({ id: 3, opponent1: { result: "loss" }, opponent2: { result: "loss" }, @@ -207,9 +201,9 @@ describe("Update matches", () => { describe("Give opponent IDs when updating", () => { beforeEach(() => { - storage.reset(); + bracket.reset(); - manager.create({ + bracket.create({ name: "Amateur", tournamentId: 0, type: "double_elimination", @@ -219,7 +213,7 @@ describe("Give opponent IDs when updating", () => { }); test("should update the right opponents based on their IDs", () => { - manager.update.match({ + bracket.updateMatch({ id: 0, opponent1: { id: 2, @@ -232,13 +226,13 @@ describe("Give opponent IDs when updating", () => { }); // Actual results must be inverted. - const after = storage.select("match", 0); - expect(after.opponent1.score).toBe(5); - expect(after.opponent2.score).toBe(10); + const after = bracket.match(0); + expect(after.opponent1?.score).toBe(5); + expect(after.opponent2?.score).toBe(10); }); test("should update the right opponent based on its ID, the other one is the remaining one", () => { - manager.update.match({ + bracket.updateMatch({ id: 0, opponent1: { id: 2, @@ -247,14 +241,14 @@ describe("Give opponent IDs when updating", () => { }); // Actual results must be inverted. - const after = storage.select("match", 0); - expect(after.opponent1.score).toBeFalsy(); - expect(after.opponent2.score).toBe(10); + const after = bracket.match(0); + expect(after.opponent1?.score).toBeFalsy(); + expect(after.opponent2?.score).toBe(10); }); test("should throw when the given opponent ID does not exist in the match", () => { expect(() => - manager.update.match({ + bracket.updateMatch({ id: 0, opponent1: { id: 3, // Belongs to match id 1. @@ -267,22 +261,22 @@ describe("Give opponent IDs when updating", () => { describe("Locked matches", () => { beforeEach(() => { - storage.reset(); - manager.create(example); + bracket.reset(); + bracket.create(example); }); test("should throw when the matches leading to the match have not been completed yet", () => { - manager.update.match({ id: 0 }); // No problem when no previous match. - expect(() => manager.update.match({ id: 8 })).toThrow( + bracket.updateMatch({ id: 0 }); // No problem when no previous match. + expect(() => bracket.updateMatch({ id: 8 })).toThrow( "The match is locked.", ); // First match of WB Round 2. - expect(() => manager.update.match({ id: 15 })).toThrow( + expect(() => bracket.updateMatch({ id: 15 })).toThrow( "The match is locked.", ); // First match of LB Round 1. - expect(() => manager.update.match({ id: 19 })).toThrow( + expect(() => bracket.updateMatch({ id: 19 })).toThrow( "The match is locked.", ); // First match of LB Round 1. - expect(() => manager.update.match({ id: 23 })).toThrow( + expect(() => bracket.updateMatch({ id: 23 })).toThrow( "The match is locked.", ); // First match of LB Round 3. }); diff --git a/app/features/tournament-bracket/core/engine/swiss/pairing.ts b/app/features/tournament-bracket/core/engine/swiss/pairing.ts new file mode 100644 index 000000000..9dafe5d35 --- /dev/null +++ b/app/features/tournament-bracket/core/engine/swiss/pairing.ts @@ -0,0 +1,296 @@ +import blossom from "edmonds-blossom-fixed"; +import { err, ok, type Result } from "neverthrow"; +import * as R from "remeda"; +import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import invariant from "~/utils/invariant"; +import type { + BracketData, + GeneratedRound, + MatchData, + SwissStanding, +} from "../types"; +import { calculateTeamStatus } from "./team-status"; + +/** + * Generates the next round of matchups for a Swiss tournament bracket within a specific group. + * + * Considers only the matches and teams within the specified group. Teams that have dropped out are excluded from the pairing process. + * If the group has an uneven number of teams, the lowest standing team that has not already received a bye is preferred to receive one. + * Matches are generated such that teams do not replay previous opponents if possible. + */ +export function generateRound( + data: BracketData, + args: { + groupId: number; + standings: SwissStanding[]; + settings: { advanceThreshold?: number; roundCount?: number } | null; + }, +): Result { + // lets consider only this groups matches + // in the case that there are more than one group + const groupsMatches = data.match.filter((m) => m.group_id === args.groupId); + + if (groupsMatches.length === 0) return err("No matches found for group"); + if (data.stage[0]?.type !== "swiss") return err("Bracket is not Swiss type"); + + // new matches can't be generated till old are over + if (!everyMatchOver(groupsMatches)) { + return err("Not all matches are over"); + } + + const groupsTeams = groupsMatches + .flatMap((match) => [match.opponent1, match.opponent2]) + .filter(Boolean); + const groupsStandings = args.standings.filter((standing) => { + return groupsTeams.some((team) => team?.id === standing.team.id); + }); + + // teams who have dropped out are not considered + let standingsWithoutDropouts = groupsStandings.filter( + (s) => !s.team.droppedOut, + ); + + // filter out teams that have advanced or been eliminated if early advance/elimination is enabled + if (typeof args.settings?.advanceThreshold === "number") { + const roundCount = + args.settings.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT; + const advanceThreshold = args.settings.advanceThreshold; + + standingsWithoutDropouts = standingsWithoutDropouts.filter((standing) => { + const wins = standing.stats?.setWins ?? 0; + const losses = standing.stats?.setLosses ?? 0; + const status = calculateTeamStatus({ + wins, + losses, + advanceThreshold, + roundCount, + }); + + return status === "active"; + }); + } + + // if there are fewer than 2 active teams, no more matches can be generated + if (standingsWithoutDropouts.length < 2) { + return err("Not enough active teams to generate matches"); + } + + const teamsThatHaveHadByes = groupsMatches + .filter((m) => m.opponent2 === null) + .map((m) => m.opponent1?.id); + + const pairs = pairUp( + standingsWithoutDropouts.map((standing) => ({ + id: standing.team.id, + score: standing.stats?.setWins ?? 0, + receivedBye: teamsThatHaveHadByes.includes(standing.team.id), + avoid: groupsMatches.flatMap((match) => { + if (match.opponent1?.id === standing.team.id) { + return match.opponent2?.id ? [match.opponent2.id] : []; + } + if (match.opponent2?.id === standing.team.id) { + return match.opponent1?.id ? [match.opponent1.id] : []; + } + return []; + }), + })), + ); + + let matchNumber = 1; + const newRoundId = data.round + .slice() + .sort((a, b) => a.id - b.id) + .filter((r) => r.group_id === args.groupId) + .find( + (r) => r.id > Math.max(...groupsMatches.map((match) => match.round_id)), + )?.id; + invariant(newRoundId, "newRoundId not found"); + + return ok({ + groupId: args.groupId, + roundId: newRoundId, + matches: pairs.map(({ opponentOne, opponentTwo }) => ({ + number: matchNumber++, + opponent1: { id: opponentOne }, + opponent2: typeof opponentTwo === "number" ? { id: opponentTwo } : null, + })), + }); +} + +function everyMatchOver(matches: MatchData[]) { + for (const match of matches) { + // bye + if (!match.opponent1 || !match.opponent2) continue; + + if (match.opponent1.result !== "win" && match.opponent2.result !== "win") { + return false; + } + } + + return true; +} + +interface SwissPairingTeam { + id: number; + /** How many matches has the team won */ + score: number; + /** List of tournament team ids this team already played */ + avoid: Array; + receivedBye?: boolean; +} + +/** + * Pairs up teams for a swiss round using maximum weighted matching, avoiding + * rematches if possible and preferring teams with equal scores to play each other. + * + * Adapted from https://github.com/slashinfty/tournament-pairings + */ +export function pairUp(players: SwissPairingTeam[]) { + if (players.length < 2) { + throw new Error("Need at least two players to pair up"); + } + if (players.length === 2) { + return [{ opponentOne: players[0].id, opponentTwo: players[1].id }]; + } + + // uncomment to add a new test case to PAIR_UP_TEST_CASES + // console.log(players); + + const matches = []; + const playerArray = R.shuffle(players).map((p, i) => ({ ...p, index: i })); + const scoreGroups = [...new Set(playerArray.map((p) => p.score))].sort( + (a, b) => a - b, + ); + const scoreSums = [ + ...new Set( + scoreGroups.flatMap((s, i, a) => { + const sums = []; + for (let j = i; j < a.length; j++) { + sums.push(s + a[j]); + } + return sums; + }), + ), + ].sort((a, b) => a - b); + + let pairs = generateWeightedPairs({ playerArray, scoreGroups, scoreSums }); + if (pairs.length === 0) { + // no possible pairs without rematches, try again allowing rematches + pairs = generateWeightedPairs({ + playerArray, + scoreGroups, + scoreSums, + considerAvoid: false, + }); + } + + const blossomPairs = blossom(pairs, true); + const playerCopy = [...playerArray]; + let byeArray = []; + do { + const indexA = playerCopy[0].index; + const indexB = blossomPairs[indexA]; + if (indexB === -1) { + byeArray.push(playerCopy.splice(0, 1)[0]); + continue; + } + playerCopy.splice(0, 1); + playerCopy.splice( + playerCopy.findIndex((p) => p.index === indexB), + 1, + ); + const playerA = playerArray.find((p) => p.index === indexA); + const playerB = playerArray.find((p) => p.index === indexB); + invariant(playerA, "Player A not found"); + invariant(playerB, "Player B not found"); + + matches.push({ + opponentOne: playerA.id, + opponentTwo: playerB.id, + }); + } while ( + playerCopy.length > + blossomPairs.reduce( + (sum: number, idx: number) => (idx === -1 ? sum + 1 : sum), + 0, + ) + ); + byeArray = [...byeArray, ...playerCopy]; + for (let i = 0; i < byeArray.length; i++) { + matches.push({ + opponentOne: byeArray[i].id, + opponentTwo: null, + }); + } + + return matches; +} + +function generateWeightedPairs({ + playerArray, + scoreGroups, + scoreSums, + considerAvoid = true, +}: { + playerArray: (SwissPairingTeam & { index: number })[]; + scoreGroups: number[]; + scoreSums: number[]; + considerAvoid?: boolean; +}) { + const pairs: [number, number, number][] = []; + for (let i = 0; i < playerArray.length; i++) { + const curr = playerArray[i]; + const next = playerArray.slice(i + 1); + for (let j = 0; j < next.length; j++) { + const opp = next[j]; + if ( + considerAvoid && + Object.hasOwn(curr, "avoid") && + curr.avoid.includes(opp.id) + ) { + continue; + } + let wt = + 75 - 75 / (scoreGroups.indexOf(Math.min(curr.score, opp.score)) + 2); + wt += + 5 - 5 / (scoreSums.findIndex((s) => s === curr.score + opp.score) + 1); + const scoreGroupDiff = Math.abs( + scoreGroups.indexOf(curr.score) - scoreGroups.indexOf(opp.score), + ); + + // TODO: consider "pairedUpDown" + // if ( + // scoreGroupDiff === 1 && + // curr.hasOwnProperty("pairedUpDown") && + // curr.pairedUpDown === false && + // opp.hasOwnProperty("pairedUpDown") && + // opp.pairedUpDown === false + // ) { + // scoreGroupDiff -= 0.65; + // } else if ( + // scoreGroupDiff > 0 && + // ((curr.hasOwnProperty("pairedUpDown") && curr.pairedUpDown === true) || + // (opp.hasOwnProperty("pairedUpDown") && opp.pairedUpDown === true)) + // ) { + // scoreGroupDiff += 0.2; + // } + + wt += 23 / (2 * (scoreGroupDiff + 2)); + + // Lower weight for larger score differences, we really want to avoid 2-0 playing 0-2 etc. + if (scoreGroupDiff >= 2) { + wt -= 10; + } + + if ( + (Object.hasOwn(curr, "receivedBye") && curr.receivedBye) || + (Object.hasOwn(opp, "receivedBye") && opp.receivedBye) + ) { + wt += 40; + } + pairs.push([curr.index, opp.index, wt]); + } + } + + return pairs; +} diff --git a/app/features/tournament-bracket/core/engine/swiss/team-status.ts b/app/features/tournament-bracket/core/engine/swiss/team-status.ts new file mode 100644 index 000000000..d5062930c --- /dev/null +++ b/app/features/tournament-bracket/core/engine/swiss/team-status.ts @@ -0,0 +1,94 @@ +export type SwissTeamStatus = "active" | "advanced" | "eliminated"; + +/** + * Calculates whether a team should advance, be eliminated, or remain active + * in a Swiss tournament with early advance/elimination rules. + * + * @returns The team's status: "advanced" if they've secured advancement, + * "eliminated" if they can no longer mathematically advance, or "active" if still competing + * + * @example + * // In a 5-round Swiss where teams need 3 wins to advance: + * calculateTeamStatus({ wins: 3, losses: 1, advanceThreshold: 3, roundCount: 5 }) // "advanced" + * calculateTeamStatus({ wins: 2, losses: 3, advanceThreshold: 3, roundCount: 5 }) // "eliminated" + * calculateTeamStatus({ wins: 2, losses: 2, advanceThreshold: 3, roundCount: 5 }) // "active" + */ +export function calculateTeamStatus({ + wins, + losses, + advanceThreshold, + roundCount, +}: { + /** Number of matches the team has won */ + wins: number; + /** Number of matches the team has lost */ + losses: number; + /** Number of wins required to advance to the next stage */ + advanceThreshold: number; + /** Total number of rounds in the Swiss stage */ + roundCount: number; +}): SwissTeamStatus { + if (wins >= advanceThreshold) { + return "advanced"; + } + + if (losses >= eliminationThreshold({ roundCount, advanceThreshold })) { + return "eliminated"; + } + + return "active"; +} + +/** + * Calculates the maximum valid advance threshold for a given round count. + * The threshold must allow for meaningful play - teams need a chance to both advance and be eliminated. + */ +export function maxAdvanceThreshold({ roundCount }: { roundCount: number }) { + return Math.ceil(roundCount / 2) + 1; +} + +/** + * Calculates the maximum losses allowed before elimination given an advance threshold and round count. + */ +export function eliminationThreshold({ + roundCount, + advanceThreshold, +}: { + roundCount: number; + advanceThreshold: number; +}) { + return roundCount - advanceThreshold + 1; +} + +/** + * Validates if an advance threshold is valid for the given round count. + */ +export function isValidAdvanceThreshold({ + roundCount, + advanceThreshold, +}: { + roundCount: number; + advanceThreshold: number; +}) { + return validAdvanceThresholdOptions({ roundCount }).includes( + advanceThreshold, + ); +} + +/** + * Returns a list of valid advance threshold options for a given round count. + * Starts from 2 wins minimum up to the calculated maximum. + */ +export function validAdvanceThresholdOptions({ + roundCount, +}: { + roundCount: number; +}) { + const result: number[] = []; + + for (let i = 2; i <= maxAdvanceThreshold({ roundCount }); i++) { + result.push(i); + } + + return result; +} diff --git a/app/features/tournament-bracket/core/engine/test-utils.ts b/app/features/tournament-bracket/core/engine/test-utils.ts new file mode 100644 index 000000000..ea31756bd --- /dev/null +++ b/app/features/tournament-bracket/core/engine/test-utils.ts @@ -0,0 +1,144 @@ +// Test-only harness giving the pure engine a stateful surface similar to the +// old BracketsManager, so the vendored test suite could be ported 1:1. + +import * as Engine from "./index"; +import type { + BracketData, + CreateBracketInput, + GroupData, + MatchData, + ParticipantResult, + RoundData, + StageData, +} from "./types"; + +interface UpdateMatchInput { + id: number; + opponent1?: Partial; + opponent2?: Partial; + status?: number; +} + +export class EngineBracket { + data: BracketData | undefined; + + create( + input: Omit & + Partial>, + ): void { + const created = Engine.create({ ...input, settings: input.settings ?? {} }); + + if (!this.data) { + this.data = created; + return; + } + + // stack another stage into the same data set, offsetting the local ids + // the same way the old library's storage assigned continuing ids + const offsets = { + stage: this.data.stage.length, + group: this.data.group.length, + round: this.data.round.length, + match: this.data.match.length, + }; + + this.data = { + stage: [ + ...this.data.stage, + ...created.stage.map((stage) => ({ + ...stage, + id: stage.id + offsets.stage, + number: input.number ?? offsets.stage + 1, + })), + ], + group: [ + ...this.data.group, + ...created.group.map((group) => ({ + ...group, + id: group.id + offsets.group, + stage_id: group.stage_id + offsets.stage, + })), + ], + round: [ + ...this.data.round, + ...created.round.map((round) => ({ + ...round, + id: round.id + offsets.round, + stage_id: round.stage_id + offsets.stage, + group_id: round.group_id + offsets.group, + })), + ], + match: [ + ...this.data.match, + ...created.match.map((match) => ({ + ...match, + id: match.id + offsets.match, + stage_id: match.stage_id + offsets.stage, + group_id: match.group_id + offsets.group, + round_id: match.round_id + offsets.round, + })), + ], + }; + } + + reset(): void { + this.data = undefined; + } + + updateMatch(input: UpdateMatchInput, force?: boolean): void { + this.data = Engine.reportResult(this.currentData(), { + matchId: input.id, + opponent1: input.opponent1, + opponent2: input.opponent2, + force, + }).data; + } + + resetMatchResults(matchId: number): void { + this.data = Engine.resetMatchResults(this.currentData(), matchId).data; + } + + stage(id = 0): StageData { + const stage = this.currentData().stage.find((s) => s.id === id); + if (!stage) throw Error(`Stage ${id} not found`); + return stage; + } + + groups(): GroupData[] { + return this.currentData().group; + } + + rounds(filter?: Partial): RoundData[] { + return applyFilter(this.currentData().round, filter); + } + + round(id: number): RoundData { + const round = this.currentData().round.find((r) => r.id === id); + if (!round) throw Error(`Round ${id} not found`); + return round; + } + + matches(filter?: Partial): MatchData[] { + return applyFilter(this.currentData().match, filter); + } + + match(id: number): MatchData { + const match = this.currentData().match.find((m) => m.id === id); + if (!match) throw Error(`Match ${id} not found`); + return match; + } + + private currentData(): BracketData { + if (!this.data) throw Error("No bracket created"); + return this.data; + } +} + +function applyFilter(rows: T[], filter?: Partial): T[] { + if (!filter) return rows; + + const entries = Object.entries(filter); + return rows.filter((row) => + entries.every(([key, value]) => row[key as keyof T] === value), + ); +} diff --git a/app/features/tournament-bracket/core/engine/types.ts b/app/features/tournament-bracket/core/engine/types.ts new file mode 100644 index 000000000..80e1a11e8 --- /dev/null +++ b/app/features/tournament-bracket/core/engine/types.ts @@ -0,0 +1,340 @@ +import type { Tables, TournamentRoundMaps } from "~/db/tables"; + +/** + * Match/set outcome for one side. Upstream brackets-model also had "draw" — + * intentionally dropped, draws are impossible in our formats. + */ +export type Result = "win" | "loss"; + +export type StageType = Tables["TournamentStage"]["type"]; + +/** + * All the possible types of group in an elimination stage. + * + * - `single_bracket` for single elimination. + * - `winner_bracket` and `loser_bracket` for double elimination. + * - `final_group` for both single and double elimination. + */ +export type GroupType = + | "single_bracket" + | "winner_bracket" + | "loser_bracket" + | "final_group"; + +// xxx: just simplify since we dont expose the option? +export type GrandFinalType = "none" | "simple" | "double"; + +// xxx: simplify because we don't have double? +export type RoundRobinMode = "simple" | "double"; + +// xxx: what can we delete here? +export type SeedOrdering = + | "natural" + | "reverse" + | "half_shift" + | "reverse_half_shift" + | "pair_flip" + | "inner_outer" + | "space_between" + | "groups.effort_balanced" + | "groups.seed_optimized" + | "groups.bracket_optimized"; + +/** The seeding for a stage. Each element is a participant id or a BYE: `null`. */ +export type Seeding = (number | null)[]; + +// xxx: are all of these really in use? +/** Same values as the old brackets-model Status — persisted in TournamentMatch.status. */ +export const MatchStatus = { + /** The two matches leading to this one are not completed yet. */ + Locked: 0, + /** One participant is ready and waiting for the other one. */ + Waiting: 1, + /** Both participants are ready to start. */ + Ready: 2, + /** The match is running. */ + Running: 3, + /** The match is completed. */ + Completed: 4, +} as const; +export type MatchStatus = (typeof MatchStatus)[keyof typeof MatchStatus]; + +/** + * The possible settings for a stage. Same shape as what is persisted in + * TournamentStage.settings today (the old brackets-model StageSettings). + */ +export interface StageSettings { + // xxx: why not inferred? + /** The number of participants. */ + size?: number; + + /** + * A list of ordering methods to apply to the seeding. + * + * - For a round-robin stage: 1 item required (**with** `"groups."` prefix). + * - For a simple elimination stage, 1 item required (**without** `"groups."` prefix). + * - For a double elimination stage, 1 item required, 3+ items supported (**without** `"groups."` prefix). + * - Item 1 (required) - Used to distribute in WB round 1. + * - Item 2 - Used to distribute WB losers in LB round 1. + * - Items 3+ - Used to distribute WB losers in LB minor rounds (1 per round). + */ + // xxx: make implementation detail? + seedOrdering?: SeedOrdering[]; + + /** Whether to balance BYEs in the seeding of an elimination stage. */ + // xxx: make implementation detail? + balanceByes?: boolean; + + /** Number of groups in a round-robin stage. */ + groupCount?: number; + + /** The mode for the round-robin stage. */ + roundRobinMode?: RoundRobinMode; + + /** + * Whether to generate a bipartite round-robin where teams are split into two + * A/B divisions and every match pairs one A team with one B team. + */ + hasAbDivisions?: boolean; + + /** A list of seeds per group for a round-robin stage to be manually ordered. */ + // xxx: delete? + manualOrdering?: number[][]; + + /** + * Whether matches in a round-robin stage are playable independently of each other. + * + * - If `false` (default), only round 1 matches start `Ready`; later rounds start + * `Locked` and unlock as both opponents complete the previous round. + * - If `true`, every match with two opponents starts `Ready`. + */ + independentRounds?: boolean; + + /** Optional final between semi-final losers. */ + consolationFinal?: boolean; + + /** Whether to skip the first round of the WB of a double elimination stage. */ + // xxx: delete? + skipFirstRound?: boolean; + + /** Optional grand final between WB and LB winners. */ + // xxx: delete? + grandFinal?: GrandFinalType; + + // xxx: just have roundCount and use groupCount from RR? + swiss?: { + groupCount: number; + roundCount: number; + }; +} + +export interface ParticipantResult { + /** `null` = to be determined (source match unfinished). */ + id: number | null; + + /** Seed position this slot was filled from. */ + position?: number; + + /** If this participant forfeits, the other automatically wins. */ + forfeit?: boolean; + + /** The current score of the participant. */ + score?: number; + + /** + * KO win count this set, aggregated on hydrate. Upstream's totalPoints is + * intentionally gone — scoring is KO-only. + */ + totalKos?: number; + + /** Tells what is the result of a duel for this participant. */ + result?: Result; +} + +/* ------------------------------------------------------------------ */ +/* Bracket data — identical shape to the old TournamentManagerDataSet */ +/* ------------------------------------------------------------------ */ + +export interface StageData { + id: number; + tournament_id: number; + name: string; + type: StageType; + settings: StageSettings; + number: number; + createdAt?: number | null; +} + +export interface GroupData { + id: number; + stage_id: number; + number: number; +} + +export interface RoundData { + id: number; + stage_id: number; + group_id: number; + number: number; + maps?: Pick | null; +} + +/** Only contains information about match status and results. */ +export interface MatchResults { + status: MatchStatus; + opponent1: ParticipantResult | null; + opponent2: ParticipantResult | null; +} + +export interface MatchData extends MatchResults { + id: number; + stage_id: number; + group_id: number; + round_id: number; + number: number; + startedAt?: number | null; +} + +/** + * The whole state of one tournament's brackets. This is the single value the + * engine reads and returns. Never mutated in place — every engine operation + * returns a new BracketData. + */ +export interface BracketData { + stage: StageData[]; + group: GroupData[]; + round: RoundData[]; + match: MatchData[]; +} + +/* ------------------------------------------------------------------ */ +/* Engine internals */ +/* ------------------------------------------------------------------ */ + +/** Used by the engine to handle placements. Is `null` if is a BYE. Has a `null` id if it's yet to be determined. */ +export type ParticipantSlot = { id: number | null; position?: number } | null; + +/** The engine only handles duels. It's one participant versus another participant. */ +export type Duel = [ParticipantSlot, ParticipantSlot]; + +/** The side of an opponent. */ +export type Side = "opponent1" | "opponent2"; + +/** The result of an array which was split by parity. */ +export interface ParitySplit { + even: T[]; + odd: T[]; +} + +/** Type of an object implementing every ordering method. */ +export type OrderingMap = Record< + SeedOrdering, + (array: T[], ...args: number[]) => T[] +>; + +/** Makes an object type deeply partial. */ +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + +/** Contains the losers and the winner of a standard bracket. */ +export interface StandardBracketResults { + /** The list of losers for each round of the bracket. */ + losers: ParticipantSlot[][]; + + /** The winner of the bracket. */ + winner: ParticipantSlot; +} + +/* ------------------------------------------------------------------ */ +/* Engine inputs */ +/* ------------------------------------------------------------------ */ + +export interface CreateBracketInput { + tournamentId: number; + name: string; + type: StageType; + /** Team ids in seed order; `null` = BYE. When omitted, `settings.size` must be given (TBD slots). */ + seeding?: Seeding; + settings: StageSettings; + /** Parallel to seeding; required when settings.hasAbDivisions. 0 = A, 1 = B. */ + abDivisions?: (0 | 1)[]; + /** Stage number within the tournament. Defaults to 1 (local data; the repository assigns the real number on insert). */ + number?: number; +} + +/** Mirrors the old manager.update.match() partial-update input. */ +export interface ReportResultInput { + matchId: number; + opponent1?: Partial< + Pick + >; + opponent2?: Partial< + Pick + >; + /** + * Bypasses the "match is locked/completed" guard. The old library's `true` + * second argument to manager.update.match(), used by endDroppedTeamMatches. + */ + force?: boolean; +} + +/** The subset of a standing the swiss round generation needs. */ +export interface SwissStanding { + team: { + id: number; + /** Truthy when the team has dropped out (DB stores 0/1). */ + droppedOut?: number | boolean; + }; + stats?: { + setWins: number; + setLosses: number; + }; +} + +/* ------------------------------------------------------------------ */ +/* Engine outputs */ +/* ------------------------------------------------------------------ */ + +/** + * Every engine mutation returns the full next state plus the delta. The + * repository persists ONLY the delta; the state is for the caller to keep + * working with (chained operations, simulations, revalidation payloads). + */ +export interface EngineResult { + data: BracketData; + /** Matches whose row must be UPDATEd (status/opponents changed). */ + changedMatches: MatchData[]; +} + +/** Result of Engine.create — nothing persisted yet, ids are local (0..n-1 per table). */ +export interface CreatedBracket extends BracketData {} // xxx: empty extends? + +/** Matches to INSERT when a new round is generated (swiss). */ +export interface GeneratedRound { + groupId: number; + roundId: number; + matches: Array<{ + number: number; + opponent1: ParticipantResult | null; + /** null opponent = BYE */ + opponent2: ParticipantResult | null; + }>; +} + +export interface DroppedTeamsResult extends EngineResult { + endedMatchIds: number[]; +} + +/* ------------------------------------------------------------------ */ +/* Aliases kept from the old brackets-manager/brackets-model modules */ +/* so imports could move over without a mechanical rename. */ +/* ------------------------------------------------------------------ */ + +// xxx: just rename +export type TournamentManagerDataSet = BracketData; +export type Stage = StageData; +export type Round = RoundData; +export type Match = MatchData; diff --git a/app/features/tournament-bracket/core/rounds.ts b/app/features/tournament-bracket/core/rounds.ts index c7ed696b9..369af2bc2 100644 --- a/app/features/tournament-bracket/core/rounds.ts +++ b/app/features/tournament-bracket/core/rounds.ts @@ -1,5 +1,5 @@ import * as R from "remeda"; -import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; +import type { TournamentManagerDataSet } from "~/features/tournament-bracket/core/engine/types"; import { TOURNAMENT } from "../../tournament/tournament-constants"; export function getRounds(args: { diff --git a/app/features/tournament-bracket/core/tests/mocks-sos.ts b/app/features/tournament-bracket/core/tests/mocks-sos.ts index 8c6f248fc..7468e5927 100644 --- a/app/features/tournament-bracket/core/tests/mocks-sos.ts +++ b/app/features/tournament-bracket/core/tests/mocks-sos.ts @@ -420,14 +420,12 @@ export const SWIM_OR_SINK_167 = ( position: 1, score: 2, result: "win", - totalPoints: 101, }, opponent2: { id: 14607, position: 44, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10930, stage_id: 1118, @@ -443,14 +441,12 @@ export const SWIM_OR_SINK_167 = ( position: 23, score: 2, result: "win", - totalPoints: 166, }, opponent2: { id: 14797, position: 22, score: 0, result: "loss", - totalPoints: 53, }, round_id: 10930, stage_id: 1118, @@ -466,14 +462,12 @@ export const SWIM_OR_SINK_167 = ( position: 22, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14607, position: 44, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10931, stage_id: 1118, @@ -489,14 +483,12 @@ export const SWIM_OR_SINK_167 = ( position: 1, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14809, position: 23, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10931, stage_id: 1118, @@ -512,14 +504,12 @@ export const SWIM_OR_SINK_167 = ( position: 23, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14607, position: 44, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10932, stage_id: 1118, @@ -535,14 +525,12 @@ export const SWIM_OR_SINK_167 = ( position: 22, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 14657, position: 1, score: 2, result: "win", - totalPoints: 101, }, round_id: 10932, stage_id: 1118, @@ -558,14 +546,12 @@ export const SWIM_OR_SINK_167 = ( position: 2, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14739, position: 43, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10933, stage_id: 1118, @@ -581,14 +567,12 @@ export const SWIM_OR_SINK_167 = ( position: 24, score: 2, result: "win", - totalPoints: 171, }, opponent2: { id: 14796, position: 21, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10933, stage_id: 1118, @@ -604,14 +588,12 @@ export const SWIM_OR_SINK_167 = ( position: 21, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14739, position: 43, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10934, stage_id: 1118, @@ -627,14 +609,12 @@ export const SWIM_OR_SINK_167 = ( position: 2, score: 2, result: "win", - totalPoints: 177, }, opponent2: { id: 14709, position: 24, score: 1, result: "loss", - totalPoints: 156, }, round_id: 10934, stage_id: 1118, @@ -650,14 +630,12 @@ export const SWIM_OR_SINK_167 = ( position: 24, score: 2, result: "win", - totalPoints: 268, }, opponent2: { id: 14739, position: 43, score: 1, result: "loss", - totalPoints: 69, }, round_id: 10935, stage_id: 1118, @@ -673,14 +651,12 @@ export const SWIM_OR_SINK_167 = ( position: 21, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 14800, position: 2, score: 2, result: "win", - totalPoints: 200, }, round_id: 10935, stage_id: 1118, @@ -696,14 +672,12 @@ export const SWIM_OR_SINK_167 = ( position: 3, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14737, position: 42, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10936, stage_id: 1118, @@ -719,14 +693,12 @@ export const SWIM_OR_SINK_167 = ( position: 25, score: 1, result: "loss", - totalPoints: 135, }, opponent2: { id: 14715, position: 20, score: 2, result: "win", - totalPoints: 188, }, round_id: 10936, stage_id: 1118, @@ -742,14 +714,12 @@ export const SWIM_OR_SINK_167 = ( position: 20, score: 2, result: "win", - totalPoints: 180, }, opponent2: { id: 14737, position: 42, score: 0, result: "loss", - totalPoints: 68, }, round_id: 10937, stage_id: 1118, @@ -765,14 +735,12 @@ export const SWIM_OR_SINK_167 = ( position: 3, score: 2, result: "win", - totalPoints: 196, }, opponent2: { id: 14702, position: 25, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10937, stage_id: 1118, @@ -788,14 +756,12 @@ export const SWIM_OR_SINK_167 = ( position: 25, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14737, position: 42, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10938, stage_id: 1118, @@ -811,14 +777,12 @@ export const SWIM_OR_SINK_167 = ( position: 20, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 14743, position: 3, score: 2, result: "win", - totalPoints: 200, }, round_id: 10938, stage_id: 1118, @@ -834,14 +798,12 @@ export const SWIM_OR_SINK_167 = ( position: 4, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14620, position: 41, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10939, stage_id: 1118, @@ -857,14 +819,12 @@ export const SWIM_OR_SINK_167 = ( position: 26, score: 2, result: "win", - totalPoints: 196, }, opponent2: { id: 14708, position: 19, score: 0, result: "loss", - totalPoints: 64, }, round_id: 10939, stage_id: 1118, @@ -880,14 +840,12 @@ export const SWIM_OR_SINK_167 = ( position: 19, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14620, position: 41, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10940, stage_id: 1118, @@ -903,14 +861,12 @@ export const SWIM_OR_SINK_167 = ( position: 4, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14735, position: 26, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10940, stage_id: 1118, @@ -926,14 +882,12 @@ export const SWIM_OR_SINK_167 = ( position: 26, score: 2, result: "win", - totalPoints: 180, }, opponent2: { id: 14620, position: 41, score: 0, result: "loss", - totalPoints: 63, }, round_id: 10941, stage_id: 1118, @@ -949,14 +903,12 @@ export const SWIM_OR_SINK_167 = ( position: 19, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 14801, position: 4, score: 2, result: "win", - totalPoints: 200, }, round_id: 10941, stage_id: 1118, @@ -972,14 +924,12 @@ export const SWIM_OR_SINK_167 = ( position: 5, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14808, position: 40, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10942, stage_id: 1118, @@ -995,14 +945,12 @@ export const SWIM_OR_SINK_167 = ( position: 27, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 14653, position: 18, score: 2, result: "win", - totalPoints: 200, }, round_id: 10942, stage_id: 1118, @@ -1018,14 +966,12 @@ export const SWIM_OR_SINK_167 = ( position: 18, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14808, position: 40, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10943, stage_id: 1118, @@ -1041,14 +987,12 @@ export const SWIM_OR_SINK_167 = ( position: 5, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14798, position: 27, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10943, stage_id: 1118, @@ -1064,14 +1008,12 @@ export const SWIM_OR_SINK_167 = ( position: 27, score: 2, result: "win", - totalPoints: 222, }, opponent2: { id: 14808, position: 40, score: 1, result: "loss", - totalPoints: 104, }, round_id: 10944, stage_id: 1118, @@ -1087,14 +1029,12 @@ export const SWIM_OR_SINK_167 = ( position: 18, score: 0, result: "loss", - totalPoints: 117, }, opponent2: { id: 14792, position: 5, score: 2, result: "win", - totalPoints: 181, }, round_id: 10944, stage_id: 1118, @@ -1110,14 +1050,12 @@ export const SWIM_OR_SINK_167 = ( position: 6, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14696, position: 39, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10945, stage_id: 1118, @@ -1133,14 +1071,12 @@ export const SWIM_OR_SINK_167 = ( position: 28, score: 1, result: "loss", - totalPoints: 1, }, opponent2: { id: 14806, position: 17, score: 2, result: "win", - totalPoints: 200, }, round_id: 10945, stage_id: 1118, @@ -1156,14 +1092,12 @@ export const SWIM_OR_SINK_167 = ( position: 17, score: 2, result: "win", - totalPoints: 2, }, opponent2: { id: 14696, position: 39, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10946, stage_id: 1118, @@ -1179,14 +1113,12 @@ export const SWIM_OR_SINK_167 = ( position: 6, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14663, position: 28, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10946, stage_id: 1118, @@ -1202,14 +1134,12 @@ export const SWIM_OR_SINK_167 = ( position: 28, score: 2, result: "win", - totalPoints: 153, }, opponent2: { id: 14696, position: 39, score: 0, result: "loss", - totalPoints: 38, }, round_id: 10947, stage_id: 1118, @@ -1225,14 +1155,12 @@ export const SWIM_OR_SINK_167 = ( position: 17, score: 0, result: "loss", - totalPoints: 17, }, opponent2: { id: 14670, position: 6, score: 2, result: "win", - totalPoints: 181, }, round_id: 10947, stage_id: 1118, @@ -1248,14 +1176,12 @@ export const SWIM_OR_SINK_167 = ( position: 7, score: 2, result: "win", - totalPoints: 3, }, opponent2: { id: 14802, position: 38, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10948, stage_id: 1118, @@ -1271,14 +1197,12 @@ export const SWIM_OR_SINK_167 = ( position: 29, score: 1, result: "loss", - totalPoints: 100, }, opponent2: { id: 14805, position: 16, score: 2, result: "win", - totalPoints: 200, }, round_id: 10948, stage_id: 1118, @@ -1294,14 +1218,12 @@ export const SWIM_OR_SINK_167 = ( position: 16, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14802, position: 38, score: 1, result: "loss", - totalPoints: 1, }, round_id: 10949, stage_id: 1118, @@ -1317,14 +1239,12 @@ export const SWIM_OR_SINK_167 = ( position: 7, score: 2, result: "win", - totalPoints: 148, }, opponent2: { id: 14517, position: 29, score: 0, result: "loss", - totalPoints: 44, }, round_id: 10949, stage_id: 1118, @@ -1340,14 +1260,12 @@ export const SWIM_OR_SINK_167 = ( position: 29, score: 2, result: "win", - totalPoints: 154, }, opponent2: { id: 14802, position: 38, score: 0, result: "loss", - totalPoints: 40, }, round_id: 10950, stage_id: 1118, @@ -1363,14 +1281,12 @@ export const SWIM_OR_SINK_167 = ( position: 16, score: 0, result: "loss", - totalPoints: 84, }, opponent2: { id: 14661, position: 7, score: 2, result: "win", - totalPoints: 193, }, round_id: 10950, stage_id: 1118, @@ -1386,14 +1302,12 @@ export const SWIM_OR_SINK_167 = ( position: 8, score: 2, result: "win", - totalPoints: 185, }, opponent2: { id: 14764, position: 37, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10951, stage_id: 1118, @@ -1409,14 +1323,12 @@ export const SWIM_OR_SINK_167 = ( position: 30, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 14777, position: 15, score: 2, result: "win", - totalPoints: 200, }, round_id: 10951, stage_id: 1118, @@ -1432,14 +1344,12 @@ export const SWIM_OR_SINK_167 = ( position: 15, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14764, position: 37, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10952, stage_id: 1118, @@ -1455,14 +1365,12 @@ export const SWIM_OR_SINK_167 = ( position: 8, score: 2, result: "win", - totalPoints: 102, }, opponent2: { id: 14741, position: 30, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10952, stage_id: 1118, @@ -1478,14 +1386,12 @@ export const SWIM_OR_SINK_167 = ( position: 30, score: 2, result: "win", - totalPoints: 3, }, opponent2: { id: 14764, position: 37, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10953, stage_id: 1118, @@ -1501,14 +1407,12 @@ export const SWIM_OR_SINK_167 = ( position: 15, score: 2, result: "win", - totalPoints: 153, }, opponent2: { id: 14804, position: 8, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10953, stage_id: 1118, @@ -1524,14 +1428,12 @@ export const SWIM_OR_SINK_167 = ( position: 9, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14742, position: 36, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10954, stage_id: 1118, @@ -1547,14 +1449,12 @@ export const SWIM_OR_SINK_167 = ( position: 31, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 14445, position: 14, score: 2, result: "win", - totalPoints: 200, }, round_id: 10954, stage_id: 1118, @@ -1570,14 +1470,12 @@ export const SWIM_OR_SINK_167 = ( position: 14, score: 2, result: "win", - totalPoints: 102, }, opponent2: { id: 14742, position: 36, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10955, stage_id: 1118, @@ -1593,14 +1491,12 @@ export const SWIM_OR_SINK_167 = ( position: 9, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14655, position: 31, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10955, stage_id: 1118, @@ -1616,14 +1512,12 @@ export const SWIM_OR_SINK_167 = ( position: 31, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 14742, position: 36, score: 2, result: "win", - totalPoints: 101, }, round_id: 10956, stage_id: 1118, @@ -1639,14 +1533,12 @@ export const SWIM_OR_SINK_167 = ( position: 14, score: 0, result: "loss", - totalPoints: 40, }, opponent2: { id: 14732, position: 9, score: 2, result: "win", - totalPoints: 155, }, round_id: 10956, stage_id: 1118, @@ -1662,14 +1554,12 @@ export const SWIM_OR_SINK_167 = ( position: 10, score: 2, result: "win", - totalPoints: 3, }, opponent2: { id: 14687, position: 35, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10957, stage_id: 1118, @@ -1685,14 +1575,12 @@ export const SWIM_OR_SINK_167 = ( position: 32, score: 1, result: "loss", - totalPoints: 172, }, opponent2: { id: 14795, position: 13, score: 2, result: "win", - totalPoints: 186, }, round_id: 10957, stage_id: 1118, @@ -1708,14 +1596,12 @@ export const SWIM_OR_SINK_167 = ( position: 13, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14687, position: 35, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10958, stage_id: 1118, @@ -1731,14 +1617,12 @@ export const SWIM_OR_SINK_167 = ( position: 10, score: 1, result: "loss", - totalPoints: 2, }, opponent2: { id: 14799, position: 32, score: 2, result: "win", - totalPoints: 3, }, round_id: 10958, stage_id: 1118, @@ -1754,14 +1638,12 @@ export const SWIM_OR_SINK_167 = ( position: 32, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14687, position: 35, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10959, stage_id: 1118, @@ -1777,14 +1659,12 @@ export const SWIM_OR_SINK_167 = ( position: 13, score: 2, result: "win", - totalPoints: 198, }, opponent2: { id: 14747, position: 10, score: 1, result: "loss", - totalPoints: 189, }, round_id: 10959, stage_id: 1118, @@ -1800,14 +1680,12 @@ export const SWIM_OR_SINK_167 = ( position: 11, score: 2, result: "win", - totalPoints: 172, }, opponent2: { id: 14713, position: 34, score: 0, result: "loss", - totalPoints: 32, }, round_id: 10960, stage_id: 1118, @@ -1823,14 +1701,12 @@ export const SWIM_OR_SINK_167 = ( position: 33, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 14803, position: 12, score: 2, result: "win", - totalPoints: 160, }, round_id: 10960, stage_id: 1118, @@ -1846,14 +1722,12 @@ export const SWIM_OR_SINK_167 = ( position: 12, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14713, position: 34, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10961, stage_id: 1118, @@ -1869,14 +1743,12 @@ export const SWIM_OR_SINK_167 = ( position: 11, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 14733, position: 33, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10961, stage_id: 1118, @@ -1892,14 +1764,12 @@ export const SWIM_OR_SINK_167 = ( position: 33, score: 2, result: "win", - totalPoints: 161, }, opponent2: { id: 14713, position: 34, score: 0, result: "loss", - totalPoints: 0, }, round_id: 10962, stage_id: 1118, @@ -1915,14 +1785,12 @@ export const SWIM_OR_SINK_167 = ( position: 12, score: 2, result: "win", - totalPoints: 170, }, opponent2: { id: 14748, position: 11, score: 0, result: "loss", - totalPoints: 65, }, round_id: 10962, stage_id: 1118, diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index 404eac822..75072dc3b 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -252,14 +252,12 @@ export const PADDLING_POOL_257 = () => position: 19, score: 1, result: "loss", - totalPoints: 128, }, opponent2: { id: 882, position: 18, score: 2, result: "win", - totalPoints: 232, }, round_id: 386, stage_id: 28, @@ -287,14 +285,12 @@ export const PADDLING_POOL_257 = () => position: 1, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 902, position: 19, score: 0, result: "loss", - totalPoints: 0, }, round_id: 387, stage_id: 28, @@ -322,14 +318,12 @@ export const PADDLING_POOL_257 = () => position: 18, score: 0, result: "loss", - totalPoints: 20, }, opponent2: { id: 892, position: 1, score: 2, result: "win", - totalPoints: 143, }, round_id: 388, stage_id: 28, @@ -344,14 +338,12 @@ export const PADDLING_POOL_257 = () => position: 2, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 838, position: 35, score: 0, result: "loss", - totalPoints: 0, }, round_id: 389, stage_id: 28, @@ -366,14 +358,12 @@ export const PADDLING_POOL_257 = () => position: 20, score: 2, result: "win", - totalPoints: 160, }, opponent2: { id: 889, position: 17, score: 0, result: "loss", - totalPoints: 36, }, round_id: 389, stage_id: 28, @@ -388,14 +378,12 @@ export const PADDLING_POOL_257 = () => position: 17, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 838, position: 35, score: 1, result: "loss", - totalPoints: 100, }, round_id: 390, stage_id: 28, @@ -410,14 +398,12 @@ export const PADDLING_POOL_257 = () => position: 2, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 898, position: 20, score: 0, result: "loss", - totalPoints: 0, }, round_id: 390, stage_id: 28, @@ -432,14 +418,12 @@ export const PADDLING_POOL_257 = () => position: 20, score: 2, result: "win", - totalPoints: 101, }, opponent2: { id: 838, position: 35, score: 0, result: "loss", - totalPoints: 0, }, round_id: 391, stage_id: 28, @@ -454,14 +438,12 @@ export const PADDLING_POOL_257 = () => position: 17, score: 0, result: "loss", - totalPoints: 43, }, opponent2: { id: 881, position: 2, score: 2, result: "win", - totalPoints: 193, }, round_id: 391, stage_id: 28, @@ -476,14 +458,12 @@ export const PADDLING_POOL_257 = () => position: 3, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 847, position: 34, score: 1, result: "loss", - totalPoints: 1, }, round_id: 392, stage_id: 28, @@ -498,14 +478,12 @@ export const PADDLING_POOL_257 = () => position: 21, score: 2, result: "win", - totalPoints: 117, }, opponent2: { id: 901, position: 16, score: 1, result: "loss", - totalPoints: 188, }, round_id: 392, stage_id: 28, @@ -520,14 +498,12 @@ export const PADDLING_POOL_257 = () => position: 16, score: 2, result: "win", - totalPoints: 157, }, opponent2: { id: 847, position: 34, score: 0, result: "loss", - totalPoints: 53, }, round_id: 393, stage_id: 28, @@ -542,14 +518,12 @@ export const PADDLING_POOL_257 = () => position: 3, score: 2, result: "win", - totalPoints: 150, }, opponent2: { id: 874, position: 21, score: 0, result: "loss", - totalPoints: 0, }, round_id: 393, stage_id: 28, @@ -564,14 +538,12 @@ export const PADDLING_POOL_257 = () => position: 21, score: 2, result: "win", - totalPoints: 3, }, opponent2: { id: 847, position: 34, score: 0, result: "loss", - totalPoints: 0, }, round_id: 394, stage_id: 28, @@ -586,14 +558,12 @@ export const PADDLING_POOL_257 = () => position: 16, score: 0, result: "loss", - totalPoints: 71, }, opponent2: { id: 891, position: 3, score: 2, result: "win", - totalPoints: 179, }, round_id: 394, stage_id: 28, @@ -608,14 +578,12 @@ export const PADDLING_POOL_257 = () => position: 4, score: 2, result: "win", - totalPoints: 172, }, opponent2: { id: 896, position: 33, score: 0, result: "loss", - totalPoints: 61, }, round_id: 395, stage_id: 28, @@ -630,14 +598,12 @@ export const PADDLING_POOL_257 = () => position: 22, score: 2, result: "win", - totalPoints: 175, }, opponent2: { id: 878, position: 15, score: 0, result: "loss", - totalPoints: 46, }, round_id: 395, stage_id: 28, @@ -652,14 +618,12 @@ export const PADDLING_POOL_257 = () => position: 15, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 896, position: 33, score: 0, result: "loss", - totalPoints: 0, }, round_id: 396, stage_id: 28, @@ -674,14 +638,12 @@ export const PADDLING_POOL_257 = () => position: 4, score: 2, result: "win", - totalPoints: 182, }, opponent2: { id: 869, position: 22, score: 0, result: "loss", - totalPoints: 72, }, round_id: 396, stage_id: 28, @@ -696,14 +658,12 @@ export const PADDLING_POOL_257 = () => position: 22, score: 2, result: "win", - totalPoints: 2, }, opponent2: { id: 896, position: 33, score: 0, result: "loss", - totalPoints: 0, }, round_id: 397, stage_id: 28, @@ -718,14 +678,12 @@ export const PADDLING_POOL_257 = () => position: 15, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 890, position: 4, score: 2, result: "win", - totalPoints: 200, }, round_id: 397, stage_id: 28, @@ -740,14 +698,12 @@ export const PADDLING_POOL_257 = () => position: 5, score: 2, result: "win", - totalPoints: 3, }, opponent2: { id: 819, position: 32, score: 0, result: "loss", - totalPoints: 0, }, round_id: 398, stage_id: 28, @@ -762,14 +718,12 @@ export const PADDLING_POOL_257 = () => position: 23, score: 0, result: "loss", - totalPoints: 81, }, opponent2: { id: 845, position: 14, score: 2, result: "win", - totalPoints: 103, }, round_id: 398, stage_id: 28, @@ -784,14 +738,12 @@ export const PADDLING_POOL_257 = () => position: 14, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 819, position: 32, score: 0, result: "loss", - totalPoints: 0, }, round_id: 399, stage_id: 28, @@ -806,14 +758,12 @@ export const PADDLING_POOL_257 = () => position: 5, score: 2, result: "win", - totalPoints: 101, }, opponent2: { id: 843, position: 23, score: 0, result: "loss", - totalPoints: 0, }, round_id: 399, stage_id: 28, @@ -828,14 +778,12 @@ export const PADDLING_POOL_257 = () => position: 23, score: 2, result: "win", - totalPoints: 199, }, opponent2: { id: 819, position: 32, score: 1, result: "loss", - totalPoints: 135, }, round_id: 400, stage_id: 28, @@ -850,14 +798,12 @@ export const PADDLING_POOL_257 = () => position: 14, score: 1, result: "loss", - totalPoints: 1, }, opponent2: { id: 886, position: 5, score: 2, result: "win", - totalPoints: 2, }, round_id: 400, stage_id: 28, @@ -872,14 +818,12 @@ export const PADDLING_POOL_257 = () => position: 6, score: 2, result: "win", - totalPoints: 175, }, opponent2: { id: 872, position: 31, score: 0, result: "loss", - totalPoints: 4, }, round_id: 401, stage_id: 28, @@ -894,14 +838,12 @@ export const PADDLING_POOL_257 = () => position: 24, score: 0, result: "loss", - totalPoints: 37, }, opponent2: { id: 871, position: 13, score: 2, result: "win", - totalPoints: 74, }, round_id: 401, stage_id: 28, @@ -916,14 +858,12 @@ export const PADDLING_POOL_257 = () => position: 13, score: 2, result: "win", - totalPoints: 3, }, opponent2: { id: 872, position: 31, score: 0, result: "loss", - totalPoints: 0, }, round_id: 402, stage_id: 28, @@ -938,14 +878,12 @@ export const PADDLING_POOL_257 = () => position: 6, score: 2, result: "win", - totalPoints: 172, }, opponent2: { id: 870, position: 24, score: 0, result: "loss", - totalPoints: 0, }, round_id: 402, stage_id: 28, @@ -960,14 +898,12 @@ export const PADDLING_POOL_257 = () => position: 24, score: 0, result: "loss", - totalPoints: 60, }, opponent2: { id: 872, position: 31, score: 2, result: "win", - totalPoints: 188, }, round_id: 403, stage_id: 28, @@ -982,14 +918,12 @@ export const PADDLING_POOL_257 = () => position: 13, score: 1, result: "loss", - totalPoints: 100, }, opponent2: { id: 867, position: 6, score: 2, result: "win", - totalPoints: 132, }, round_id: 403, stage_id: 28, @@ -1004,14 +938,12 @@ export const PADDLING_POOL_257 = () => position: 7, score: 2, result: "win", - totalPoints: 101, }, opponent2: { id: 897, position: 30, score: 0, result: "loss", - totalPoints: 0, }, round_id: 404, stage_id: 28, @@ -1026,14 +958,12 @@ export const PADDLING_POOL_257 = () => position: 25, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 828, position: 12, score: 2, result: "win", - totalPoints: 101, }, round_id: 404, stage_id: 28, @@ -1048,14 +978,12 @@ export const PADDLING_POOL_257 = () => position: 12, score: 2, result: "win", - totalPoints: 170, }, opponent2: { id: 897, position: 30, score: 0, result: "loss", - totalPoints: 112, }, round_id: 405, stage_id: 28, @@ -1070,14 +998,12 @@ export const PADDLING_POOL_257 = () => position: 7, score: 2, result: "win", - totalPoints: 152, }, opponent2: { id: 893, position: 25, score: 0, result: "loss", - totalPoints: 23, }, round_id: 405, stage_id: 28, @@ -1092,14 +1018,12 @@ export const PADDLING_POOL_257 = () => position: 25, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 897, position: 30, score: 2, result: "win", - totalPoints: 101, }, round_id: 406, stage_id: 28, @@ -1114,14 +1038,12 @@ export const PADDLING_POOL_257 = () => position: 12, score: 2, result: "win", - totalPoints: 160, }, opponent2: { id: 884, position: 7, score: 1, result: "loss", - totalPoints: 218, }, round_id: 406, stage_id: 28, @@ -1136,14 +1058,12 @@ export const PADDLING_POOL_257 = () => position: 8, score: 2, result: "win", - totalPoints: 199, }, opponent2: { id: 887, position: 29, score: 0, result: "loss", - totalPoints: 52, }, round_id: 407, stage_id: 28, @@ -1158,14 +1078,12 @@ export const PADDLING_POOL_257 = () => position: 26, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 900, position: 11, score: 2, result: "win", - totalPoints: 200, }, round_id: 407, stage_id: 28, @@ -1180,14 +1098,12 @@ export const PADDLING_POOL_257 = () => position: 11, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 887, position: 29, score: 0, result: "loss", - totalPoints: 0, }, round_id: 408, stage_id: 28, @@ -1202,14 +1118,12 @@ export const PADDLING_POOL_257 = () => position: 8, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 883, position: 26, score: 0, result: "loss", - totalPoints: 0, }, round_id: 408, stage_id: 28, @@ -1224,14 +1138,12 @@ export const PADDLING_POOL_257 = () => position: 26, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 887, position: 29, score: 0, result: "loss", - totalPoints: 0, }, round_id: 409, stage_id: 28, @@ -1246,14 +1158,12 @@ export const PADDLING_POOL_257 = () => position: 11, score: 2, result: "win", - totalPoints: 149, }, opponent2: { id: 879, position: 8, score: 0, result: "loss", - totalPoints: 40, }, round_id: 409, stage_id: 28, @@ -1268,14 +1178,12 @@ export const PADDLING_POOL_257 = () => position: 9, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 873, position: 28, score: 0, result: "loss", - totalPoints: 0, }, round_id: 410, stage_id: 28, @@ -1290,14 +1198,12 @@ export const PADDLING_POOL_257 = () => position: 27, score: 1, result: "loss", - totalPoints: 98, }, opponent2: { id: 875, position: 10, score: 2, result: "win", - totalPoints: 174, }, round_id: 410, stage_id: 28, @@ -1312,14 +1218,12 @@ export const PADDLING_POOL_257 = () => position: 10, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 873, position: 28, score: 0, result: "loss", - totalPoints: 0, }, round_id: 411, stage_id: 28, @@ -1334,14 +1238,12 @@ export const PADDLING_POOL_257 = () => position: 9, score: 2, result: "win", - totalPoints: 191, }, opponent2: { id: 839, position: 27, score: 0, result: "loss", - totalPoints: 19, }, round_id: 411, stage_id: 28, @@ -1356,14 +1258,12 @@ export const PADDLING_POOL_257 = () => position: 27, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 873, position: 28, score: 0, result: "loss", - totalPoints: 0, }, round_id: 412, stage_id: 28, @@ -1378,14 +1278,12 @@ export const PADDLING_POOL_257 = () => position: 10, score: 1, result: "loss", - totalPoints: 184, }, opponent2: { id: 888, position: 9, score: 2, result: "win", - totalPoints: 117, }, round_id: 412, stage_id: 28, @@ -7197,14 +7095,12 @@ export const PADDLING_POOL_255 = () => position: 19, score: 2, result: "win", - totalPoints: 82, }, opponent2: { id: 707, position: 18, score: 0, result: "loss", - totalPoints: 80, }, round_id: 285, stage_id: 20, @@ -7234,14 +7130,12 @@ export const PADDLING_POOL_255 = () => position: 1, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 673, position: 19, score: 0, result: "loss", - totalPoints: 0, }, round_id: 286, stage_id: 20, @@ -7271,14 +7165,12 @@ export const PADDLING_POOL_255 = () => position: 18, score: 0, result: "loss", - totalPoints: 55, }, opponent2: { id: 698, position: 1, score: 2, result: "win", - totalPoints: 156, }, round_id: 287, stage_id: 20, @@ -7294,14 +7186,12 @@ export const PADDLING_POOL_255 = () => position: 2, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 700, position: 35, score: 0, result: "loss", - totalPoints: 0, }, round_id: 288, stage_id: 20, @@ -7317,14 +7207,12 @@ export const PADDLING_POOL_255 = () => position: 20, score: 1, result: "loss", - totalPoints: 119, }, opponent2: { id: 678, position: 17, score: 2, result: "win", - totalPoints: 221, }, round_id: 288, stage_id: 20, @@ -7340,14 +7228,12 @@ export const PADDLING_POOL_255 = () => position: 17, score: 2, result: "win", - totalPoints: 190, }, opponent2: { id: 700, position: 35, score: 0, result: "loss", - totalPoints: 78, }, round_id: 289, stage_id: 20, @@ -7363,14 +7249,12 @@ export const PADDLING_POOL_255 = () => position: 2, score: 2, result: "win", - totalPoints: 168, }, opponent2: { id: 714, position: 20, score: 0, result: "loss", - totalPoints: 60, }, round_id: 289, stage_id: 20, @@ -7386,14 +7270,12 @@ export const PADDLING_POOL_255 = () => position: 20, score: 2, result: "win", - totalPoints: 160, }, opponent2: { id: 700, position: 35, score: 0, result: "loss", - totalPoints: 75, }, round_id: 290, stage_id: 20, @@ -7409,14 +7291,12 @@ export const PADDLING_POOL_255 = () => position: 17, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 718, position: 2, score: 2, result: "win", - totalPoints: 200, }, round_id: 290, stage_id: 20, @@ -7432,14 +7312,12 @@ export const PADDLING_POOL_255 = () => position: 3, score: 2, result: "win", - totalPoints: 70, }, opponent2: { id: 710, position: 34, score: 0, result: "loss", - totalPoints: 0, }, round_id: 291, stage_id: 20, @@ -7455,14 +7333,12 @@ export const PADDLING_POOL_255 = () => position: 21, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 702, position: 16, score: 2, result: "win", - totalPoints: 3, }, round_id: 291, stage_id: 20, @@ -7478,14 +7354,12 @@ export const PADDLING_POOL_255 = () => position: 16, score: 2, result: "win", - totalPoints: 3, }, opponent2: { id: 710, position: 34, score: 0, result: "loss", - totalPoints: 0, }, round_id: 292, stage_id: 20, @@ -7501,14 +7375,12 @@ export const PADDLING_POOL_255 = () => position: 3, score: 2, result: "win", - totalPoints: 164, }, opponent2: { id: 672, position: 21, score: 0, result: "loss", - totalPoints: 0, }, round_id: 292, stage_id: 20, @@ -7524,14 +7396,12 @@ export const PADDLING_POOL_255 = () => position: 21, score: 2, result: "win", - totalPoints: 2, }, opponent2: { id: 710, position: 34, score: 0, result: "loss", - totalPoints: 0, }, round_id: 293, stage_id: 20, @@ -7547,14 +7417,12 @@ export const PADDLING_POOL_255 = () => position: 16, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 716, position: 3, score: 2, result: "win", - totalPoints: 200, }, round_id: 293, stage_id: 20, @@ -7570,14 +7438,12 @@ export const PADDLING_POOL_255 = () => position: 4, score: 2, result: "win", - totalPoints: 175, }, opponent2: { id: 699, position: 33, score: 0, result: "loss", - totalPoints: 23, }, round_id: 294, stage_id: 20, @@ -7593,14 +7459,12 @@ export const PADDLING_POOL_255 = () => position: 22, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 719, position: 15, score: 2, result: "win", - totalPoints: 200, }, round_id: 294, stage_id: 20, @@ -7616,14 +7480,12 @@ export const PADDLING_POOL_255 = () => position: 15, score: 2, result: "win", - totalPoints: 179, }, opponent2: { id: 699, position: 33, score: 0, result: "loss", - totalPoints: 26, }, round_id: 295, stage_id: 20, @@ -7639,14 +7501,12 @@ export const PADDLING_POOL_255 = () => position: 4, score: 2, result: "win", - totalPoints: 199, }, opponent2: { id: 695, position: 22, score: 0, result: "loss", - totalPoints: 72, }, round_id: 295, stage_id: 20, @@ -7662,14 +7522,12 @@ export const PADDLING_POOL_255 = () => position: 22, score: 2, result: "win", - totalPoints: 191, }, opponent2: { id: 699, position: 33, score: 0, result: "loss", - totalPoints: 58, }, round_id: 296, stage_id: 20, @@ -7685,14 +7543,12 @@ export const PADDLING_POOL_255 = () => position: 15, score: 1, result: "loss", - totalPoints: 159, }, opponent2: { id: 724, position: 4, score: 2, result: "win", - totalPoints: 160, }, round_id: 296, stage_id: 20, @@ -7708,14 +7564,12 @@ export const PADDLING_POOL_255 = () => position: 5, score: 2, result: "win", - totalPoints: 106, }, opponent2: { id: 712, position: 32, score: 0, result: "loss", - totalPoints: 53, }, round_id: 297, stage_id: 20, @@ -7731,14 +7585,12 @@ export const PADDLING_POOL_255 = () => position: 23, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 713, position: 14, score: 2, result: "win", - totalPoints: 102, }, round_id: 297, stage_id: 20, @@ -7754,14 +7606,12 @@ export const PADDLING_POOL_255 = () => position: 14, score: 2, result: "win", - totalPoints: 72, }, opponent2: { id: 712, position: 32, score: 0, result: "loss", - totalPoints: 33, }, round_id: 298, stage_id: 20, @@ -7777,14 +7627,12 @@ export const PADDLING_POOL_255 = () => position: 5, score: 2, result: "win", - totalPoints: 183, }, opponent2: { id: 705, position: 23, score: 0, result: "loss", - totalPoints: 0, }, round_id: 298, stage_id: 20, @@ -7800,14 +7648,12 @@ export const PADDLING_POOL_255 = () => position: 23, score: 2, result: "win", - totalPoints: 195, }, opponent2: { id: 712, position: 32, score: 0, result: "loss", - totalPoints: 20, }, round_id: 299, stage_id: 20, @@ -7823,14 +7669,12 @@ export const PADDLING_POOL_255 = () => position: 14, score: 2, result: "win", - totalPoints: 2, }, opponent2: { id: 715, position: 5, score: 1, result: "loss", - totalPoints: 1, }, round_id: 299, stage_id: 20, @@ -7846,14 +7690,12 @@ export const PADDLING_POOL_255 = () => position: 6, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 717, position: 31, score: 0, result: "loss", - totalPoints: 0, }, round_id: 300, stage_id: 20, @@ -7869,14 +7711,12 @@ export const PADDLING_POOL_255 = () => position: 24, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 677, position: 13, score: 2, result: "win", - totalPoints: 3, }, round_id: 300, stage_id: 20, @@ -7892,14 +7732,12 @@ export const PADDLING_POOL_255 = () => position: 13, score: 2, result: "win", - totalPoints: 101, }, opponent2: { id: 717, position: 31, score: 0, result: "loss", - totalPoints: 0, }, round_id: 301, stage_id: 20, @@ -7915,14 +7753,12 @@ export const PADDLING_POOL_255 = () => position: 6, score: 1, result: "loss", - totalPoints: 266, }, opponent2: { id: 720, position: 24, score: 2, result: "win", - totalPoints: 219, }, round_id: 301, stage_id: 20, @@ -7938,14 +7774,12 @@ export const PADDLING_POOL_255 = () => position: 24, score: 2, result: "win", - totalPoints: 2, }, opponent2: { id: 717, position: 31, score: 0, result: "loss", - totalPoints: 0, }, round_id: 302, stage_id: 20, @@ -7961,14 +7795,12 @@ export const PADDLING_POOL_255 = () => position: 13, score: 2, result: "win", - totalPoints: 80, }, opponent2: { id: 676, position: 6, score: 1, result: "loss", - totalPoints: 100, }, round_id: 302, stage_id: 20, @@ -7984,14 +7816,12 @@ export const PADDLING_POOL_255 = () => position: 7, score: 2, result: "win", - totalPoints: 185, }, opponent2: { id: 704, position: 30, score: 0, result: "loss", - totalPoints: 53, }, round_id: 303, stage_id: 20, @@ -8007,14 +7837,12 @@ export const PADDLING_POOL_255 = () => position: 25, score: 0, result: "loss", - totalPoints: 72, }, opponent2: { id: 674, position: 12, score: 2, result: "win", - totalPoints: 197, }, round_id: 303, stage_id: 20, @@ -8030,14 +7858,12 @@ export const PADDLING_POOL_255 = () => position: 12, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 704, position: 30, score: 0, result: "loss", - totalPoints: 0, }, round_id: 304, stage_id: 20, @@ -8053,14 +7879,12 @@ export const PADDLING_POOL_255 = () => position: 7, score: 2, result: "win", - totalPoints: 3, }, opponent2: { id: 721, position: 25, score: 0, result: "loss", - totalPoints: 0, }, round_id: 304, stage_id: 20, @@ -8076,14 +7900,12 @@ export const PADDLING_POOL_255 = () => position: 25, score: 1, result: "loss", - totalPoints: 1, }, opponent2: { id: 704, position: 30, score: 2, result: "win", - totalPoints: 3, }, round_id: 305, stage_id: 20, @@ -8099,14 +7921,12 @@ export const PADDLING_POOL_255 = () => position: 12, score: 0, result: "loss", - totalPoints: 83, }, opponent2: { id: 722, position: 7, score: 2, result: "win", - totalPoints: 184, }, round_id: 305, stage_id: 20, @@ -8122,14 +7942,12 @@ export const PADDLING_POOL_255 = () => position: 8, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 675, position: 29, score: 0, result: "loss", - totalPoints: 0, }, round_id: 306, stage_id: 20, @@ -8145,14 +7963,12 @@ export const PADDLING_POOL_255 = () => position: 26, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 697, position: 11, score: 2, result: "win", - totalPoints: 200, }, round_id: 306, stage_id: 20, @@ -8168,14 +7984,12 @@ export const PADDLING_POOL_255 = () => position: 11, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 675, position: 29, score: 0, result: "loss", - totalPoints: 0, }, round_id: 307, stage_id: 20, @@ -8191,14 +8005,12 @@ export const PADDLING_POOL_255 = () => position: 8, score: 1, result: "loss", - totalPoints: 158, }, opponent2: { id: 706, position: 26, score: 2, result: "win", - totalPoints: 235, }, round_id: 307, stage_id: 20, @@ -8214,14 +8026,12 @@ export const PADDLING_POOL_255 = () => position: 26, score: 2, result: "win", - totalPoints: 259, }, opponent2: { id: 675, position: 29, score: 1, result: "loss", - totalPoints: 162, }, round_id: 308, stage_id: 20, @@ -8237,14 +8047,12 @@ export const PADDLING_POOL_255 = () => position: 11, score: 2, result: "win", - totalPoints: 200, }, opponent2: { id: 708, position: 8, score: 0, result: "loss", - totalPoints: 0, }, round_id: 308, stage_id: 20, @@ -8260,14 +8068,12 @@ export const PADDLING_POOL_255 = () => position: 9, score: 2, result: "win", - totalPoints: 85, }, opponent2: { id: 725, position: 28, score: 1, result: "loss", - totalPoints: 80, }, round_id: 309, stage_id: 20, @@ -8283,14 +8089,12 @@ export const PADDLING_POOL_255 = () => position: 27, score: 0, result: "loss", - totalPoints: 0, }, opponent2: { id: 709, position: 10, score: 2, result: "win", - totalPoints: 2, }, round_id: 309, stage_id: 20, @@ -8306,14 +8110,12 @@ export const PADDLING_POOL_255 = () => position: 10, score: 2, result: "win", - totalPoints: 164, }, opponent2: { id: 725, position: 28, score: 0, result: "loss", - totalPoints: 38, }, round_id: 310, stage_id: 20, @@ -8329,14 +8131,12 @@ export const PADDLING_POOL_255 = () => position: 9, score: 2, result: "win", - totalPoints: 101, }, opponent2: { id: 723, position: 27, score: 0, result: "loss", - totalPoints: 0, }, round_id: 310, stage_id: 20, @@ -8352,14 +8152,12 @@ export const PADDLING_POOL_255 = () => position: 27, score: 1, result: "loss", - totalPoints: 110, }, opponent2: { id: 725, position: 28, score: 2, result: "win", - totalPoints: 187, }, round_id: 311, stage_id: 20, @@ -8375,14 +8173,12 @@ export const PADDLING_POOL_255 = () => position: 10, score: 2, result: "win", - totalPoints: 188, }, opponent2: { id: 701, position: 9, score: 0, result: "loss", - totalPoints: 155, }, round_id: 311, stage_id: 20, diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index 33eb8e15e..cbbb4c90b 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -1,5 +1,5 @@ import * as R from "remeda"; -import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; +import type { TournamentManagerDataSet } from "~/features/tournament-bracket/core/engine/types"; import type * as Progression from "../Progression"; import { Tournament } from "../Tournament"; import type { TournamentData } from "../Tournament.server"; diff --git a/app/features/tournament-bracket/core/toMapList.ts b/app/features/tournament-bracket/core/toMapList.ts index ab8a32b2e..1620b8f49 100644 --- a/app/features/tournament-bracket/core/toMapList.ts +++ b/app/features/tournament-bracket/core/toMapList.ts @@ -3,7 +3,7 @@ import type { Tables, TournamentRoundMaps } from "~/db/tables"; import * as MapList from "~/features/map-list-generator/core/MapList"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; -import type { Round } from "~/modules/brackets-model"; +import type { Round } from "~/features/tournament-bracket/core/engine/types"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { logger } from "~/utils/logger"; import { assertUnreachable } from "~/utils/types"; diff --git a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts index 06c5b70d4..a9363f3a8 100644 --- a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts @@ -1,5 +1,5 @@ import type { ActionFunction } from "react-router"; -import { sql } from "~/db/sql"; +import { db, sql } from "~/db/sql"; import { TournamentMatchStatus } from "~/db/tables"; import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; @@ -7,7 +7,8 @@ import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeap import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { endDroppedTeamMatches } from "~/features/tournament/tournament-utils.server"; -import { getServerTournamentManager } from "~/features/tournament-bracket/core/brackets-manager/manager.server"; +import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server"; +import * as Engine from "~/features/tournament-bracket/core/engine"; import * as PickBan from "~/features/tournament-bracket/core/PickBan"; import { clearTournamentDataCache, @@ -93,8 +94,6 @@ export const action: ActionFunction = async ({ params, request }) => { ); }; - const manager = getServerTournamentManager(); - const scores: [number, number] = [ match.opponentOne?.score ?? 0, match.opponentTwo?.score ?? 0, @@ -181,7 +180,8 @@ export const action: ActionFunction = async ({ params, request }) => { tournament.matchIdToBracketIdx(match.id)!, )!; errorToastIfFalsy( - !bracket.collectResultsWithPoints || data.points, + // xxx: just have data.ko from now on? and update error + !bracket.collectsKos || data.points, "Points are required for this bracket", ); @@ -212,9 +212,14 @@ export const action: ActionFunction = async ({ params, request }) => { ); try { - sql.transaction(() => { - manager.update.match({ - id: match.id, + await db.transaction().execute(async (trx) => { + const bracketData = await BracketRepository.findByTournamentId( + tournamentId, + trx, + ); + const reported = Engine.reportResult(bracketData, { + matchId: match.id, + // xxx: should we be able to know that inside reportResult? no need to pass if we track best of etc. opponent1: { score: scores[0], result: setOver && scores[0] > scores[1] ? "win" : undefined, @@ -224,6 +229,20 @@ export const action: ActionFunction = async ({ params, request }) => { result: setOver && scores[1] > scores[0] ? "win" : undefined, }, }); + let changedMatches = reported.changedMatches; + if (setOver) { + const droppedResult = endDroppedTeamMatches({ + tournament, + data: reported.data, + }); + endedDroppedMatchIds = droppedResult.endedMatchIds; + changedMatches = [ + ...changedMatches, + ...droppedResult.changedMatches, + ]; + } + + await BracketRepository.applyMatchChanges(changedMatches, trx); const result = insertTournamentMatchGameResult({ matchId: match.id, @@ -251,14 +270,7 @@ export const action: ActionFunction = async ({ params, request }) => { tournamentTeamId: match.opponentTwo!.id!, }); } - - if (setOver) { - endedDroppedMatchIds = endDroppedTeamMatches({ - tournament, - manager, - }); - } - })(); + }); } catch (error) { // another request already reported this game in the race window, // let their page refresh to pick up the already-recorded result @@ -370,11 +382,16 @@ export const action: ActionFunction = async ({ params, request }) => { return unplayedPicks[0] ? [unplayedPicks[0].number] : []; })(); - sql.transaction(() => { + await db.transaction().execute(async (trx) => { deleteTournamentMatchGameResultById(lastResult.id); - manager.update.match({ - id: match.id, + const bracketData = await BracketRepository.findByTournamentId( + tournamentId, + trx, + ); + // xxx: i guess reportResult should not be needed here? just "Engine.resetMatchResults" + const reported = Engine.reportResult(bracketData, { + matchId: match.id, opponent1: { score: shouldReset ? undefined : scores[0], }, @@ -383,14 +400,18 @@ export const action: ActionFunction = async ({ params, request }) => { }, }); + let changedMatches = reported.changedMatches; if (shouldReset) { - manager.reset.matchResults(match.id); + const reset = Engine.resetMatchResults(reported.data, match.id); + changedMatches = [...changedMatches, ...reset.changedMatches]; } + await BracketRepository.applyMatchChanges(changedMatches, trx); + for (const number of pickBanEventNumbersToDelete) { deletePickBanEvent({ matchId, number }); } - })(); + }); emitMatchUpdate = true; emitTournamentUpdate = true; @@ -674,7 +695,8 @@ export const action: ActionFunction = async ({ params, request }) => { const bracketFormat = tournament.bracketByIdx( tournament.matchIdToBracketIdx(match.id)!, )!.type; - sql.transaction(() => { + await db.transaction().execute(async (trx) => { + // xxx: feels hacky // edge case but for round robin we can just leave the match as is, lock it then unlock later to continue where they left off (should not really ever happen) if (bracketFormat !== "round_robin") { for (const followingMatch of followingMatches) { @@ -682,6 +704,7 @@ export const action: ActionFunction = async ({ params, request }) => { } } + // xxx: feels hacky // when the set was force-ended early no extra result was inserted for // the forced win, so the last result is a genuinely played game and must // be kept to avoid desyncing the score from the results @@ -689,8 +712,12 @@ export const action: ActionFunction = async ({ params, request }) => { deleteTournamentMatchGameResultById(lastResult.id); } - manager.update.match({ - id: match.id, + const bracketData = await BracketRepository.findByTournamentId( + tournamentId, + trx, + ); + const reported = Engine.reportResult(bracketData, { + matchId: match.id, opponent1: { score: endedEarly ? scoreOne : scores[0], result: undefined, @@ -700,7 +727,8 @@ export const action: ActionFunction = async ({ params, request }) => { result: undefined, }, }); - })(); + await BracketRepository.applyMatchChanges(reported.changedMatches, trx); + }); // the teams advanced into following matches are being pulled back out, // so those "waiting for teams" pages need to revalidate too @@ -810,9 +838,14 @@ export const action: ActionFunction = async ({ params, request }) => { `Ending set by organizer: User ID: ${user.id}; Match ID: ${match.id}; Winner: ${winnerTeamId}; Random: ${!data.winnerTeamId}`, ); - sql.transaction(() => { - manager.update.match({ - id: match.id, + await db.transaction().execute(async (trx) => { + // xxx: this find -> report -> enddroppedteam -> apply is repeated in quite a few places, helper? + const bracketData = await BracketRepository.findByTournamentId( + tournamentId, + trx, + ); + const reported = Engine.reportResult(bracketData, { + matchId: match.id, opponent1: { score: match.opponentOne?.score, result: winnerTeamId === match.opponentOne!.id ? "win" : "loss", @@ -823,11 +856,17 @@ export const action: ActionFunction = async ({ params, request }) => { }, }); - endedDroppedMatchIds = endDroppedTeamMatches({ + const droppedResult = endDroppedTeamMatches({ tournament, - manager, + data: reported.data, }); - })(); + endedDroppedMatchIds = droppedResult.endedMatchIds; + + await BracketRepository.applyMatchChanges( + [...reported.changedMatches, ...droppedResult.changedMatches], + trx, + ); + }); // the set ended early so no further games will be played; trim weapons // reported in advance for map indexes beyond the games actually played diff --git a/app/features/tournament-match/components/TournamentMatchActionTab.tsx b/app/features/tournament-match/components/TournamentMatchActionTab.tsx index 5300cbfde..ff01e567b 100644 --- a/app/features/tournament-match/components/TournamentMatchActionTab.tsx +++ b/app/features/tournament-match/components/TournamentMatchActionTab.tsx @@ -56,7 +56,7 @@ export function TournamentMatchActionTab({ const withPoints = tournament.bracketByIdxOrDefault( tournament.matchIdToBracketIdx(data.match.id) ?? 0, - ).collectResultsWithPoints; + ).collectsKos; const count = data.match.roundMaps.count; const countType = data.match.roundMaps.type; diff --git a/app/features/tournament-match/components/TournamentMatchAdminTab.tsx b/app/features/tournament-match/components/TournamentMatchAdminTab.tsx index 9f15ea4c1..963e30523 100644 --- a/app/features/tournament-match/components/TournamentMatchAdminTab.tsx +++ b/app/features/tournament-match/components/TournamentMatchAdminTab.tsx @@ -351,7 +351,7 @@ function EditReportedScoresSection({ const withPoints = tournament.bracketByIdxOrDefault( tournament.matchIdToBracketIdx(data.match.id) ?? 0, - ).collectResultsWithPoints; + ).collectsKos; return (
diff --git a/app/features/tournament-match/core/mapList.server.ts b/app/features/tournament-match/core/mapList.server.ts index 7e1b04fa2..88ced4b52 100644 --- a/app/features/tournament-match/core/mapList.server.ts +++ b/app/features/tournament-match/core/mapList.server.ts @@ -2,8 +2,8 @@ import type { Tables, TournamentRoundMaps } from "~/db/tables"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { mapPickingStyleToModes } from "~/features/tournament/tournament-utils"; import type { Bracket } from "~/features/tournament-bracket/core/Bracket"; +import type { Round } from "~/features/tournament-bracket/core/engine/types"; import type * as PickBan from "~/features/tournament-bracket/core/PickBan"; -import type { Round } from "~/modules/brackets-model"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { generateBalancedMapList } from "~/modules/tournament-map-list-generator/balanced-map-list"; import { starterMap } from "~/modules/tournament-map-list-generator/starter-map"; diff --git a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts index c94ae4db0..474bcae5f 100644 --- a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts @@ -7,13 +7,13 @@ import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeap import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { isLeagueRoundLocked } from "~/features/tournament/tournament-utils"; +import { MatchStatus as Status } from "~/features/tournament-bracket/core/engine/types"; import * as PickBan from "~/features/tournament-bracket/core/PickBan"; import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server"; import { matchPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils"; import * as UserCardRepository from "~/features/user-card/UserCardRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { Status } from "~/modules/brackets-model"; import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server"; import { IS_E2E_TEST_RUN } from "~/utils/e2e"; import { logger } from "~/utils/logger"; diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index cff891526..a16664124 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -15,11 +15,9 @@ import { identifierToUserIds } from "~/features/mmr/mmr-utils"; import * as Progression from "~/features/tournament-bracket/core/Progression"; import type { TournamentSummary } from "~/features/tournament-bracket/core/summarizer.server"; import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; -import { Status } from "~/modules/brackets-model"; import { modesShort } from "~/modules/in-game-lists/modes"; import { nullFilledArray, nullifyingAvg } from "~/utils/arrays"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; -import { shortNanoid } from "~/utils/id"; import invariant from "~/utils/invariant"; import { commonUserSelect, @@ -1049,30 +1047,6 @@ export function addPickBanEvent( return db.insertInto("TournamentMatchPickBanEvent").values(values).execute(); } -export function resetBracket(tournamentStageId: number) { - return db.transaction().execute(async (trx) => { - await trx - .deleteFrom("TournamentMatch") - .where("stageId", "=", tournamentStageId) - .execute(); - - await trx - .deleteFrom("TournamentRound") - .where("stageId", "=", tournamentStageId) - .execute(); - - await trx - .deleteFrom("TournamentGroup") - .where("stageId", "=", tournamentStageId) - .execute(); - - await trx - .deleteFrom("TournamentStage") - .where("id", "=", tournamentStageId) - .execute(); - }); -} - export function reopenTournament(tournamentId: number) { return db.transaction().execute(async (trx) => { await trx @@ -1309,49 +1283,6 @@ export function finalizeWithoutSummary(tournamentId: number) { .execute(); } -export type TournamentRepositoryInsertableMatch = Omit< - Insertable, - "status" | "chatCode" ->; - -export function insertSwissMatches( - matches: TournamentRepositoryInsertableMatch[], -) { - if (matches.length === 0) { - throw new Error("No matches to insert"); - } - - return db - .insertInto("TournamentMatch") - .values( - matches.map((match) => ({ - groupId: match.groupId, - number: match.number, - opponentOne: match.opponentOne, - opponentTwo: match.opponentTwo, - roundId: match.roundId, - stageId: match.stageId, - status: Status.Ready, - chatCode: shortNanoid(), - })), - ) - .execute(); -} - -export function deleteSwissMatches({ - groupId, - roundId, -}: { - groupId: number; - roundId: number; -}) { - return db - .deleteFrom("TournamentMatch") - .where("groupId", "=", groupId) - .where("roundId", "=", roundId) - .execute(); -} - export async function searchByName({ query, limit, diff --git a/app/features/tournament/core/Standings.test.ts b/app/features/tournament/core/Standings.test.ts index ae6fc0ad2..53b67f5c5 100644 --- a/app/features/tournament/core/Standings.test.ts +++ b/app/features/tournament/core/Standings.test.ts @@ -1,11 +1,10 @@ import { describe, expect, it } from "vitest"; +import { EngineBracket } from "~/features/tournament-bracket/core/engine/test-utils"; import { progressions, testTournament, tournamentCtxTeam, } from "~/features/tournament-bracket/core/tests/test-utils"; -import { BracketsManager } from "~/modules/brackets-manager"; -import { InMemoryDatabase } from "~/modules/brackets-memory-db"; import invariant from "~/utils/invariant"; import { matchesPlayed, @@ -158,17 +157,16 @@ describe("matchesPlayed", () => { }); function roundRobinToSingleEliminationTournament() { - const storage = new InMemoryDatabase(); - const manager = new BracketsManager(storage); + const bracket = new EngineBracket(); - manager.create({ + bracket.create({ name: "Main Bracket", tournamentId: 1, type: "round_robin", seeding: [1, 2, 3, 4], settings: { groupCount: 1, seedOrdering: ["groups.seed_optimized"] }, }); - manager.create({ + bracket.create({ name: "B1", tournamentId: 1, type: "single_elimination", @@ -178,8 +176,8 @@ function roundRobinToSingleEliminationTournament() { // play every match across both brackets, lower id always wins while (true) { - const pending = storage - .select("match")! + const pending = bracket + .matches() .find( (m) => typeof m.opponent1?.id === "number" && @@ -189,8 +187,8 @@ function roundRobinToSingleEliminationTournament() { ); if (!pending) break; - const winnerIsOpp1 = pending.opponent1.id < pending.opponent2.id; - manager.update.match({ + const winnerIsOpp1 = pending.opponent1!.id! < pending.opponent2!.id!; + bracket.updateMatch({ id: pending.id, opponent1: winnerIsOpp1 ? { score: 2, result: "win" } : { score: 0 }, opponent2: winnerIsOpp1 ? { score: 0 } : { score: 2, result: "win" }, @@ -209,15 +207,14 @@ function roundRobinToSingleEliminationTournament() { tournamentCtxTeam(4, { startingBracketIdx: 0, seed: 4 }), ], }, - data: manager.get.tournamentData(1), + data: bracket.data!, }); } function singleEliminationTournament() { - const storage = new InMemoryDatabase(); - const manager = new BracketsManager(storage); + const bracket = new EngineBracket(); - manager.create({ + bracket.create({ name: "Main Bracket", tournamentId: 1, type: "single_elimination", @@ -226,8 +223,8 @@ function singleEliminationTournament() { }); while (true) { - const pending = storage - .select("match")! + const pending = bracket + .matches() .find( (m) => typeof m.opponent1?.id === "number" && @@ -237,8 +234,8 @@ function singleEliminationTournament() { ); if (!pending) break; - const winnerIsOpp1 = pending.opponent1.id < pending.opponent2.id; - manager.update.match({ + const winnerIsOpp1 = pending.opponent1!.id! < pending.opponent2!.id!; + bracket.updateMatch({ id: pending.id, opponent1: winnerIsOpp1 ? { score: 2, result: "win" } : { score: 0 }, opponent2: winnerIsOpp1 ? { score: 0 } : { score: 2, result: "win" }, @@ -257,15 +254,14 @@ function singleEliminationTournament() { tournamentCtxTeam(4, { seed: 4 }), ], }, - data: manager.get.tournamentData(1), + data: bracket.data!, }); } function abDivisionsTournament() { - const storage = new InMemoryDatabase(); - const manager = new BracketsManager(storage); + const bracket = new EngineBracket(); - manager.create({ + bracket.create({ name: "AB RR", tournamentId: 1, type: "round_robin", @@ -284,15 +280,15 @@ function abDivisionsTournament() { "2-3": 2, "3-4": 3, }; - for (const match of storage.select("match")!) { - const a = match.opponent1.id as number; - const b = match.opponent2.id as number; + for (const match of bracket.matches()) { + const a = match.opponent1!.id as number; + const b = match.opponent2!.id as number; const key = a < b ? `${a}-${b}` : `${b}-${a}`; const winnerId = winnerByMatchup[key]; invariant(winnerId, `unexpected matchup ${key}`); const loserScore = key === "2-3" || key === "3-4" ? 1 : 0; - const winnerIsOpp1 = match.opponent1.id === winnerId; - manager.update.match({ + const winnerIsOpp1 = match.opponent1!.id === winnerId; + bracket.updateMatch({ id: match.id, opponent1: winnerIsOpp1 ? { score: 2, result: "win" } @@ -303,7 +299,7 @@ function abDivisionsTournament() { }); } - const data = manager.get.tournamentData(1); + const data = bracket.data!; return testTournament({ ctx: { diff --git a/app/features/tournament/queries/createSwissBracketInTransaction.server.ts b/app/features/tournament/queries/createSwissBracketInTransaction.server.ts deleted file mode 100644 index 3a9a1441e..000000000 --- a/app/features/tournament/queries/createSwissBracketInTransaction.server.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { sql } from "~/db/sql"; -import type { Tables } from "~/db/tables"; -import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; -import { databaseTimestampNow } from "~/utils/dates"; -import { shortNanoid } from "~/utils/id"; -import invariant from "~/utils/invariant"; - -const createTournamentStageStm = sql.prepare(/* sql */ ` - insert into "TournamentStage" ( - "tournamentId", - "type", - "createdAt", - "settings", - "number", - "name" - ) values ( - @tournamentId, - @type, - @createdAt, - @settings, - (select coalesce(max("number"), 0) + 1 from "TournamentStage" where "tournamentId" = @tournamentId), - @name - ) returning * -`); - -const createTournamentGroupStm = sql.prepare(/* sql */ ` - insert into "TournamentGroup" ( - "number", - "stageId" - ) values ( - @number, - @stageId - ) returning * -`); - -const createTournamentRoundStm = sql.prepare(/* sql */ ` - insert into "TournamentRound" ( - "groupId", - "number", - "stageId" - ) values ( - @groupId, - @number, - @stageId - ) returning * -`); - -const createTournamentMatchStm = sql.prepare(/* sql */ ` - insert into "TournamentMatch" ( - "chatCode", - "groupId", - "number", - "opponentOne", - "opponentTwo", - "roundId", - "stageId", - "status" - ) values ( - @chatCode, - @groupId, - @number, - @opponentOne, - @opponentTwo, - @roundId, - @stageId, - @status - ) -`); - -export function createSwissBracketInTransaction( - input: TournamentManagerDataSet, -) { - const stageInput = input.stage[0]; - invariant(stageInput, "Stage input is required"); - invariant(stageInput.type === "swiss", "Invalid stage type"); - - const stageFromDB = createTournamentStageStm.get({ - tournamentId: stageInput.tournament_id, - type: stageInput.type, - createdAt: databaseTimestampNow(), - settings: JSON.stringify(stageInput.settings), - name: stageInput.name, - }) as Tables["TournamentStage"]; - - for (const group of input.group) { - const groupFromDB = createTournamentGroupStm.get({ - number: group.number, - stageId: stageFromDB.id, - }) as Tables["TournamentGroup"]; - - for (const round of input.round) { - if (round.group_id !== group.id) { - continue; - } - - const roundFromDB = createTournamentRoundStm.get({ - groupId: groupFromDB.id, - number: round.number, - stageId: stageFromDB.id, - }) as Tables["TournamentRound"]; - - for (const match of input.match) { - if (match.round_id !== round.id) { - continue; - } - - createTournamentMatchStm.run({ - chatCode: shortNanoid(), - groupId: groupFromDB.id, - number: match.number, - opponentOne: match.opponent1 - ? JSON.stringify(match.opponent1) - : "null", - opponentTwo: match.opponent2 - ? JSON.stringify(match.opponent2) - : "null", - roundId: roundFromDB.id, - stageId: stageFromDB.id, - status: match.status, - }); - } - } - } - - return stageFromDB; -} diff --git a/app/features/tournament/tournament-test-utils.ts b/app/features/tournament/tournament-test-utils.ts index 2b6206579..a78be91ad 100644 --- a/app/features/tournament/tournament-test-utils.ts +++ b/app/features/tournament/tournament-test-utils.ts @@ -2,7 +2,8 @@ import * as CalendarRepository from "~/features/calendar/CalendarRepository.serv import { databaseTimestampNow } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { withUserId } from "~/utils/Test"; -import { getServerTournamentManager } from "../tournament-bracket/core/brackets-manager/manager.server"; +import * as BracketRepository from "../tournament-bracket/BracketRepository.server"; +import * as Engine from "../tournament-bracket/core/engine"; import { tournamentFromDB } from "../tournament-bracket/core/Tournament.server"; import { updateRoundMaps } from "./queries/updateRoundMaps.server"; import * as TournamentTeamRepository from "./TournamentTeamRepository.server"; @@ -99,8 +100,6 @@ export async function dbInsertTournamentTeam({ * Assumes that the tournament has only one bracket and one round. */ export async function dbStartTournament(seeding: number[], tournamentId = 1) { - const manager = getServerTournamentManager(); - const tournament = await tournamentFromDB({ tournamentId, user: undefined, @@ -118,12 +117,15 @@ export async function dbStartTournament(seeding: number[], tournamentId = 1) { seeding.length, ); - manager.create({ + await BracketRepository.insertBracket({ tournamentId: tournament.ctx.id, - name: bracket.name, - type: bracket.type, - seeding, - settings, + bracket: Engine.create({ + tournamentId: tournament.ctx.id, + name: bracket.name, + type: bracket.type, + seeding, + settings, + }), }); // assuming here every tournament has only one round diff --git a/app/features/tournament/tournament-utils.server.ts b/app/features/tournament/tournament-utils.server.ts index 2a2e1263c..57910de26 100644 --- a/app/features/tournament/tournament-utils.server.ts +++ b/app/features/tournament/tournament-utils.server.ts @@ -1,5 +1,5 @@ import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server"; -import type { getServerTournamentManager } from "~/features/tournament-bracket/core/brackets-manager/manager.server"; +import * as Engine from "~/features/tournament-bracket/core/engine"; import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; import { logger } from "~/utils/logger"; import { errorToast, errorToastIfFalsy } from "~/utils/remix.server"; @@ -69,68 +69,39 @@ export async function requireSendouQParticipationIfNeeded({ /** * Ends all unfinished matches involving dropped teams by awarding wins to their opponents. - * If both teams in a match have dropped, a random winner is selected. + * If both teams in a match have dropped, a random winner is selected. Pure over the given + * bracket data — the caller persists the returned changedMatches. * * @param tournament - The tournament instance - * @param manager - The bracket manager instance used to update matches + * @param data - The bracket data to end matches in (fresh rows or the previous engine result) * @param droppedTeamId - Optional team ID to filter matches for a specific dropped team. * If omitted, processes all matches with any dropped team. */ export function endDroppedTeamMatches({ tournament, - manager, + data, droppedTeamId, }: { tournament: Tournament; - manager: ReturnType; + data: Engine.BracketData; droppedTeamId?: number; }) { - const stageData = manager.get.tournamentData(tournament.ctx.id); + const droppedTeamIds = tournament.ctx.teams + .filter((team) => team.droppedOut) + .map((team) => team.id); + if (typeof droppedTeamId === "number") droppedTeamIds.push(droppedTeamId); - const endedMatchIds: number[] = []; - - for (const match of stageData.match) { - if (!match.opponent1?.id || !match.opponent2?.id) continue; - if (match.opponent1.result === "win" || match.opponent2.result === "win") - continue; - - const team1 = tournament.teamById(match.opponent1.id); - const team2 = tournament.teamById(match.opponent2.id); - - const team1Dropped = - team1?.droppedOut || match.opponent1.id === droppedTeamId; - const team2Dropped = - team2?.droppedOut || match.opponent2.id === droppedTeamId; - - if (!team1Dropped && !team2Dropped) continue; - - const winnerTeamId = (() => { - if (team1Dropped && !team2Dropped) return match.opponent2.id; - if (!team1Dropped && team2Dropped) return match.opponent1.id; - return Math.random() < 0.5 ? match.opponent1.id : match.opponent2.id; - })(); + const result = Engine.endDroppedTeamMatches(data, { + droppedTeamIds, + randomPick: (a, b) => (Math.random() < 0.5 ? a : b), // xxx: why randomPick a param like this? + }); + // xxx: maybe move to caller? or repository function + for (const matchId of result.endedMatchIds) { logger.info( - `Ending match with dropped team: Match ID: ${match.id}; Team1 dropped: ${team1Dropped}; Team2 dropped: ${team2Dropped}; Winner: ${winnerTeamId}`, + `Ending match with dropped team: Match ID: ${matchId}; Dropped team ids: ${droppedTeamIds.join(", ")}`, ); - - manager.update.match( - { - id: match.id, - opponent1: { - score: match.opponent1.score, - result: winnerTeamId === match.opponent1.id ? "win" : "loss", - }, - opponent2: { - score: match.opponent2.score, - result: winnerTeamId === match.opponent2.id ? "win" : "loss", - }, - }, - true, - ); - - endedMatchIds.push(match.id); } - return endedMatchIds; + return result; } diff --git a/app/modules/brackets-manager/README.md b/app/modules/brackets-manager/README.md deleted file mode 100644 index 8a6975699..000000000 --- a/app/modules/brackets-manager/README.md +++ /dev/null @@ -1 +0,0 @@ -Taken from https://github.com/Drarig29/brackets-manager.js diff --git a/app/modules/brackets-manager/base/updater.ts b/app/modules/brackets-manager/base/updater.ts deleted file mode 100644 index 31135535d..000000000 --- a/app/modules/brackets-manager/base/updater.ts +++ /dev/null @@ -1,331 +0,0 @@ -import type { GroupType, Match, Stage } from "~/modules/brackets-model"; -import { Status } from "~/modules/brackets-model"; -import type { SetNextOpponent } from "../helpers"; -import * as helpers from "../helpers"; -import type { DeepPartial, Side } from "../types"; -import { BaseGetter } from "./getter"; - -export class BaseUpdater extends BaseGetter { - /** - * Updates the matches related (previous and next) to a match. - * - * @param match A match. - * @param updatePrevious Whether to update the previous matches. - * @param updateNext Whether to update the next matches. - */ - protected updateRelatedMatches( - match: Match, - updatePrevious: boolean, - updateNext: boolean, - ): void { - const { roundNumber, roundCount } = this.getRoundPositionalInfo( - match.round_id, - ); - - const stage = this.storage.select("stage", match.stage_id); - if (!stage) throw Error("Stage not found."); - - const group = this.storage.select("group", match.group_id); - if (!group) throw Error("Group not found."); - - const matchLocation = helpers.getMatchLocation(stage.type, group.number); - - updatePrevious && - this.updatePrevious(match, matchLocation, stage, roundNumber); - updateNext && - this.updateNext(match, matchLocation, stage, roundNumber, roundCount); - } - - /** - * Updates a match based on a partial match. - * - * @param stored A reference to what will be updated in the storage. - * @param match Input of the update. - * @param force Whether to force update locked matches. - */ - protected updateMatch( - stored: Match, - match: DeepPartial, - force?: boolean, - ): void { - if (!force && helpers.isMatchUpdateLocked(stored)) - throw Error("The match is locked."); - - const stage = this.storage.select("stage", stored.stage_id); - if (!stage) throw Error("Stage not found."); - - const { statusChanged, resultChanged } = helpers.setMatchResults( - stored, - match, - ); - this.applyMatchUpdate(stored); - - // Don't update related matches if it's a simple score update. - if (!statusChanged && !resultChanged) return; - - if (!helpers.isRoundRobin(stage) && !helpers.isSwiss(stage)) { - this.updateRelatedMatches(stored, statusChanged, resultChanged); - } else if (helpers.isRoundRobin(stage) && resultChanged) { - this.unlockNextRoundRobinRound(stored); - } - } - - /** - * Updates the opponents and status of a match and its child games. - * - * @param match A match. - */ - protected applyMatchUpdate(match: Match): void { - if (!this.storage.update("match", match.id, match)) - throw Error("Could not update the match."); - } - - /** - * Updates the match(es) leading to the current match based on this match results. - * - * @param match Input of the update. - * @param matchLocation Location of the current match. - * @param stage The parent stage. - * @param roundNumber Number of the round. - */ - protected updatePrevious( - match: Match, - matchLocation: GroupType, - stage: Stage, - roundNumber: number, - ): void { - const previousMatches = this.getPreviousMatches( - match, - matchLocation, - stage, - roundNumber, - ); - if (previousMatches.length === 0) return; - - if (match.status < Status.Running) { - this.resetMatchesStatus(previousMatches); - } - } - - /** - * Resets the status of a list of matches to what it should currently be. - * - * @param matches The matches to update. - */ - protected resetMatchesStatus(matches: Match[]): void { - for (const match of matches) { - match.status = helpers.getMatchStatus(match); - this.applyMatchUpdate(match); - } - } - - /** - * Updates the match(es) following the current match based on this match results. - * - * @param match Input of the update. - * @param matchLocation Location of the current match. - * @param stage The parent stage. - * @param roundNumber Number of the round. - * @param roundCount Count of rounds. - */ - protected updateNext( - match: Match, - matchLocation: GroupType, - stage: Stage, - roundNumber: number, - roundCount: number, - ): void { - const nextMatches = this.getNextMatches( - match, - matchLocation, - stage, - roundNumber, - roundCount, - ); - if (nextMatches.length === 0) { - return; - } - - const winnerSide = helpers.getMatchResult(match); - const actualRoundNumber = - stage.settings.skipFirstRound && matchLocation === "winner_bracket" - ? roundNumber + 1 - : roundNumber; - - if (winnerSide) - this.applyToNextMatches( - helpers.setNextOpponent, - match, - matchLocation, - actualRoundNumber, - roundCount, - nextMatches, - winnerSide, - ); - else - this.applyToNextMatches( - helpers.resetNextOpponent, - match, - matchLocation, - actualRoundNumber, - roundCount, - nextMatches, - ); - } - - /** - * Applies a SetNextOpponent function to matches following the current match. - * - * @param setNextOpponent The SetNextOpponent function. - * @param match The current match. - * @param matchLocation Location of the current match. - * @param roundNumber Number of the current round. - * @param roundCount Count of rounds. - * @param nextMatches The matches following the current match. - * @param winnerSide Side of the winner in the current match. - */ - protected applyToNextMatches( - setNextOpponent: SetNextOpponent, - match: Match, - matchLocation: GroupType, - roundNumber: number, - roundCount: number, - nextMatches: (Match | null)[], - winnerSide?: Side, - ): void { - if (matchLocation === "final_group") { - if (!nextMatches[0]) throw Error("First next match is null."); - setNextOpponent(nextMatches[0], "opponent1", match, "opponent1"); - setNextOpponent(nextMatches[0], "opponent2", match, "opponent2"); - this.applyMatchUpdate(nextMatches[0]); - return; - } - - const nextSide = helpers.getNextSide( - match.number, - roundNumber, - roundCount, - matchLocation, - ); - - if (nextMatches[0]) { - setNextOpponent(nextMatches[0], nextSide, match, winnerSide); - this.propagateByeWinners(nextMatches[0]); - } - - if (nextMatches.length !== 2) return; - if (!nextMatches[1]) throw Error("Second next match is null."); - - // The second match is either the consolation final (single elimination) or a loser bracket match (double elimination). - - if (matchLocation === "single_bracket") { - setNextOpponent( - nextMatches[1], - nextSide, - match, - winnerSide && helpers.getOtherSide(winnerSide), - ); - this.applyMatchUpdate(nextMatches[1]); - } else { - const nextSideLB = helpers.getNextSideLoserBracket( - match.number, - nextMatches[1], - roundNumber, - ); - setNextOpponent( - nextMatches[1], - nextSideLB, - match, - winnerSide && helpers.getOtherSide(winnerSide), - ); - this.propagateByeWinners(nextMatches[1]); - } - } - - /** - * Propagates winner against BYEs in related matches. - * - * @param match The current match. - */ - protected propagateByeWinners(match: Match): void { - helpers.setMatchResults(match, match); // BYE propagation is only in non round-robin stages. - this.applyMatchUpdate(match); - - if (helpers.hasBye(match)) this.updateRelatedMatches(match, true, true); - } - - /** - * Unlocks matches in the next round of a round-robin group if both participants are ready. - * - * @param match The match that was just completed. - */ - protected unlockNextRoundRobinRound(match: Match): void { - const round = this.storage.select("round", match.round_id); - if (!round) throw Error("Round not found."); - - const nextRound = this.storage.selectFirst("round", { - group_id: round.group_id, - number: round.number + 1, - }); - if (!nextRound) return; - - const currentRoundMatches = this.storage.select("match", { - round_id: round.id, - }); - if (!currentRoundMatches) return; - - const nextRoundMatches = this.storage.select("match", { - round_id: nextRound.id, - }); - if (!nextRoundMatches) return; - - for (const nextMatch of nextRoundMatches) { - if (nextMatch.status !== Status.Locked) continue; - - const participant1Id = nextMatch.opponent1?.id; - const participant2Id = nextMatch.opponent2?.id; - - if (!participant1Id || !participant2Id) continue; - - const participant1Ready = this.isParticipantReadyForNextRound( - participant1Id, - currentRoundMatches, - ); - const participant2Ready = this.isParticipantReadyForNextRound( - participant2Id, - currentRoundMatches, - ); - - if (participant1Ready && participant2Ready) { - nextMatch.status = Status.Ready; - this.applyMatchUpdate(nextMatch); - } - } - } - - /** - * Checks if a participant has completed their match in the current round. - * - * @param participantId The participant to check. - * @param roundMatches All matches in the round. - */ - protected isParticipantReadyForNextRound( - participantId: number, - roundMatches: Match[], - ): boolean { - const participantMatch = roundMatches.find( - (m) => - m.opponent1?.id === participantId || m.opponent2?.id === participantId, - ); - - // If the participant doesn't have a match in this round, they had a bye/didn't play - // and are considered ready - if (!participantMatch) return true; - - // If the match has a BYE (one opponent is null), it's considered completed - if (!participantMatch.opponent1?.id || !participantMatch.opponent2?.id) - return true; - - return participantMatch.status >= Status.Completed; - } -} diff --git a/app/modules/brackets-manager/create.ts b/app/modules/brackets-manager/create.ts deleted file mode 100644 index 124a99046..000000000 --- a/app/modules/brackets-manager/create.ts +++ /dev/null @@ -1,975 +0,0 @@ -import { - type Group, - type InputStage, - type Match, - type Round, - type Seeding, - type SeedOrdering, - type Stage, - Status, -} from "~/modules/brackets-model"; -import type { BracketsManager } from "."; -import * as helpers from "./helpers"; -import { defaultMinorOrdering, ordering } from "./ordering"; -import type { - Duel, - OmitId, - ParticipantSlot, - StandardBracketResults, - Storage, -} from "./types"; - -/** - * Creates a stage. - * - * @param this Instance of BracketsManager. - * @param stage The stage to create. - */ -export function create(this: BracketsManager, stage: InputStage): Stage { - const instance = new Create(this.storage, stage); - return instance.run(); -} - -class Create { - private storage: Storage; - private stage: InputStage; - private readonly seedOrdering: SeedOrdering[]; - private enableByesInUpdate: boolean; - - /** - * Creates an instance of Create, which will handle the creation of the stage. - * - * @param storage The implementation of Storage. - * @param stage The stage to create. - */ - constructor(storage: Storage, stage: InputStage) { - this.storage = storage; - this.stage = stage; - this.stage.settings = this.stage.settings || {}; - this.seedOrdering = this.stage.settings.seedOrdering || []; - this.enableByesInUpdate = false; - - if (!this.stage.name) throw Error("You must provide a name for the stage."); - - if (!Number.isInteger(this.stage.tournamentId)) - throw Error("You must provide a tournament id for the stage."); - - if (stage.type === "round_robin") - this.stage.settings.roundRobinMode = - this.stage.settings.roundRobinMode || "simple"; - - if (stage.type === "single_elimination") - this.stage.settings.consolationFinal = - this.stage.settings.consolationFinal || false; - - if (stage.type === "double_elimination") - this.stage.settings.grandFinal = this.stage.settings.grandFinal || "none"; - } - - /** - * Run the creation process. - */ - public run(): Stage { - let stage: Stage; - - switch (this.stage.type) { - case "round_robin": - stage = this.roundRobin(); - break; - case "single_elimination": - stage = this.singleElimination(); - break; - case "double_elimination": - stage = this.doubleElimination(); - break; - default: - throw Error("Unknown stage type."); - } - - if (stage.id === -1) - throw Error("Something went wrong when creating the stage."); - - this.ensureSeedOrdering(stage.id); - - return stage; - } - - /** - * Creates a round-robin stage. - * - * Group count must be given. It will distribute participants in groups and rounds. - */ - private roundRobin(): Stage { - if (this.stage.settings?.hasAbDivisions) return this.abDivisionRoundRobin(); - - const groups = this.getRoundRobinGroups(); - const stage = this.createStage(); - - for (let i = 0; i < groups.length; i++) - this.createRoundRobinGroup(stage.id, i + 1, groups[i]); - - return stage; - } - - /** - * Creates a bipartite (A/B divisions) round-robin stage. - * - * Participants are partitioned into two pools by `abDivisions` (parallel to the seeding). - * Each group receives equal A and B teams, and matches only pair A against B. - */ - private abDivisionRoundRobin(): Stage { - const groups = this.getAbDivisionGroups(); - const stage = this.createStage(); - - for (let i = 0; i < groups.length; i++) - this.createAbDivisionRoundRobinGroup( - stage.id, - i + 1, - groups[i].a, - groups[i].b, - ); - - return stage; - } - - /** - * Creates a single elimination stage. - * - * One bracket and optionally a consolation final between semi-final losers. - */ - private singleElimination(): Stage { - if ( - Array.isArray(this.stage.settings?.seedOrdering) && - this.stage.settings?.seedOrdering.length !== 1 - ) - throw Error("You must specify one seed ordering method."); - - const slots = this.getSlots(); - const stage = this.createStage(); - const method = this.getStandardBracketFirstRoundOrdering(); - const ordered = ordering[method](slots); - - const { losers } = this.createStandardBracket(stage.id, 1, ordered); - this.createConsolationFinal(stage.id, losers); - - return stage; - } - - /** - * Creates a double elimination stage. - * - * One upper bracket (winner bracket, WB), one lower bracket (loser bracket, LB) and optionally a grand final - * between the winner of both bracket, which can be simple or double. - */ - private doubleElimination(): Stage { - if ( - this.stage.settings && - Array.isArray(this.stage.settings.seedOrdering) && - this.stage.settings.seedOrdering.length < 1 - ) - throw Error("You must specify at least one seed ordering method."); - - const slots = this.getSlots(); - const stage = this.createStage(); - const method = this.getStandardBracketFirstRoundOrdering(); - const ordered = ordering[method](slots); - - if (this.stage.settings?.skipFirstRound) - this.createDoubleEliminationSkipFirstRound(stage.id, ordered); - else this.createDoubleElimination(stage.id, ordered); - - return stage; - } - - /** - * Creates a double elimination stage with skip first round option. - * - * @param stageId ID of the stage. - * @param slots A list of slots. - */ - private createDoubleEliminationSkipFirstRound( - stageId: number, - slots: ParticipantSlot[], - ): void { - const { even: directInWb, odd: directInLb } = helpers.splitByParity(slots); - const { losers: losersWb, winner: winnerWb } = this.createStandardBracket( - stageId, - 1, - directInWb, - ); - - // biome-ignore lint/suspicious/noNonNullAssertedOptionalChain: Biome 2.3.1 upgrade - if (helpers.isDoubleEliminationNecessary(this.stage.settings?.size!)) { - const winnerLb = this.createLowerBracket(stageId, 2, [ - directInLb, - ...losersWb, - ]); - this.createGrandFinal(stageId, winnerWb, winnerLb); - } - } - - /** - * Creates a double elimination stage. - * - * @param stageId ID of the stage. - * @param slots A list of slots. - */ - private createDoubleElimination( - stageId: number, - slots: ParticipantSlot[], - ): void { - const { losers: losersWb, winner: winnerWb } = this.createStandardBracket( - stageId, - 1, - slots, - ); - - // biome-ignore lint/suspicious/noNonNullAssertedOptionalChain: Biome 2.3.1 upgrade - if (helpers.isDoubleEliminationNecessary(this.stage.settings?.size!)) { - const winnerLb = this.createLowerBracket(stageId, 2, losersWb); - this.createGrandFinal(stageId, winnerWb, winnerLb); - } - } - - /** - * Creates a round-robin group. - * - * This will make as many rounds as needed to let each participant match every other once. - * - * @param stageId ID of the parent stage. - * @param number Number in the stage. - * @param slots A list of slots. - */ - private createRoundRobinGroup( - stageId: number, - number: number, - slots: ParticipantSlot[], - ): void { - const groupId = this.insertGroup({ - stage_id: stageId, - number, - }); - - if (groupId === -1) throw Error("Could not insert the group."); - - // Groups can be padded with empty slots when teams don't divide evenly - // (`null`) or by the seed ordering (`undefined`). An empty slot is just an - // absent team, so drop it to round-robin only the present teams — otherwise - // the padding becomes BYE rounds that strand real matches in later rounds. - // TBD slots (`{ id: null }`) are kept; only nullish placeholders are removed. - const presentSlots = slots.filter( - (slot) => slot !== null && slot !== undefined, - ); - - const rounds = helpers.makeRoundRobinMatches( - presentSlots, - this.stage.settings?.roundRobinMode, - ); - - for (let i = 0; i < rounds.length; i++) - this.createRound(stageId, groupId, i + 1, rounds[0].length, rounds[i]); - } - - /** - * Creates a bipartite round-robin group where every A team plays every B team exactly once. - * - * @param stageId ID of the parent stage. - * @param number Number in the stage. - * @param slotsA Slots in division A (ordered by seed). - * @param slotsB Slots in division B (ordered by seed). - */ - private createAbDivisionRoundRobinGroup( - stageId: number, - number: number, - slotsA: ParticipantSlot[], - slotsB: ParticipantSlot[], - ): void { - const groupId = this.insertGroup({ - stage_id: stageId, - number, - }); - - if (groupId === -1) throw Error("Could not insert the group."); - - const rounds = helpers.makeAbDivisionRoundRobinMatches(slotsA, slotsB); - - for (let i = 0; i < rounds.length; i++) - this.createRound(stageId, groupId, i + 1, rounds[0].length, rounds[i]); - } - - /** - * Creates a standard bracket, which is the only one in single elimination and the upper one in double elimination. - * - * This will make as many rounds as needed to end with one winner. - * - * @param stageId ID of the parent stage. - * @param number Number in the stage. - * @param slots A list of slots. - */ - private createStandardBracket( - stageId: number, - number: number, - slots: ParticipantSlot[], - ): StandardBracketResults { - const roundCount = helpers.getUpperBracketRoundCount(slots.length); - const groupId = this.insertGroup({ - stage_id: stageId, - number, - }); - - if (groupId === -1) throw Error("Could not insert the group."); - - let duels = helpers.makePairs(slots); - let roundNumber = 1; - - const losers: ParticipantSlot[][] = []; - - for (let i = roundCount - 1; i >= 0; i--) { - const matchCount = 2 ** i; - duels = this.getCurrentDuels(duels, matchCount); - losers.push(duels.map(helpers.byeLoser)); - this.createRound(stageId, groupId, roundNumber++, matchCount, duels); - } - - return { losers, winner: helpers.byeWinner(duels[0]) }; - } - - /** - * Creates a lower bracket, alternating between major and minor rounds. - * - * - A major round is a regular round. - * - A minor round matches the previous (major) round's winners against upper bracket losers of the corresponding round. - * - * @param stageId ID of the parent stage. - * @param number Number in the stage. - * @param losers One list of losers per upper bracket round. - */ - private createLowerBracket( - stageId: number, - number: number, - losers: ParticipantSlot[][], - ): ParticipantSlot { - // biome-ignore lint/suspicious/noNonNullAssertedOptionalChain: Biome 2.3.1 upgrade - const participantCount = this.stage.settings?.size!; - const roundPairCount = helpers.getRoundPairCount(participantCount); - - let losersId = 0; - - const method = this.getMajorOrdering(participantCount); - const ordered = ordering[method](losers[losersId++]); - - const groupId = this.insertGroup({ - stage_id: stageId, - number, - }); - - if (groupId === -1) throw Error("Could not insert the group."); - - let duels = helpers.makePairs(ordered); - let roundNumber = 1; - - for (let i = 0; i < roundPairCount; i++) { - const matchCount = 2 ** (roundPairCount - i - 1); - - // Major round. - duels = this.getCurrentDuels(duels, matchCount, true); - this.createRound(stageId, groupId, roundNumber++, matchCount, duels); - - // Minor round. - const minorOrdering = this.getMinorOrdering( - participantCount, - i, - roundPairCount, - ); - duels = this.getCurrentDuels( - duels, - matchCount, - false, - losers[losersId++], - minorOrdering, - ); - this.createRound(stageId, groupId, roundNumber++, matchCount, duels); - } - - return helpers.byeWinnerToGrandFinal(duels[0]); - } - - /** - * Creates a bracket with rounds that only have 1 match each. Used for finals. - * - * @param stageId ID of the parent stage. - * @param number Number in the stage. - * @param duels A list of duels. - */ - private createUniqueMatchBracket( - stageId: number, - number: number, - duels: Duel[], - ): void { - const groupId = this.insertGroup({ - stage_id: stageId, - number, - }); - - if (groupId === -1) throw Error("Could not insert the group."); - - for (let i = 0; i < duels.length; i++) - this.createRound(stageId, groupId, i + 1, 1, [duels[i]]); - } - - /** - * Creates a round, which contain matches. - * - * @param stageId ID of the parent stage. - * @param groupId ID of the parent group. - * @param roundNumber Number in the group. - * @param matchCount Duel/match count. - * @param duels A list of duels. - */ - private createRound( - stageId: number, - groupId: number, - roundNumber: number, - matchCount: number, - duels: Duel[], - ): void { - const roundId = this.insertRound({ - number: roundNumber, - stage_id: stageId, - group_id: groupId, - }); - - if (roundId === -1) throw Error("Could not insert the round."); - - for (let i = 0; i < matchCount; i++) { - this.createMatch(stageId, groupId, roundId, i + 1, roundNumber, duels[i]); - } - } - - /** - * Creates a match, possibly with match games. - * - * @param stageId ID of the parent stage. - * @param groupId ID of the parent group. - * @param roundId ID of the parent round. - * @param matchNumber Number in the round. - * @param opponents The two opponents matching against each other. - */ - private createMatch( - stageId: number, - groupId: number, - roundId: number, - matchNumber: number, - roundNumber: number, - opponents: Duel, - ): void { - const opponent1 = helpers.toResultWithPosition(opponents[0]); - const opponent2 = helpers.toResultWithPosition(opponents[1]); - - // Round-robin matches can easily be removed. Prevent BYE vs. BYE matches. - if ( - this.stage.type === "round_robin" && - opponent1 === null && - opponent2 === null - ) - return; - - let status = helpers.getMatchStatus(opponents); - - // In round-robin, only the first round is ready to play at the beginning. - // other matches have teams set but they are busy playing the first round. - if ( - this.stage.type === "round_robin" && - roundNumber > 1 && - !this.stage.settings?.independentRounds - ) { - status = Status.Locked; - } - - const parentId = this.insertMatch( - { - number: matchNumber, - stage_id: stageId, - group_id: groupId, - round_id: roundId, - status, - opponent1, - opponent2, - }, - null, - ); - - if (parentId === -1) throw Error("Could not insert the match."); - } - - /** - * Gets the duels for the current round based on the previous one. No ordering is done, it must be done beforehand for the first round. - * - * @param previousDuels Duels of the previous round. - * @param currentDuelCount Count of duels (matches) in the current round. - */ - private getCurrentDuels( - previousDuels: Duel[], - currentDuelCount: number, - ): Duel[]; - - /** - * Gets the duels for a major round in the LB. No ordering is done, it must be done beforehand for the first round. - * - * @param previousDuels Duels of the previous round. - * @param currentDuelCount Count of duels (matches) in the current round. - * @param major Indicates that the round is a major round in the LB. - */ - private getCurrentDuels( - previousDuels: Duel[], - currentDuelCount: number, - major: true, - ): Duel[]; - - /** - * Gets the duels for a minor round in the LB. Ordering is done. - * - * @param previousDuels Duels of the previous round. - * @param currentDuelCount Count of duels (matches) in the current round. - * @param major Indicates that the round is a minor round in the LB. - * @param losers The losers going from the WB. - * @param method The ordering method to apply to the losers. - */ - private getCurrentDuels( - previousDuels: Duel[], - currentDuelCount: number, - major: false, - losers: ParticipantSlot[], - method?: SeedOrdering, - ): Duel[]; - - /** - * Generic implementation. - * - * @param previousDuels Always given. - * @param currentDuelCount Always given. - * @param major Only for loser bracket. - * @param losers Only for minor rounds of loser bracket. - * @param method Only for minor rounds. Ordering method for the losers. - */ - private getCurrentDuels( - previousDuels: Duel[], - currentDuelCount: number, - major?: boolean, - losers?: ParticipantSlot[], - method?: SeedOrdering, - ): Duel[] { - if ( - (major === undefined || major) && - previousDuels.length === currentDuelCount - ) { - // First round. - return previousDuels; - } - - if (major === undefined || major) { - // From major to major (WB) or minor to major (LB). - return helpers.transitionToMajor(previousDuels); - } - - // From major to minor (LB). - // Losers and method won't be undefined. - return helpers.transitionToMinor(previousDuels, losers!, method); - } - - /** - * Returns a list of slots. - * - If `seeding` was given, inserts them in the storage. - * - If `size` was given, only returns a list of empty slots. - * - * @param positions An optional list of positions (seeds) for a manual ordering. - */ - public getSlots(positions?: number[]): ParticipantSlot[] { - const size = this.stage.settings?.size || this.stage.seeding?.length || 0; - helpers.ensureValidSize(this.stage.type, size); - - if (size && !this.stage.seeding) - return Array.from(Array(size), (_: ParticipantSlot, i) => ({ - id: null, - position: i + 1, - })); - - if (!this.stage.seeding) - throw Error("Either size or seeding must be given."); - - this.stage.settings = { - ...this.stage.settings, - size, // Always set the size. - }; - - helpers.ensureNoDuplicates(this.stage.seeding); - this.stage.seeding = helpers.fixSeeding(this.stage.seeding, size); - - if (this.stage.type !== "round_robin" && this.stage.settings.balanceByes) - this.stage.seeding = helpers.balanceByes( - this.stage.seeding, - this.stage.settings.size, - ); - - return this.getSlotsUsingIds(this.stage.seeding, positions); - } - - /** - * Returns the list of slots with a seeding containing IDs. No database mutation. - * - * @param seeding The seeding (IDs). - * @param positions An optional list of positions (seeds) for a manual ordering. - */ - private getSlotsUsingIds( - seeding: Seeding, - positions?: number[], - ): ParticipantSlot[] { - if (positions && positions.length !== seeding.length) { - throw Error( - "Not enough seeds in at least one group of the manual ordering.", - ); - } - - const slots = seeding.map((slot, i) => { - if (slot === null) return null; // BYE. - - return { id: slot, position: i + 1 }; - }); - - if (!positions) return slots; - - return positions.map((position) => slots[position - 1]); - } - - /** - * Gets the current stage number based on existing stages. - */ - private getStageNumber(): number { - const stages = this.storage.select("stage", { - tournament_id: this.stage.tournamentId, - }); - const stageNumbers = stages?.map((stage) => stage.number); - - if (this.stage.number !== undefined) { - if (stageNumbers?.includes(this.stage.number)) - throw Error("The given stage number already exists."); - - return this.stage.number; - } - - if (!stageNumbers?.length) return 1; - - const maxNumber = Math.max(...stageNumbers); - return maxNumber + 1; - } - - /** - * Safely gets an ordering by its index in the stage input settings. - * - * @param orderingIndex Index of the ordering. - * @param stageType A value indicating if the method should be a group method or not. - * @param defaultMethod The default method to use if not given. - */ - private getOrdering( - orderingIndex: number, - stageType: "elimination" | "groups", - defaultMethod: SeedOrdering, - ): SeedOrdering { - if (!this.stage.settings?.seedOrdering) { - this.seedOrdering.push(defaultMethod); - return defaultMethod; - } - - const method = this.stage.settings.seedOrdering[orderingIndex]; - if (!method) { - this.seedOrdering.push(defaultMethod); - return defaultMethod; - } - - if (stageType === "elimination" && method.match(/^groups\./)) - throw Error( - "You must specify a seed ordering method without a 'groups' prefix", - ); - - if ( - stageType === "groups" && - method !== "natural" && - !method.match(/^groups\./) - ) - throw Error( - "You must specify a seed ordering method with a 'groups' prefix", - ); - - return method; - } - - /** - * Gets the duels in groups for a round-robin stage. - */ - private getRoundRobinGroups(): ParticipantSlot[][] { - if ( - this.stage.settings?.groupCount === undefined || - !Number.isInteger(this.stage.settings.groupCount) - ) - throw Error("You must specify a group count for round-robin stages."); - - if (this.stage.settings.groupCount <= 0) - throw Error("You must provide a strictly positive group count."); - - if (this.stage.settings?.manualOrdering) { - if ( - this.stage.settings?.manualOrdering.length !== - this.stage.settings?.groupCount - ) - throw Error( - "Group count in the manual ordering does not correspond to the given group count.", - ); - - const positions = this.stage.settings?.manualOrdering.flat(); - const slots = this.getSlots(positions); - - return helpers.makeGroups(slots, this.stage.settings.groupCount); - } - - if ( - Array.isArray(this.stage.settings.seedOrdering) && - this.stage.settings.seedOrdering.length !== 1 - ) - throw Error("You must specify one seed ordering method."); - - const method = this.getRoundRobinOrdering(); - const slots = this.getSlots(); - const ordered = ordering[method](slots, this.stage.settings.groupCount); - return helpers.makeGroups(ordered, this.stage.settings.groupCount); - } - - /** - * Partitions the seeded slots into A and B pools then distributes them into groups - * such that each group has an equal number of A and B participants. - */ - private getAbDivisionGroups(): { - a: ParticipantSlot[]; - b: ParticipantSlot[]; - }[] { - if ( - this.stage.settings?.groupCount === undefined || - !Number.isInteger(this.stage.settings.groupCount) - ) - throw Error("You must specify a group count for round-robin stages."); - - if (this.stage.settings.groupCount <= 0) - throw Error("You must provide a strictly positive group count."); - - const abDivisions = this.stage.abDivisions; - if (!abDivisions) - throw Error( - "abDivisions must be provided when hasAbDivisions is enabled.", - ); - - const slots = this.getSlots(); - - if (abDivisions.length !== slots.length) - throw Error("abDivisions length must match the seeding length."); - - const divisionA: ParticipantSlot[] = []; - const divisionB: ParticipantSlot[] = []; - - for (let i = 0; i < slots.length; i++) { - const slot = slots[i]; - if (slot === null) - throw Error("BYEs are not supported with A/B divisions."); - - const division = abDivisions[i]; - if (division === 0) divisionA.push(slot); - else if (division === 1) divisionB.push(slot); - else - throw Error( - `Participant at seed ${i + 1} is missing an A/B division assignment.`, - ); - } - - return helpers.makeAbDivisionGroups( - divisionA, - divisionB, - this.stage.settings.groupCount, - ); - } - - /** - * Returns the ordering method for the groups in a round-robin stage. - */ - public getRoundRobinOrdering(): SeedOrdering { - return this.getOrdering(0, "groups", "groups.effort_balanced"); - } - - /** - * Returns the ordering method for the first round of the upper bracket of an elimination stage. - */ - public getStandardBracketFirstRoundOrdering(): SeedOrdering { - return this.getOrdering(0, "elimination", "space_between"); - } - - /** - * Safely gets the only major ordering for the lower bracket. - * - * @param participantCount Number of participants in the stage. - */ - private getMajorOrdering(participantCount: number): SeedOrdering { - return this.getOrdering( - 1, - "elimination", - defaultMinorOrdering[participantCount]?.[0] || "natural", - ); - } - - /** - * Safely gets a minor ordering for the lower bracket by its index. - * - * @param participantCount Number of participants in the stage. - * @param index Index of the minor round. - * @param minorRoundCount Number of minor rounds. - */ - private getMinorOrdering( - participantCount: number, - index: number, - minorRoundCount: number, - ): SeedOrdering | undefined { - // No ordering for the last minor round. There is only one participant to order. - if (index === minorRoundCount - 1) return undefined; - - return this.getOrdering( - 2 + index, - "elimination", - defaultMinorOrdering[participantCount]?.[1 + index] || "natural", - ); - } - - /** - * Inserts a stage or finds an existing one. - * - * @param stage The stage to insert. - */ - private insertStage(stage: OmitId): number { - return this.storage.insert("stage", stage); - } - - /** - * Inserts a group or finds an existing one. - * - * @param group The group to insert. - */ - private insertGroup(group: OmitId): number { - return this.storage.insert("group", group); - } - - /** - * Inserts a round or finds an existing one. - * - * @param round The round to insert. - */ - private insertRound(round: OmitId): number { - return this.storage.insert("round", round); - } - - /** - * Inserts a match or updates an existing one. - * - * @param match The match to insert. - * @param existing An existing match corresponding to the current one. - */ - private insertMatch(match: OmitId, existing: Match | null): number { - if (!existing) return this.storage.insert("match", match); - - const updated = helpers.getUpdatedMatchResults( - match, - existing, - this.enableByesInUpdate, - ) as Match; - if (!this.storage.update("match", existing.id, updated)) - throw Error("Could not update the match."); - - return existing.id; - } - - /** - * Creates a new stage. - */ - private createStage(): Stage { - const number = this.getStageNumber(); - const stage: OmitId = { - tournament_id: this.stage.tournamentId, - name: this.stage.name, - type: this.stage.type, - number: number, - settings: this.stage.settings || {}, - }; - - const stageId = this.insertStage(stage); - - if (stageId === -1) throw Error("Could not insert the stage."); - - return { ...stage, id: stageId }; - } - - /** - * Creates a consolation final for the semi final losers of a single elimination stage. - * - * @param stageId ID of the stage. - * @param losers The semi final losers who will play the consolation final. - */ - private createConsolationFinal( - stageId: number, - losers: ParticipantSlot[][], - ): void { - if (!this.stage.settings?.consolationFinal) return; - - const semiFinalLosers = losers[losers.length - 2] as Duel; - this.createUniqueMatchBracket(stageId, 2, [semiFinalLosers]); - } - - /** - * Creates a grand final (none, simple or double) for winners of both bracket in a double elimination stage. - * - * @param stageId ID of the stage. - * @param winnerWb The winner of the winner bracket. - * @param winnerLb The winner of the loser bracket. - */ - private createGrandFinal( - stageId: number, - winnerWb: ParticipantSlot, - winnerLb: ParticipantSlot, - ): void { - // No Grand Final by default. - const grandFinal = this.stage.settings?.grandFinal; - if (grandFinal === "none") return; - - // One duel by default. - const finalDuels: Duel[] = [[winnerWb, winnerLb]]; - - // Second duel. - if (grandFinal === "double") finalDuels.push([{ id: null }, { id: null }]); - - this.createUniqueMatchBracket(stageId, 3, finalDuels); - } - - /** - * Ensures that the seed ordering list is stored even if it was not given in the first place. - * - * @param stageId ID of the stage. - */ - private ensureSeedOrdering(stageId: number): void { - if (this.stage.settings?.seedOrdering?.length === this.seedOrdering.length) - return; - - const stage = this.storage.select("stage", stageId); - if (!stage) throw Error(`Stage not found. (stageId: ${stageId})`); - - stage.settings = { - ...stage.settings, - seedOrdering: this.seedOrdering, - }; - - if (!this.storage.update("stage", stageId, stage)) - throw Error("Could not update the stage."); - } -} diff --git a/app/modules/brackets-manager/get.ts b/app/modules/brackets-manager/get.ts deleted file mode 100644 index 5ce1c8b19..000000000 --- a/app/modules/brackets-manager/get.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { Group, Match, Round, Stage } from "~/modules/brackets-model"; -import { BaseGetter } from "./base/getter"; -import type { Database } from "./types"; - -export class Get extends BaseGetter { - /** - * Returns the data needed to display a stage. - * - * @param stageId ID of the stage. - */ - public stageData(stageId: number): Database { - const stageData = this.getStageSpecificData(stageId); - - return { - stage: [stageData.stage], - group: stageData.groups, - round: stageData.rounds, - match: stageData.matches, - }; - } - - /** - * Returns the data needed to display a whole tournament with all its stages. - * - * @param tournamentId ID of the tournament. - */ - public tournamentData(tournamentId: number): Database { - const stages = this.storage.select("stage", { - tournament_id: tournamentId, - }); - if (!stages) throw Error("Error getting stages."); - - const stagesData = stages.map((stage) => - this.getStageSpecificData(stage.id), - ); - - return { - stage: stages, - group: stagesData.flatMap((data) => data.groups), - round: stagesData.flatMap((data) => data.rounds), - match: stagesData.flatMap((data) => data.matches), - }; - } - - /** - * Returns only the data specific to the given stage (without the participants). - * - * @param stageId ID of the stage. - */ - private getStageSpecificData(stageId: number): { - stage: Stage; - groups: Group[]; - rounds: Round[]; - matches: Match[]; - } { - const stage = this.storage.select("stage", stageId); - if (!stage) throw Error("Stage not found."); - - const groups = this.storage.select("group", { stage_id: stageId }); - if (!groups) throw Error("Error getting groups."); - - const rounds = this.storage.select("round", { stage_id: stageId }); - if (!rounds) throw Error("Error getting rounds."); - - const matches = this.storage.select("match", { stage_id: stageId }); - if (!matches) throw Error("Error getting matches."); - - return { - stage, - groups, - rounds, - matches, - }; - } -} diff --git a/app/modules/brackets-manager/index.ts b/app/modules/brackets-manager/index.ts deleted file mode 100644 index 3cc3935c8..000000000 --- a/app/modules/brackets-manager/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { BracketsManager } from "./manager"; - -import type { CrudInterface, Database, OmitId, Table } from "./types"; - -export { - BracketsManager, - type CrudInterface, - type Database, - type OmitId, - type Table, -}; diff --git a/app/modules/brackets-manager/manager.ts b/app/modules/brackets-manager/manager.ts deleted file mode 100644 index b5a45146b..000000000 --- a/app/modules/brackets-manager/manager.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { InputStage, Stage } from "~/modules/brackets-model"; -import { create } from "./create"; -import { Get } from "./get"; -import * as helpers from "./helpers"; -import { Reset } from "./reset"; -import type { - CrudInterface, - Database, - DataTypes, - Storage, - Table, -} from "./types"; -import { Update } from "./update"; - -/** - * A class to handle tournament management at those levels: `stage`, `group`, `round` and `match`. - */ -export class BracketsManager { - public storage: Storage; - - public get: Get; - public update: Update; - public reset: Reset; - - /** - * Creates an instance of BracketsManager, which will handle all the stuff from the library. - * - * @param storageInterface An implementation of CrudInterface. - */ - constructor(storageInterface: CrudInterface) { - const storage = storageInterface as Storage; - - storage.selectFirst = ( - table: T, - filter: Partial, - ): DataTypes[T] | null => { - const results = this.storage.select(table, filter); - if (!results || results.length === 0) return null; - return results[0]; - }; - - storage.selectLast = ( - table: T, - filter: Partial, - ): DataTypes[T] | null => { - const results = this.storage.select(table, filter); - if (!results || results.length === 0) return null; - return results[results.length - 1]; - }; - - this.storage = storage; - this.get = new Get(this.storage); - this.update = new Update(this.storage); - this.reset = new Reset(this.storage); - } - - /** - * Creates a stage for an existing tournament. The tournament won't be created. - * - * @param stage A stage to create. - */ - public create(stage: InputStage): Stage { - return create.call(this, stage); - } - - /** - * Imports data in the database. - * - * @param data Data to import. - * @param normalizeIds Enable ID normalization: all IDs (and references to them) are remapped to consecutive IDs starting from 0. - */ - public importData(rawData: Database, normalizeIds = false): void { - const data = normalizeIds ? helpers.normalizeIds(rawData) : rawData; - - if (!this.storage.delete("stage")) - throw Error("Could not empty the stage table."); - if (!this.storage.insert("stage", data.stage)) - throw Error("Could not import stages."); - - if (!this.storage.delete("group")) - throw Error("Could not empty the group table."); - if (!this.storage.insert("group", data.group)) - throw Error("Could not import groups."); - - if (!this.storage.delete("round")) - throw Error("Could not empty the round table."); - if (!this.storage.insert("round", data.round)) - throw Error("Could not import rounds."); - - if (!this.storage.delete("match")) - throw Error("Could not empty the match table."); - if (!this.storage.insert("match", data.match)) - throw Error("Could not import matches."); - } - - /** - * Exports data from the database. - */ - public export(): Database { - const stages = this.storage.select("stage"); - if (!stages) throw Error("Error getting stages."); - - const groups = this.storage.select("group"); - if (!groups) throw Error("Error getting groups."); - - const rounds = this.storage.select("round"); - if (!rounds) throw Error("Error getting rounds."); - - const matches = this.storage.select("match"); - if (!matches) throw Error("Error getting matches."); - - return { - stage: stages, - group: groups, - round: rounds, - match: matches, - }; - } -} diff --git a/app/modules/brackets-manager/reset.ts b/app/modules/brackets-manager/reset.ts deleted file mode 100644 index 0cf246a6f..000000000 --- a/app/modules/brackets-manager/reset.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Status } from "~/modules/brackets-model"; -import { BaseUpdater } from "./base/updater"; -import * as helpers from "./helpers"; - -export class Reset extends BaseUpdater { - /** - * Resets the results of a match. - * - * This will update related matches accordingly. - * - * @param matchId ID of the match. - */ - public matchResults(matchId: number): void { - const stored = this.storage.select("match", matchId); - if (!stored) throw Error("Match not found."); - - const stage = this.storage.select("stage", stored.stage_id); - if (!stage) throw Error("Stage not found."); - - const group = this.storage.select("group", stored.group_id); - if (!group) throw Error("Group not found."); - - const { roundNumber, roundCount } = this.getRoundPositionalInfo( - stored.round_id, - ); - const matchLocation = helpers.getMatchLocation(stage.type, group.number); - const nextMatches = - stage.type !== "round_robin" && stage.type !== "swiss" - ? this.getNextMatches( - stored, - matchLocation, - stage, - roundNumber, - roundCount, - ) - : []; - - if ( - nextMatches.some( - (match) => - match && - match.status >= Status.Running && - !helpers.isMatchByeCompleted(match), - ) - ) - throw Error("The match is locked."); - - helpers.resetMatchResults(stored); - this.applyMatchUpdate(stored); - - if (!helpers.isRoundRobin(stage) && !helpers.isSwiss(stage)) - this.updateRelatedMatches(stored, true, true); - } -} diff --git a/app/modules/brackets-manager/test/double-elimination.test.ts b/app/modules/brackets-manager/test/double-elimination.test.ts deleted file mode 100644 index ab668c710..000000000 --- a/app/modules/brackets-manager/test/double-elimination.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { beforeEach, describe, expect, test } from "vitest"; -import { TournamentMatchStatus } from "~/db/tables"; -import { InMemoryDatabase } from "~/modules/brackets-memory-db"; -import { BracketsManager } from "../manager"; - -const storage = new InMemoryDatabase(); -const manager = new BracketsManager(storage); - -describe("Delete stage", () => { - beforeEach(() => { - storage.reset(); - }); - - test("should create a double elimination stage", () => { - manager.create({ - name: "Amateur", - tournamentId: 0, - type: "double_elimination", - seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], - settings: { seedOrdering: ["natural"], grandFinal: "simple" }, - }); - - const stage = storage.select("stage", 0); - expect(stage.name).toBe("Amateur"); - expect(stage.type).toBe("double_elimination"); - - expect(storage.select("group")!.length).toBe(3); - expect(storage.select("round")!.length).toBe(4 + 6 + 1); - expect(storage.select("match")!.length).toBe(30); - }); - - test("should create a tournament with 256+ tournaments", () => { - manager.create({ - name: "Example with 256 participants", - tournamentId: 0, - type: "double_elimination", - settings: { size: 256 }, - }); - }); - - test("should create a tournament with a double grand final", () => { - manager.create({ - name: "Example with double grand final", - tournamentId: 0, - type: "double_elimination", - seeding: [1, 2, 3, 4, 5, 6, 7, 8], - settings: { grandFinal: "double", seedOrdering: ["natural"] }, - }); - - expect(storage.select("group")!.length).toBe(3); - expect(storage.select("round")!.length).toBe(3 + 4 + 2); - expect(storage.select("match")!.length).toBe(15); - }); -}); - -describe("Previous and next match update in double elimination stage", () => { - beforeEach(() => { - storage.reset(); - }); - - test("should end a match and determine next matches", () => { - manager.create({ - name: "Amateur", - tournamentId: 0, - type: "double_elimination", - seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], - settings: { seedOrdering: ["natural"], grandFinal: "simple" }, - }); - - const before = storage.select("match", 8); // First match of WB round 2 - expect(before.opponent2.id).toBeNull(); - - manager.update.match({ - id: 0, // First match of WB round 1 - opponent1: { score: 16, result: "win" }, - opponent2: { score: 12 }, - }); - - manager.update.match({ - id: 1, // Second match of WB round 1 - opponent1: { score: 13 }, - opponent2: { score: 16, result: "win" }, - }); - - manager.update.match({ - id: 15, // First match of LB round 1 - opponent1: { score: 16, result: "win" }, - opponent2: { score: 10 }, - }); - - expect( - storage.select("match", 8).opponent1.id, // Determined opponent for WB round 2 - ).toBe(storage.select("match", 0).opponent1.id); // Winner of first match round 1 - - expect( - storage.select("match", 8).opponent2.id, // Determined opponent for WB round 2 - ).toBe(storage.select("match", 1).opponent2.id); // Winner of second match round 1 - - expect( - storage.select("match", 15).opponent2.id, // Determined opponent for LB round 1 - ).toBe(storage.select("match", 1).opponent1.id); // Loser of second match round 1 - - expect( - storage.select("match", 19).opponent2.id, // Determined opponent for LB round 2 - ).toBe(storage.select("match", 0).opponent2.id); // Loser of first match round 1 - }); - - test("should propagate winner when BYE is already in next match in loser bracket", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "double_elimination", - seeding: [1, 2, 3, null], - settings: { grandFinal: "simple" }, - }); - - manager.update.match({ - id: 1, // Second match of WB round 1 - opponent1: { score: 16, result: "win" }, - opponent2: { score: 12 }, - }); - - const loserId = storage.select("match", 1).opponent2.id; - let matchSemiLB = storage.select("match", 3); - - expect(matchSemiLB.opponent2.id).toBe(loserId); - expect(matchSemiLB.opponent2.result).toBe("win"); - expect(matchSemiLB.status).toBe(TournamentMatchStatus.Completed); - - expect( - storage.select("match", 4).opponent2.id, // Propagated winner in LB Final because of the BYE. - ).toBe(loserId); - - manager.reset.matchResults(1); // Second match of WB round 1 - - matchSemiLB = storage.select("match", 3); - expect(matchSemiLB.opponent2.id).toBeNull(); - expect(matchSemiLB.opponent2.result).toBeUndefined(); - expect(matchSemiLB.status).toBe(TournamentMatchStatus.Locked); - - expect(storage.select("match", 4).opponent2.id).toBeNull(); // Propagated winner is removed. - }); - - test("should determine matches in grand final", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "double_elimination", - seeding: [1, 2, 3, 4], - settings: { grandFinal: "double" }, - }); - - manager.update.match({ - id: 0, // First match of WB round 1 - opponent1: { score: 16, result: "win" }, - opponent2: { score: 12 }, - }); - - manager.update.match({ - id: 1, // Second match of WB round 1 - opponent1: { score: 13 }, - opponent2: { score: 16, result: "win" }, - }); - - manager.update.match({ - id: 2, // WB Final - opponent1: { score: 16, result: "win" }, - opponent2: { score: 9 }, - }); - - expect( - storage.select("match", 5).opponent1.id, // Determined opponent for the grand final (round 1) - ).toBe(storage.select("match", 0).opponent1.id); // Winner of WB Final - - manager.update.match({ - id: 3, // Only match of LB round 1 - opponent1: { score: 12, result: "win" }, // Team 4 - opponent2: { score: 8 }, - }); - - manager.update.match({ - id: 4, // LB Final - opponent1: { score: 14, result: "win" }, // Team 3 - opponent2: { score: 7 }, - }); - - expect( - storage.select("match", 5).opponent2.id, // Determined opponent for the grand final (round 1) - ).toBe(storage.select("match", 1).opponent2.id); // Winner of LB Final - - manager.update.match({ - id: 5, // Grand Final round 1 - opponent1: { score: 10 }, - opponent2: { score: 16, result: "win" }, // Team 3 - }); - - expect( - storage.select("match", 6).opponent2.id, // Determined opponent for the grand final (round 2) - ).toBe(storage.select("match", 1).opponent2.id); // Winner of LB Final - - expect(storage.select("match", 5).status).toBe( - TournamentMatchStatus.Completed, - ); // Grand final (round 1) - expect(storage.select("match", 6).status).toBe( - TournamentMatchStatus.Ready, - ); // Grand final (round 2) - - manager.update.match({ - id: 6, // Grand Final round 2 - opponent1: { score: 16, result: "win" }, - opponent2: { score: 10 }, - }); - }); - - test("should determine next matches and reset them", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "double_elimination", - seeding: [1, 2, 3, 4], - settings: { grandFinal: "double" }, - }); - - manager.update.match({ - id: 0, // First match of WB round 1 - opponent1: { score: 16, result: "win" }, - opponent2: { score: 12 }, - }); - - const beforeReset = storage.select("match", 3); // Determined opponent for LB round 1 - expect(beforeReset.opponent1.id).toBe( - storage.select("match", 0).opponent2.id, - ); - expect(beforeReset.opponent1.position).toBe(1); // Must be set. - - manager.reset.matchResults(0); // First match of WB round 1 - - const afterReset = storage.select("match", 3); // Determined opponent for LB round 1 - expect(afterReset.opponent1.id).toBeNull(); - expect(afterReset.opponent1.position).toBe(1); // It must stay. - }); - - test("should choose the correct previous and next matches based on losers ordering", () => { - manager.create({ - name: "Amateur", - tournamentId: 0, - type: "double_elimination", - seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], - settings: { - seedOrdering: ["natural", "reverse", "reverse"], - grandFinal: "simple", - }, - }); - - manager.update.match({ id: 0, opponent1: { result: "win" } }); // WB 1.1 - expect( - storage.select("match", 18).opponent2.id, // Determined opponent for last match of LB round 1 (reverse ordering for losers) - ).toBe(storage.select("match", 0).opponent2.id); // Loser of first match round 1 - - manager.update.match({ id: 1, opponent1: { result: "win" } }); // WB 1.2 - expect( - storage.select("match", 18).opponent1.id, // Determined opponent for last match of LB round 1 (reverse ordering for losers) - ).toBe(storage.select("match", 1).opponent2.id); // Loser of second match round 1 - - manager.update.match({ id: 8, opponent1: { result: "win" } }); // WB 2.1 - expect( - storage.select("match", 22).opponent1.id, // Determined opponent for last match of LB round 2 (reverse ordering for losers) - ).toBe(storage.select("match", 8).opponent2.id); // Loser of first match round 2 - - manager.update.match({ id: 6, opponent1: { result: "win" } }); // WB 1.7 - manager.update.match({ id: 7, opponent1: { result: "win" } }); // WB 1.8 - manager.update.match({ id: 11, opponent1: { result: "win" } }); // WB 2.4 - manager.update.match({ id: 15, opponent1: { result: "win" } }); // LB 1.1 - manager.update.match({ id: 19, opponent1: { result: "win" } }); // LB 2.1 - - expect(storage.select("match", 8).status).toBe( - TournamentMatchStatus.Completed, - ); // WB 2.1 - }); - - test("should send the losers to the right LB matches in round 1", () => { - manager.create({ - name: "Example with inner_outer loser ordering", - tournamentId: 0, - type: "double_elimination", - seeding: [1, 2, 3, 4, 5, 6, 7, 8], - settings: { - seedOrdering: ["inner_outer", "inner_outer"], - }, - }); - - expect(storage.select("match", 7).opponent1.position).toBe(1); - expect(storage.select("match", 7).opponent2.position).toBe(4); - expect(storage.select("match", 8).opponent1.position).toBe(2); - expect(storage.select("match", 8).opponent2.position).toBe(3); - - // Match of position 1. - manager.update.match({ - id: 0, - opponent1: { result: "win" }, // Loser id: 7. - }); - - expect(storage.select("match", 7).opponent1.id).toBe(8); - - // Match of position 2. - manager.update.match({ - id: 1, - opponent1: { result: "win" }, // Loser id: 4. - }); - - expect(storage.select("match", 8).opponent1.id).toBe(5); - - // Match of position 3. - manager.update.match({ - id: 2, - opponent1: { result: "win" }, // Loser id: 6. - }); - - expect(storage.select("match", 8).opponent2.id).toBe(7); - - // Match of position 4. - manager.update.match({ - id: 3, - opponent1: { result: "win" }, // Loser id: 5. - }); - - expect(storage.select("match", 7).opponent2.id).toBe(6); - }); -}); - -describe("Skip first round", () => { - beforeEach(() => { - storage.reset(); - - manager.create({ - name: "Example with double grand final", - tournamentId: 0, - type: "double_elimination", - seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], - settings: { - seedOrdering: ["natural"], - skipFirstRound: true, - grandFinal: "double", - }, - }); - }); - - test("should create a double elimination stage with skip first round option", () => { - expect(storage.select("group")!.length).toBe(3); - expect(storage.select("round")!.length).toBe(3 + 6 + 2); // One round less in WB. - expect(storage.select("match")!.length).toBe( - 4 + 2 + 1 + (4 + 4 + 2 + 2 + 1 + 1) + (1 + 1), - ); - - expect(storage.select("round", 0).number).toBe(1); // Even though the "real" first round is skipped, the stored first round's number should be 1. - - expect(storage.select("match", 0).opponent1.id).toBe(1); // First match of WB. - expect(storage.select("match", 7).opponent1.id).toBe(2); // First match of LB. - }); - - test("should choose the correct previous and next matches", () => { - manager.update.match({ id: 0, opponent1: { result: "win" } }); - expect(storage.select("match", 7).opponent1.id).toBe(2); // First match of LB Round 1 (must stay). - expect(storage.select("match", 12).opponent1.id).toBe(3); // First match of LB Round 2 (must be updated). - - manager.update.match({ id: 1, opponent1: { result: "win" } }); - expect(storage.select("match", 7).opponent2.id).toBe(4); // First match of LB Round 1 (must stay). - expect(storage.select("match", 11).opponent1.id).toBe(7); // Second match of LB Round 2 (must be updated). - - manager.update.match({ id: 4, opponent1: { result: "win" } }); // First match of WB Round 2. - expect(storage.select("match", 18).opponent1.id).toBe(5); // First match of LB Round 4. - - manager.update.match({ id: 7, opponent1: { result: "win" } }); // First match of LB Round 1. - expect(storage.select("match", 11).opponent2.id).toBe(2); // First match of LB Round 2. - - for (let i = 2; i < 21; i++) - manager.update.match({ id: i, opponent1: { result: "win" } }); - - expect(storage.select("match", 15).opponent1.id).toBe(7); // First match of LB Round 3. - - expect(storage.select("match", 21).opponent1.id).toBe(1); // GF Round 1. - expect(storage.select("match", 21).opponent2.id).toBe(9); // GF Round 1. - - manager.update.match({ id: 21, opponent2: { result: "win" } }); - - expect(storage.select("match", 21).opponent1.id).toBe(1); // GF Round 2. - expect(storage.select("match", 22).opponent2.id).toBe(9); // GF Round 2. - - manager.update.match({ id: 22, opponent2: { result: "win" } }); - }); -}); diff --git a/app/modules/brackets-manager/test/general.test.ts b/app/modules/brackets-manager/test/general.test.ts deleted file mode 100644 index 0492c6a15..000000000 --- a/app/modules/brackets-manager/test/general.test.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { beforeEach, describe, expect, test } from "vitest"; -import { InMemoryDatabase } from "~/modules/brackets-memory-db"; -import { BracketsManager } from "../manager"; - -const storage = new InMemoryDatabase(); -const manager = new BracketsManager(storage); - -describe("BYE handling", () => { - beforeEach(() => { - storage.reset(); - }); - - test("should propagate BYEs through the brackets", () => { - manager.create({ - name: "Example with BYEs", - tournamentId: 0, - type: "double_elimination", - seeding: [1, null, null, null], - settings: { seedOrdering: ["natural"], grandFinal: "simple" }, - }); - - expect(storage.select("match", 2).opponent1.id).toBe(1); - expect(storage.select("match", 2).opponent2).toBe(null); - - expect(storage.select("match", 3).opponent1).toBe(null); - expect(storage.select("match", 3).opponent2).toBe(null); - - expect(storage.select("match", 4).opponent1).toBe(null); - expect(storage.select("match", 4).opponent2).toBe(null); - - expect(storage.select("match", 5).opponent1.id).toBe(1); - expect(storage.select("match", 5).opponent2).toBe(null); - }); - - test("should handle incomplete seeding during creation", () => { - manager.create({ - name: "Example with BYEs", - tournamentId: 0, - type: "double_elimination", - seeding: [1, 2], - settings: { - seedOrdering: ["natural"], - balanceByes: false, // Default value. - size: 4, - }, - }); - - expect(storage.select("match", 0).opponent1.id).toBe(1); - expect(storage.select("match", 0).opponent2.id).toBe(2); - - expect(storage.select("match", 1).opponent1).toBe(null); - expect(storage.select("match", 1).opponent2).toBe(null); - }); - - test("should balance BYEs in the seeding", () => { - manager.create({ - name: "Example with BYEs", - tournamentId: 0, - type: "double_elimination", - seeding: [1, 2], - settings: { - seedOrdering: ["natural"], - balanceByes: true, - size: 4, - }, - }); - - expect(storage.select("match", 0).opponent1.id).toBe(1); - expect(storage.select("match", 0).opponent2).toBe(null); - - expect(storage.select("match", 1).opponent1.id).toBe(2); - expect(storage.select("match", 1).opponent2).toBe(null); - }); -}); - -describe("Position checks", () => { - beforeEach(() => { - storage.reset(); - - manager.create({ - name: "Example with double grand final", - tournamentId: 0, - type: "double_elimination", - settings: { - size: 8, - grandFinal: "simple", - seedOrdering: ["natural"], - }, - }); - }); - - test("should not have a position when we don't need the origin of a participant", () => { - const matchFromWbRound2 = storage.select("match", 4); - expect(matchFromWbRound2.opponent1.position).toBe(undefined); - expect(matchFromWbRound2.opponent2.position).toBe(undefined); - - const matchFromLbRound2 = storage.select("match", 9); - expect(matchFromLbRound2.opponent2.position).toBe(undefined); - - const matchFromGrandFinal = storage.select("match", 13); - expect(matchFromGrandFinal.opponent1.position).toBe(undefined); - }); - - test("should have a position where we need the origin of a participant", () => { - const matchFromWbRound1 = storage.select("match", 0); - expect(matchFromWbRound1.opponent1.position).toBe(1); - expect(matchFromWbRound1.opponent2.position).toBe(2); - - const matchFromLbRound1 = storage.select("match", 7); - expect(matchFromLbRound1.opponent1.position).toBe(1); - expect(matchFromLbRound1.opponent2.position).toBe(2); - - const matchFromLbRound2 = storage.select("match", 9); - expect(matchFromLbRound2.opponent1.position).toBe(2); - - const matchFromGrandFinal = storage.select("match", 13); - expect(matchFromGrandFinal.opponent2.position).toBe(1); - }); -}); - -describe("Special cases", () => { - beforeEach(() => { - storage.reset(); - }); - - test("should throw if the name of the stage is not provided", () => { - expect(() => - // @ts-expect-error testing throwing - manager.create({ - tournamentId: 0, - type: "single_elimination", - }), - ).toThrow("You must provide a name for the stage."); - }); - - test("should throw if the tournament id of the stage is not provided", () => { - expect(() => - // @ts-expect-error testing throwing - manager.create({ - name: "Example", - type: "single_elimination", - }), - ).toThrow("You must provide a tournament id for the stage."); - }); - - test("should throw if the participant count of a stage is not a power of two", () => { - expect(() => - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: [1, 2, 3, 4, 5, 6, 7], - }), - ).toThrow( - "The library only supports a participant count which is a power of two.", - ); - - expect(() => - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - settings: { size: 3 }, - }), - ).toThrow( - "The library only supports a participant count which is a power of two.", - ); - }); - - test("should throw if the participant count of a stage is less than two", () => { - expect(() => - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - settings: { size: 0 }, - }), - ).toThrow( - "Impossible to create an empty stage. If you want an empty seeding, just set the size of the stage.", - ); - - expect(() => - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - settings: { size: 1 }, - }), - ).toThrow("Impossible to create a stage with less than 2 participants."); - }); -}); - -describe("Seeding and ordering in elimination", () => { - beforeEach(() => { - storage.reset(); - - manager.create({ - name: "Amateur", - tournamentId: 0, - type: "double_elimination", - seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], - settings: { - seedOrdering: [ - "inner_outer", - "reverse", - "pair_flip", - "half_shift", - "reverse", - ], - }, - }); - }); - - test("should have the good orderings everywhere", () => { - const firstRoundMatchWB = storage.select("match", 0); - expect(firstRoundMatchWB.opponent1.position).toBe(1); - expect(firstRoundMatchWB.opponent2.position).toBe(16); - - const firstRoundMatchLB = storage.select("match", 15); - expect(firstRoundMatchLB.opponent1.position).toBe(8); - expect(firstRoundMatchLB.opponent2.position).toBe(7); - - const secondRoundMatchLB = storage.select("match", 19); - expect(secondRoundMatchLB.opponent1.position).toBe(2); - - const secondRoundSecondMatchLB = storage.select("match", 20); - expect(secondRoundSecondMatchLB.opponent1.position).toBe(1); - - const fourthRoundMatchLB = storage.select("match", 25); - expect(fourthRoundMatchLB.opponent1.position).toBe(2); - - const finalRoundMatchLB = storage.select("match", 28); - expect(finalRoundMatchLB.opponent1.position).toBe(1); - }); -}); - -describe("Reset match and match games", () => { - beforeEach(() => { - storage.reset(); - }); - - test("should reset results of a match", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: [1, 2], - settings: { - seedOrdering: ["natural"], - size: 8, - }, - }); - - manager.update.match({ - id: 0, - opponent1: { score: 16, result: "win" }, - opponent2: { score: 12 }, - }); - - let match = storage.select("match", 0); - expect(match.opponent1.score).toBe(16); - expect(match.opponent2.score).toBe(12); - expect(match.opponent1.result).toBe("win"); - - let semi1 = storage.select("match", 4); - expect(semi1.opponent1.result).toBe("win"); - expect(semi1.opponent2).toBe(null); - - let final = storage.select("match", 6); - expect(final.opponent1.result).toBe("win"); - expect(final.opponent2).toBe(null); - - manager.reset.matchResults(0); // Score stays as is. - - match = storage.select("match", 0); - expect(match.opponent1.score).toBe(16); - expect(match.opponent2.score).toBe(12); - expect(match.opponent1.result).toBe(undefined); - - semi1 = storage.select("match", 4); - expect(semi1.opponent1.result).toBe(undefined); - expect(semi1.opponent2).toBe(null); - - final = storage.select("match", 6); - expect(final.opponent1.result).toBe(undefined); - expect(final.opponent2).toBe(null); - }); - - test("should throw when at least one of the following match is locked", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: [1, 2, 3, 4], - settings: { - seedOrdering: ["natural"], - }, - }); - - manager.update.match({ - id: 0, - opponent1: { score: 16, result: "win" }, - opponent2: { score: 12 }, - }); - - manager.update.match({ - id: 1, - opponent1: { score: 16, result: "win" }, - opponent2: { score: 12 }, - }); - - manager.update.match({ - id: 2, - opponent1: { score: 16, result: "win" }, - opponent2: { score: 12 }, - }); - - expect(() => manager.reset.matchResults(0)).toThrow("The match is locked."); - }); -}); - -describe("Import / export", () => { - beforeEach(() => { - storage.reset(); - }); - - test("should import data in the storage", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: [1, 2, 3, 4], - settings: { - seedOrdering: ["natural"], - }, - }); - - const initialData = manager.get.stageData(0); - - manager.update.match({ - id: 0, - opponent1: { score: 16, result: "win" }, - opponent2: { score: 12 }, - }); - - manager.update.match({ - id: 1, - opponent1: { score: 16, result: "win" }, - opponent2: { score: 12 }, - }); - - manager.update.match({ - id: 2, - opponent1: { score: 16, result: "win" }, - opponent2: { score: 12 }, - }); - - expect(storage.select("match", 0).opponent1.result).toBe("win"); - expect(storage.select("match", 1).opponent1.result).toBe("win"); - - manager.importData(initialData); - - expect(storage.select("match", 0).opponent1.result).toBe(undefined); - expect(storage.select("match", 1).opponent1.result).toBe(undefined); - }); - - test("should export data from the storage", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: [1, 2, 3, 4], - settings: { - seedOrdering: ["natural"], - }, - }); - - const data = manager.export(); - - for (const key of ["stage", "group", "round", "match"]) { - expect(Object.keys(data).includes(key)).toBe(true); - } - - expect(storage.select("stage")).toEqual(data.stage); - expect(storage.select("group")).toEqual(data.group); - expect(storage.select("round")).toEqual(data.round); - expect(storage.select("match")).toEqual(data.match); - }); -}); diff --git a/app/modules/brackets-manager/test/round-robin.test.ts b/app/modules/brackets-manager/test/round-robin.test.ts deleted file mode 100644 index 981424570..000000000 --- a/app/modules/brackets-manager/test/round-robin.test.ts +++ /dev/null @@ -1,511 +0,0 @@ -import { beforeEach, describe, expect, test } from "vitest"; -import { InMemoryDatabase } from "~/modules/brackets-memory-db"; -import { BracketsManager } from "../manager"; - -const storage = new InMemoryDatabase(); -const manager = new BracketsManager(storage); - -describe("Create a round-robin stage", () => { - beforeEach(() => { - storage.reset(); - }); - - test("should create a round-robin stage", () => { - const example = { - name: "Example", - tournamentId: 0, - type: "round_robin", - seeding: [1, 2, 3, 4, 5, 6, 7, 8], - settings: { groupCount: 2 }, - } as any; - - manager.create(example); - - const stage = storage.select("stage", 0)!; - expect(stage.name).toBe(example.name); - expect(stage.type).toBe(example.type); - - expect(storage.select("group")!.length).toBe(2); - expect(storage.select("round")!.length).toBe(6); - expect(storage.select("match")!.length).toBe(12); - }); - - test("should create a round-robin stage with a manual seeding", () => { - const example = { - name: "Example", - tournamentId: 0, - type: "round_robin", - seeding: [1, 2, 3, 4, 5, 6, 7, 8], - settings: { - groupCount: 2, - manualOrdering: [ - [1, 4, 6, 7], - [2, 3, 5, 8], - ], - }, - } as any; - - manager.create(example); - - for (let groupIndex = 0; groupIndex < 2; groupIndex++) { - const matches = storage.select("match", { group_id: groupIndex })!; - const participants = [ - matches[0].opponent1.position, - matches[1].opponent2.position, - matches[1].opponent1.position, - matches[0].opponent2.position, - ]; - - expect(participants).toEqual(example.settings.manualOrdering[groupIndex]); - } - }); - - test("should throw if manual ordering has invalid counts", () => { - expect(() => - manager.create({ - name: "Example", - tournamentId: 0, - type: "round_robin", - seeding: [1, 2, 3, 4, 5, 6, 7, 8], - settings: { - groupCount: 2, - manualOrdering: [[1, 4, 6, 7]], - }, - }), - ).toThrow( - "Group count in the manual ordering does not correspond to the given group count.", - ); - - expect(() => - manager.create({ - name: "Example", - tournamentId: 0, - type: "round_robin", - seeding: [1, 2, 3, 4, 5, 6, 7, 8], - settings: { - groupCount: 2, - manualOrdering: [ - [1, 4], - [2, 3], - ], - }, - }), - ).toThrow("Not enough seeds in at least one group of the manual ordering."); - }); - - test("should drop empty slots instead of creating BYE matches", () => { - const example = { - name: "Example", - tournamentId: 0, - type: "round_robin", - seeding: [1, 2, 3, 4, 5, null, null, null], - settings: { groupCount: 2 }, - } as any; - - manager.create(example); - - // 5 teams in 2 groups -> groups of 3 and 2 with no BYE matches at all. - const matches = storage.select("match")!; - expect(matches.length).toBe(4); - for (const match of matches) { - expect(match.opponent1?.id).not.toBeNull(); - expect(match.opponent2?.id).not.toBeNull(); - } - }); - - test("should not pad a short group with empty rounds when teams divide unevenly", () => { - // 5 teams in 2 groups -> groups of 3 and 2. The 2-team group must be a - // clean single-round single-match group, not padded with BYE-only rounds - // that strand the real match in a later round. - manager.create({ - name: "Uneven groups", - tournamentId: 0, - type: "round_robin", - seeding: [1, 2, 3, 4, 5], - settings: { groupCount: 2, seedOrdering: ["groups.seed_optimized"] }, - } as any); - - const isRealMatch = (match: any) => - match.opponent1?.id != null && match.opponent2?.id != null; - - const shortGroup = storage.select("group")!.find((group: any) => { - const matches = storage.select("match", { group_id: group.id })!; - return matches.filter(isRealMatch).length === 1; - })!; - - const rounds = storage.select("round", { - group_id: shortGroup.id, - })!; - const matches = storage.select("match", { - group_id: shortGroup.id, - })!; - const realMatch = matches.find(isRealMatch)!; - const realMatchRound = rounds.find( - (round: any) => round.id === realMatch.round_id, - )!; - - // No BYE matches and no empty rounds, just the single match in round 1. - expect(matches.length).toBe(1); - expect(rounds.length).toBe(1); - expect(realMatchRound.number).toBe(1); - }); - - test("should create a round-robin stage with to be determined participants", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "round_robin", - settings: { - groupCount: 4, - size: 16, - }, - }); - - expect(storage.select("group")!.length).toBe(4); - expect(storage.select("round")!.length).toBe(4 * 3); - expect(storage.select("match")!.length).toBe(4 * 3 * 2); - }); - - test("should create a round-robin stage with effort balanced", () => { - manager.create({ - name: "Example with effort balanced", - tournamentId: 0, - type: "round_robin", - seeding: [1, 2, 3, 4, 5, 6, 7, 8], - settings: { - groupCount: 2, - seedOrdering: ["groups.seed_optimized"], - }, - }); - - expect(storage.select("match", 0).opponent1.id).toBe(1); - expect(storage.select("match", 0).opponent2.id).toBe(8); - }); - - test("should throw if no group count given", () => { - expect(() => - manager.create({ - name: "Example", - tournamentId: 0, - type: "round_robin", - }), - ).toThrow("You must specify a group count for round-robin stages."); - }); - - test("should throw if the group count is not strictly positive", () => { - expect(() => - manager.create({ - name: "Example", - tournamentId: 0, - type: "round_robin", - settings: { - groupCount: 0, - size: 4, - seedOrdering: ["groups.seed_optimized"], - }, - }), - ).toThrow("You must provide a strictly positive group count."); - }); - - test("creates an A/B divisions round-robin where every A team plays every B team once", () => { - const seeding = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; - // alternate A (0) / B (1) so that seed order 1..12 gives A=[1,3,5,7,9,11], B=[2,4,6,8,10,12] - const abDivisions = seeding.map((_, i) => (i % 2 === 0 ? 0 : 1)); - - manager.create({ - name: "AB Example", - tournamentId: 0, - type: "round_robin", - seeding, - abDivisions: abDivisions as (0 | 1)[], - settings: { - groupCount: 1, - hasAbDivisions: true, - seedOrdering: ["groups.seed_optimized"], - }, - }); - - expect(storage.select("group")!.length).toBe(1); - expect(storage.select("round")!.length).toBe(6); - expect(storage.select("match")!.length).toBe(36); - - const divisionAIds = new Set([1, 3, 5, 7, 9, 11]); - const divisionBIds = new Set([2, 4, 6, 8, 10, 12]); - const pairings = new Set(); - - for (const match of storage.select("match")!) { - const aId: number = match.opponent1.id; - const bId: number = match.opponent2.id; - - expect(divisionAIds.has(aId)).toBe(true); - expect(divisionBIds.has(bId)).toBe(true); - - const key = `${aId}-${bId}`; - expect(pairings.has(key)).toBe(false); - pairings.add(key); - } - - expect(pairings.size).toBe(36); - }); - - test("throws when A/B divisions are requested but abDivisions is missing", () => { - expect(() => - manager.create({ - name: "Missing AB", - tournamentId: 0, - type: "round_robin", - seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], - settings: { - groupCount: 1, - hasAbDivisions: true, - }, - }), - ).toThrow("abDivisions must be provided when hasAbDivisions is enabled."); - }); - - test("creates an A/B divisions round-robin with uneven (±1) divisions and a single group", () => { - const seeding = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; - manager.create({ - name: "Uneven AB", - tournamentId: 0, - type: "round_robin", - seeding, - abDivisions: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], - settings: { - groupCount: 1, - hasAbDivisions: true, - seedOrdering: ["groups.seed_optimized"], - }, - }); - - expect(storage.select("group")!.length).toBe(1); - expect(storage.select("round")!.length).toBe(6); - expect(storage.select("match")!.length).toBe(30); - }); - - test("throws when A/B divisions are uneven with multiple groups", () => { - expect(() => - manager.create({ - name: "Uneven AB multi-group", - tournamentId: 0, - type: "round_robin", - seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], - abDivisions: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], - settings: { - groupCount: 2, - hasAbDivisions: true, - }, - }), - ).toThrow("Uneven A/B divisions are only supported with a single group."); - }); -}); - -describe("Update scores in a round-robin stage", () => { - beforeEach(() => { - storage.reset(); - manager.create({ - name: "Example scores", - tournamentId: 0, - type: "round_robin", - seeding: [1, 2, 3, 4], - settings: { groupCount: 1 }, - }); - }); - - describe("Example use-case", () => { - beforeEach(() => { - storage.reset(); - manager.create({ - name: "Example scores", - tournamentId: 0, - type: "round_robin", - seeding: [1, 2, 3, 4], - settings: { groupCount: 1 }, - }); - }); - - test("should set all the scores", () => { - manager.update.match({ - id: 0, - opponent1: { score: 16, result: "win" }, // POCEBLO - opponent2: { score: 9 }, // AQUELLEHEURE?! - }); - - manager.update.match({ - id: 1, - opponent1: { score: 3 }, // Ballec Squad - opponent2: { score: 16, result: "win" }, // twitch.tv/mrs_fly - }); - - manager.update.match({ - id: 2, - opponent1: { score: 16, result: "win" }, // twitch.tv/mrs_fly - opponent2: { score: 0 }, // AQUELLEHEURE?! - }); - - manager.update.match({ - id: 3, - opponent1: { score: 16, result: "win" }, // POCEBLO - opponent2: { score: 2 }, // Ballec Squad - }); - - manager.update.match({ - id: 4, - opponent1: { score: 16, result: "win" }, // Ballec Squad - opponent2: { score: 12 }, // AQUELLEHEURE?! - }); - - manager.update.match({ - id: 5, - opponent1: { score: 4 }, // twitch.tv/mrs_fly - opponent2: { score: 16, result: "win" }, // POCEBLO - }); - }); - }); - - test("should unlock next round matches as soon as both participants are ready", () => { - // Round robin with 4 teams: [1, 2, 3, 4] - // Round 1: Match 0 (1 vs 2), Match 1 (3 vs 4) - // Round 2: Match 2 (1 vs 3), Match 3 (2 vs 4) - // Round 3: Match 4 (1 vs 4), Match 5 (2 vs 3) - - const round1Match1 = storage.select("match", 0)!; - const round1Match2 = storage.select("match", 1)!; - const round2Match1 = storage.select("match", 2)!; - const round2Match2 = storage.select("match", 3)!; - - // Initially, only round 1 matches should be ready - expect(round1Match1.status).toBe(2); // Ready (1 vs 2) - expect(round1Match2.status).toBe(2); // Ready (3 vs 4) - expect(round2Match1.status).toBe(0); // Locked (1 vs 3) - expect(round2Match2.status).toBe(0); // Locked (2 vs 4) - - // Complete first match of round 1 (1 vs 2) - manager.update.match({ - id: 0, - opponent1: { score: 16, result: "win" }, // Team 1 wins - opponent2: { score: 9 }, // Team 2 loses - }); - - // Round 2 Match 1 (1 vs 3) should still be locked because team 3 hasn't finished - // Round 2 Match 2 (2 vs 4) should still be locked because team 4 hasn't finished - let round2Match1After = storage.select("match", 2)!; - let round2Match2After = storage.select("match", 3)!; - expect(round2Match1After.status).toBe(0); // Still Locked - expect(round2Match2After.status).toBe(0); // Still Locked - - // Complete second match of round 1 (3 vs 4) - manager.update.match({ - id: 1, - opponent1: { score: 3 }, // Team 3 loses - opponent2: { score: 16, result: "win" }, // Team 4 wins - }); - - // Now both matches in round 2 should be unlocked - // Match 2 (1 vs 3): both team 1 and team 3 have finished round 1 - // Match 3 (2 vs 4): both team 2 and team 4 have finished round 1 - round2Match1After = storage.select("match", 2)!; - round2Match2After = storage.select("match", 3)!; - expect(round2Match1After.status).toBe(2); // Ready - expect(round2Match2After.status).toBe(2); // Ready - }); - - test("should leave every match Ready when independentRounds is set", () => { - storage.reset(); - manager.create({ - name: "Independent rounds", - tournamentId: 0, - type: "round_robin", - seeding: [1, 2, 3, 4], - settings: { groupCount: 1, independentRounds: true }, - }); - - const matches = storage.select("match")!; - for (const match of matches) { - expect(match.status).toBe(2); - } - - // reporting a round-2 match before round-1 finishes must not throw - const round2Match = storage.select("match", 2)!; - expect(() => - manager.update.match({ - id: round2Match.id, - opponent1: { score: 16, result: "win" }, - opponent2: { score: 4 }, - }), - ).not.toThrow(); - }); - - test("should let the only real match be played in a group with fewer teams than slots", () => { - storage.reset(); - // Group sized for 3 but only 2 teams placed (the 3rd slot is a BYE). - // The two real teams only meet in round 3, preceded by two BYE rounds - // that can never be reported. The real match must still be playable. - manager.create({ - name: "Two teams in a three-slot group", - tournamentId: 0, - type: "round_robin", - seeding: [1, 2, null], - settings: { groupCount: 1 }, - } as any); - - const realMatch = storage - .select("match")! - .find((m: any) => m.opponent1?.id && m.opponent2?.id)!; - - expect(realMatch.status).toBe(2); // Ready - - expect(() => - manager.update.match({ - id: realMatch.id, - opponent1: { score: 16, result: "win" }, - opponent2: { score: 9 }, - }), - ).not.toThrow(); - }); - - test("should unlock next round matches with BYE participants", () => { - storage.reset(); - // Create a round robin with 3 teams (odd number creates rounds where one team doesn't play) - manager.create({ - name: "Example with BYEs", - tournamentId: 0, - type: "round_robin", - seeding: [1, 2, 3], - settings: { groupCount: 1 }, - }); - - // With 3 teams, the rounds look like: - // Round 1: Match (teams 3 vs 2) - Team 1 doesn't play - // Round 2: Match (teams 1 vs 3) - Team 2 doesn't play - // Round 3: Match (teams 2 vs 1) - Team 3 doesn't play - - const allMatches = storage.select("match")!; - const allRounds = storage.select("round")!; - - // Find the actual match (not BYE vs BYE which doesn't exist) - const round1RealMatch = allMatches.find( - (m: any) => m.round_id === allRounds[0].id && m.opponent1 && m.opponent2, - )!; - const round2RealMatch = allMatches.find( - (m: any) => m.round_id === allRounds[1].id && m.opponent1 && m.opponent2, - )!; - - expect(round1RealMatch.status).toBe(2); // Ready - expect(round2RealMatch.status).toBe(0); // Locked initially - - // Complete the only real match in round 1 (teams 3 vs 2) - // Team 1 didn't play in round 1 - manager.update.match({ - id: round1RealMatch.id, - opponent1: { score: 16, result: "win" }, - opponent2: { score: 9 }, - }); - - // The real match in round 2 (teams 1 vs 3) should now be unlocked - // because team 1 didn't play in round 1 (considered ready) - // and team 3 just finished their match - const round2AfterUpdate = storage.select("match", round2RealMatch.id)!; - expect(round2AfterUpdate.status).toBe(2); // Ready - }); -}); diff --git a/app/modules/brackets-manager/test/single-elimination.test.ts b/app/modules/brackets-manager/test/single-elimination.test.ts deleted file mode 100644 index 5ee96a1e9..000000000 --- a/app/modules/brackets-manager/test/single-elimination.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { beforeEach, describe, expect, test } from "vitest"; -import { TournamentMatchStatus } from "~/db/tables"; -import { InMemoryDatabase } from "~/modules/brackets-memory-db"; -import { BracketsManager } from "../manager"; - -const storage = new InMemoryDatabase(); -const manager = new BracketsManager(storage); - -describe("Create single elimination stage", () => { - beforeEach(() => { - storage.reset(); - }); - - test("should create a single elimination stage", () => { - const example = { - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], - settings: { seedOrdering: ["natural"] }, - } as any; - - manager.create(example); - - const stage = storage.select("stage", 0); - expect(stage.name).toBe(example.name); - expect(stage.type).toBe(example.type); - - expect(storage.select("group")!.length).toBe(1); - expect(storage.select("round")!.length).toBe(4); - expect(storage.select("match")!.length).toBe(15); - }); - - test("should create a single elimination stage with BYEs", () => { - manager.create({ - name: "Example with BYEs", - tournamentId: 0, - type: "single_elimination", - seeding: [1, null, 3, 4, null, null, 7, 8], - settings: { seedOrdering: ["natural"] }, - }); - - expect(storage.select("match", 4).opponent1.id).toBe(1); - expect(storage.select("match", 4).opponent2.id).toBe(null); - expect(storage.select("match", 5).opponent1).toBe(null); - expect(storage.select("match", 5).opponent2.id).toBe(null); - }); - - test("should create a single elimination stage with consolation final", () => { - manager.create({ - name: "Example with consolation final", - tournamentId: 0, - type: "single_elimination", - seeding: [1, 2, 3, 4, 5, 6, 7, 8], - settings: { consolationFinal: true, seedOrdering: ["natural"] }, - }); - - expect(storage.select("group")!.length).toBe(2); - expect(storage.select("round")!.length).toBe(4); - expect(storage.select("match")!.length).toBe(8); - }); - - test("should create a single elimination stage with consolation final and BYEs", () => { - manager.create({ - name: "Example with consolation final and BYEs", - tournamentId: 0, - type: "single_elimination", - seeding: [null, null, null, 4, 5, 6, 7, 8], - settings: { consolationFinal: true, seedOrdering: ["natural"] }, - }); - - expect(storage.select("match", 4).opponent1).toBe(null); - expect(storage.select("match", 4).opponent2.id).toBe(4); - - // Consolation final - expect(storage.select("match", 7).opponent1).toBe(null); - expect(storage.select("match", 7).opponent2.id).toBe(null); - }); - - test("should create a single elimination stage with Bo3 matches", () => { - manager.create({ - name: "Example with Bo3 matches", - tournamentId: 0, - type: "single_elimination", - seeding: [1, 2, 3, 4, 5, 6, 7, 8], - settings: { seedOrdering: ["natural"] }, - }); - - expect(storage.select("group")!.length).toBe(1); - expect(storage.select("round")!.length).toBe(3); - expect(storage.select("match")!.length).toBe(7); - }); - - test("should throw if the given number property already exists", () => { - manager.create({ - name: "Stage 1", - tournamentId: 0, - type: "single_elimination", - number: 1, - settings: { size: 2 }, - }); - - expect(() => - manager.create({ - name: "Stage 1", - tournamentId: 0, - type: "single_elimination", - number: 1, // Duplicate - settings: { size: 2 }, - }), - ).toThrow("The given stage number already exists."); - }); - - test("should throw if the seeding has duplicate participants", () => { - expect(() => - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: [ - 1, - 1, // Duplicate - 3, - 4, - ], - }), - ).toThrow("The seeding has a duplicate participant."); - }); -}); - -describe("Previous and next match update", () => { - beforeEach(() => { - storage.reset(); - }); - - test("should determine matches in consolation final", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: [1, 2, 3, 4], - settings: { consolationFinal: true }, - }); - - manager.update.match({ - id: 0, // First match of round 1 - opponent1: { score: 16, result: "win" }, - opponent2: { score: 12 }, - }); - - manager.update.match({ - id: 1, // Second match of round 1 - opponent1: { score: 13 }, - opponent2: { score: 16, result: "win" }, - }); - - expect(storage.select("match", 3).opponent1.id).toBe( - storage.select("match", 0).opponent2.id, - ); - expect(storage.select("match", 3).opponent2.id).toBe( - storage.select("match", 1).opponent1.id, - ); - expect(storage.select("match", 2).status).toBe( - TournamentMatchStatus.Ready, - ); - expect(storage.select("match", 3).status).toBe( - TournamentMatchStatus.Ready, - ); - }); - - test("should play both the final and consolation final in parallel", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: [1, 2, 3, 4], - settings: { consolationFinal: true }, - }); - - manager.update.match({ - id: 0, // First match of round 1 - opponent1: { score: 16, result: "win" }, - opponent2: { score: 12 }, - }); - - manager.update.match({ - id: 1, // Second match of round 1 - opponent1: { score: 13 }, - opponent2: { score: 16, result: "win" }, - }); - - manager.update.match({ - id: 2, // Final - opponent1: { score: 12 }, - opponent2: { score: 9 }, - }); - - expect(storage.select("match", 2).status).toBe( - TournamentMatchStatus.Running, - ); - expect(storage.select("match", 3).status).toBe( - TournamentMatchStatus.Ready, - ); - - manager.update.match({ - id: 3, // Consolation final - opponent1: { score: 12 }, - opponent2: { score: 9 }, - }); - - expect(storage.select("match", 2).status).toBe( - TournamentMatchStatus.Running, - ); - expect(storage.select("match", 3).status).toBe( - TournamentMatchStatus.Running, - ); - - manager.update.match({ - id: 3, // Consolation final - opponent1: { score: 16, result: "win" }, - opponent2: { score: 9 }, - }); - - expect(storage.select("match", 2).status).toBe( - TournamentMatchStatus.Running, - ); - - manager.update.match({ - id: 2, // Final - opponent1: { score: 16, result: "win" }, - opponent2: { score: 9 }, - }); - }); -}); diff --git a/app/modules/brackets-manager/types.ts b/app/modules/brackets-manager/types.ts deleted file mode 100644 index 39ced56ba..000000000 --- a/app/modules/brackets-manager/types.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { - Group, - Match, - Round, - SeedOrdering, - Stage, -} from "~/modules/brackets-model"; - -/** - * Type of an object implementing every ordering method. - */ -export type OrderingMap = Record< - SeedOrdering, - (array: T[], ...args: number[]) => T[] ->; - -/** - * Omits the `id` property of a type. - */ -export type OmitId = Omit; - -/** - * Defines a T which can be null. - */ -export type Nullable = T | null; - -/** - * An object which maps an ID to another ID. - */ -export type IdMapping = Record; - -/** - * Used by the library to handle placements. Is `null` if is a BYE. Has a `null` name if it's yet to be determined. - */ -export type ParticipantSlot = { id: number | null; position?: number } | null; - -/** - * The library only handles duels. It's one participant versus another participant. - */ -export type Duel = [ParticipantSlot, ParticipantSlot]; - -/** - * The side of an opponent. - */ -export type Side = "opponent1" | "opponent2"; - -/** - * Positional information about a round. - */ -export type RoundPositionalInfo = { - roundNumber: number; - roundCount: number; -}; - -/** - * The result of an array which was split by parity. - */ -export interface ParitySplit { - even: T[]; - odd: T[]; -} - -/** - * Makes an object type deeply partial. - */ -export type DeepPartial = T extends object - ? { - [P in keyof T]?: DeepPartial; - } - : T; - -/** - * Converts all value types to array types. - */ -export type ValueToArray = { - [K in keyof T]: Array; -}; - -/** - * Data type associated to each database table. - */ -export interface DataTypes { - stage: Stage; - group: Group; - round: Round; - match: Match; -} - -/** - * The types of table in the storage. - */ -export type Table = keyof DataTypes; - -/** - * Format of the data in a database. - */ -export type Database = ValueToArray; - -export type TournamentManagerDataSet = Database; - -/** - * Contains the losers and the winner of the bracket. - */ -export interface StandardBracketResults { - /** - * The list of losers for each round of the bracket. - */ - losers: ParticipantSlot[][]; - - /** - * The winner of the bracket. - */ - winner: ParticipantSlot; -} - -/** - * This CRUD interface is used by the manager to abstract storage. - */ -export interface CrudInterface { - /** - * Inserts a value in the database and returns its id. - * - * @param table Where to insert. - * @param value What to insert. - */ - insert(table: T, value: OmitId): number; - - /** - * Inserts multiple values in the database. - * - * @param table Where to insert. - * @param values What to insert. - */ - insert(table: T, values: OmitId[]): boolean; - - /** - * Gets all data from a table in the database. - * - * @param table Where to get from. - */ - select(table: T): Array | null; - - /** - * Gets specific data from a table in the database. - * - * @param table Where to get from. - * @param id What to get. - */ - select(table: T, id: number): DataTypes[T] | null; - - /** - * Gets data from a table in the database with a filter. - * - * @param table Where to get from. - * @param filter An object to filter data. - */ - select( - table: T, - filter: Partial, - ): Array | null; - - /** - * Updates data in a table. - * - * @param table Where to update. - * @param id What to update. - * @param value How to update. - */ - update(table: T, id: number, value: DataTypes[T]): boolean; - - /** - * Updates data in a table. - * - * @param table Where to update. - * @param filter An object to filter data. - * @param value How to update. - */ - update( - table: T, - filter: Partial, - value: Partial, - ): boolean; - - /** - * Empties a table completely. - * - * @param table Where to delete everything. - */ - delete(table: T): boolean; - - /** - * Delete data in a table, based on a filter. - * - * @param table Where to delete in. - * @param filter An object to filter data. - */ - delete(table: T, filter: Partial): boolean; -} - -export interface Storage extends CrudInterface { - selectFirst( - table: T, - filter: Partial, - ): DataTypes[T] | null; - selectLast( - table: T, - filter: Partial, - ): DataTypes[T] | null; -} diff --git a/app/modules/brackets-manager/update.ts b/app/modules/brackets-manager/update.ts deleted file mode 100644 index 20b661715..000000000 --- a/app/modules/brackets-manager/update.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Match } from "~/modules/brackets-model"; -import { BaseUpdater } from "./base/updater"; -import type { DeepPartial } from "./types"; - -export class Update extends BaseUpdater { - /** - * Updates partial information of a match. Its id must be given. - * - * This will update related matches accordingly. - * - * @param match Values to change in a match. - * @param force If true, bypasses the locked match check. - */ - public match( - match: DeepPartial, - force?: boolean, - ): void { - if (match.id === undefined) throw Error("No match id given."); - - const stored = this.storage.select("match", match.id); - if (!stored) throw Error("Match not found."); - - this.updateMatch(stored, match, force); - } -} diff --git a/app/modules/brackets-memory-db/README.md b/app/modules/brackets-memory-db/README.md deleted file mode 100644 index 186e43067..000000000 --- a/app/modules/brackets-memory-db/README.md +++ /dev/null @@ -1 +0,0 @@ -Taken from https://github.com/Drarig29/brackets-storage diff --git a/app/modules/brackets-memory-db/index.ts b/app/modules/brackets-memory-db/index.ts deleted file mode 100644 index d09cc3d96..000000000 --- a/app/modules/brackets-memory-db/index.ts +++ /dev/null @@ -1,264 +0,0 @@ -import type { - CrudInterface, - Database, - OmitId, - Table, -} from "~/modules/brackets-manager"; - -export class InMemoryDatabase implements CrudInterface { - protected data: Database = { - stage: [], - group: [], - round: [], - match: [], - }; - - /** - * @param data "import" data from external - */ - setData(data: Database): void { - this.data = data; - } - - /** - * @param partial Filter - */ - makeFilter(partial: any): (entry: any) => boolean { - return (entry: any): boolean => { - let result = true; - for (const key of Object.keys(partial)) - result = result && entry[key] === partial[key]; - - return result; - }; - } - - /** - * Clearing all of the data - */ - reset(): void { - this.data = { - stage: [], - group: [], - round: [], - match: [], - }; - } - - insert(table: Table, value: OmitId): number; - /** - * Inserts multiple values in the database. - * - * @param table Where to insert. - * @param values What to insert. - */ - insert(table: Table, values: OmitId[]): boolean; - - /** - * Implementation of insert - * - * @param table Where to insert. - * @param values What to insert. - */ - insert(table: Table, values: OmitId | OmitId[]): number | boolean { - let id = - this.data[table].length > 0 - ? Math.max(...this.data[table].map((d) => d.id)) + 1 - : 0; - - if (!Array.isArray(values)) { - try { - // @ts-expect-error idc - if (values.id) { - // @ts-expect-error idc - this.data[table][values.id] = values; - } else { - // @ts-expect-error imported - this.data[table].push({ id, ...values }); - } - } catch { - return -1; - } - return id; - } - - try { - for (const object of values) { - // @ts-expect-error idc - if (object.id) { - // @ts-expect-error idc - this.data[table][object.id] = object; - } else { - // @ts-expect-error imported - this.data[table].push({ id: id++, ...object }); - } - } - } catch { - return false; - } - - return true; - } - - /** - * Gets all data from a table in the database. - * - * @param table Where to get from. - */ - select(table: Table): T[] | null; - /** - * Gets specific data from a table in the database. - * - * @param table Where to get from. - * @param id What to get. - */ - select(table: Table, id: number): T | null; - /** - * Gets data from a table in the database with a filter. - * - * @param table Where to get from. - * @param filter An object to filter data. - */ - select(table: Table, filter: Partial): T[] | null; - - /** - * @param table Where to get from. - * @param arg Arg. - */ - select(table: Table, arg?: number | Partial): T[] | null { - try { - if (arg === undefined) { - // @ts-expect-error imported - return this.data[table].map((val) => structuredClone(val)); - } - - if (typeof arg === "number") { - // @ts-expect-error imported - return structuredClone(this.data[table].find((d) => d?.id === arg)); - } - - // @ts-expect-error imported - return this.data[table] - .filter(this.makeFilter(arg)) - .map((val) => structuredClone(val)); - } catch { - return null; - } - } - - /** - * Updates data in a table. - * - * @param table Where to update. - * @param id What to update. - * @param value How to update. - */ - - update(table: Table, id: number, value: T): boolean; - - /** - * Updates data in a table. - * - * @param table Where to update. - * @param filter An object to filter data. - * @param value How to update. - */ - update(table: Table, filter: Partial, value: Partial): boolean; - - /** - * Updates data in a table. - * - * @param table Where to update. - * @param arg - * @param value How to update. - */ - update( - table: Table, - arg: number | Partial, - value?: Partial, - ): boolean { - if (typeof arg === "number") { - if ( - this.data[table][arg] && - value && - // @ts-expect-error idc - this.data[table][arg].id !== value.id - ) { - throw new Error("ID mismatch."); - } - try { - // @ts-expect-error imported - this.data[table][arg] = value; - return true; - } catch { - return false; - } - } - - const values = this.data[table].filter(this.makeFilter(arg)); - if (!values) { - return false; - } - - for (const v of values) { - const existing = this.data[table][v.id]; - for (const key in value) { - if ( - // @ts-expect-error imported - existing[key] && - typeof existing[key] === "object" && - typeof value[key] === "object" - ) { - Object.assign(existing[key], value[key]); // For opponent objects, this does a deep merge of level 2. - } else { - // @ts-expect-error imported - existing[key] = value[key]; // Otherwise, do a simple value assignment. - } - } - this.data[table][v.id] = existing; - } - - return true; - } - - /** - * Empties a table completely. - * - * @param table Where to delete everything. - */ - delete(table: Table): boolean; - /** - * Delete data in a table, based on a filter. - * - * @param table Where to delete in. - * @param filter An object to filter data. - */ - delete(table: Table, filter: Partial): boolean; - - /** - * Delete data in a table, based on a filter. - * - * @param table Where to delete in. - * @param filter An object to filter data. - */ - delete(table: Table, filter?: Partial): boolean { - const values = this.data[table]; - if (!values) { - return false; - } - - if (!filter) { - this.data[table] = []; - - return true; - } - - const predicate = this.makeFilter(filter); - const negativeFilter = (value: any): boolean => !predicate(value); - - // @ts-expect-error imported - this.data[table] = values.filter(negativeFilter); - - return true; - } -} diff --git a/app/modules/brackets-model/index.ts b/app/modules/brackets-model/index.ts deleted file mode 100644 index 78c2505d4..000000000 --- a/app/modules/brackets-model/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./input"; -export * from "./other"; -export * from "./storage"; -export * from "./unions"; diff --git a/app/modules/brackets-model/input.ts b/app/modules/brackets-model/input.ts deleted file mode 100644 index 205fed8dd..000000000 --- a/app/modules/brackets-model/input.ts +++ /dev/null @@ -1,147 +0,0 @@ -/*------------------------------------------------------------| - * Contains everything which is given by the user as input. - *-----------------------------------------------------------*/ - -import type { - GrandFinalType, - RoundRobinMode, - SeedOrdering, - StageType, -} from "./unions"; - -/** - * The seeding for a stage. - * - * Each element represents a participant, which can be: - * - A full object, with possibly extra fields. - * - Its name. - * - Its ID. - * - Or a BYE: `null`. - */ -export type Seeding = (number | null)[]; - -/** - * Used to create a stage. - */ -export interface InputStage { - /** - * ID of the parent tournament. - * - * Used to determine the `number` property of a stage related to a tournament. - */ - tournamentId: number; - - /** Name of the stage. */ - name: string; - - /** Type of stage. */ - type: StageType; - - /** The number of the stage in its tournament. Is determined if not given. */ - number?: number; - - /** Contains participants or `null` for BYEs. */ - seeding?: Seeding; - - /** - * A/B division assignment parallel to `seeding`. `0` = A, `1` = B. - * Required when `settings.hasAbDivisions` is `true`. - */ - abDivisions?: (0 | 1)[]; - - /** Contains optional settings specific to each stage type. */ - settings?: StageSettings; -} - -/** - * The possible settings for a stage. - */ -export interface StageSettings { - /** - * The number of participants. - */ - size?: number; - - /** - * A list of ordering methods to apply to the seeding. - * - * - For a round-robin stage: 1 item required (**with** `"groups."` prefix). - * - Used to distribute in groups. - * - For a simple elimination stage, 1 item required (**without** `"groups."` prefix). - * - Used to distribute in round 1. - * - For a double elimination stage, 1 item required, 3+ items supported (**without** `"groups."` prefix). - * - Item 1 (required) - Used to distribute in WB round 1. - * - Item 2 - Used to distribute WB losers in LB round 1. - * - Items 3+ - Used to distribute WB losers in LB minor rounds (1 per round). - */ - seedOrdering?: SeedOrdering[]; - - /** - * Whether to balance BYEs in the seeding of an elimination stage. - * - * This prevents having BYE against BYE in matches. - */ - balanceByes?: boolean; - - /** - * Number of groups in a round-robin stage. - */ - groupCount?: number; - - /** - * The mode for the round-robin stage. - * - * - If `simple`, each participant plays each opponent once. - * - If `double`, each participant plays each opponent twice, once at home and once away. - */ - roundRobinMode?: RoundRobinMode; - - /** - * Whether to generate a bipartite round-robin where teams are split into two - * A/B divisions and every match pairs one A team with one B team. - * Only valid on round-robin stages. - */ - hasAbDivisions?: boolean; - - /** - * A list of seeds per group for a round-robin stage to be manually ordered. - * - * Seed ordering is ignored if this property is given. - */ - manualOrdering?: number[][]; - - /** - * Whether matches in a round-robin stage are playable independently of each other. - * - * - If `false` (default), only round 1 matches start `Ready`; later rounds start - * `Locked` and unlock as both opponents complete the previous round. - * - If `true`, every match with two opponents starts `Ready` (used by league - * formats where weeks are scheduled independently and may be played out of order). - */ - independentRounds?: boolean; - - /** - * Optional final between semi-final losers. - */ - consolationFinal?: boolean; - - /** - * Whether to skip the first round of the WB of a double elimination stage. - */ - skipFirstRound?: boolean; - - /** - * Optional grand final between WB and LB winners. - * - * - If `none`, there is no grand final. - * - If `simple`, the final is a single match. The winner is the winner of the stage. - * - If `double`, if the WB winner wins, he's the winner of the stage. But if he loses, the final is reset and there is a very last match. - * It might be fairer since it gives the WB winner the right to lose once during the stage... - */ - grandFinal?: GrandFinalType; - - swiss?: { - groupCount: number; - roundCount: number; - }; -} diff --git a/app/modules/brackets-model/other.ts b/app/modules/brackets-model/other.ts deleted file mode 100644 index d798ce15a..000000000 --- a/app/modules/brackets-model/other.ts +++ /dev/null @@ -1,66 +0,0 @@ -/*---------------------------------------------------------------------------| - * Contains the rest of the types which doesn't belong to the other files. - *--------------------------------------------------------------------------*/ - -import type { Result } from "./unions"; - -/** - * The possible status for a match. - */ -export const Status = { - /** The two matches leading to this one are not completed yet. */ - Locked: 0, - - /** One participant is ready and waiting for the other one. */ - Waiting: 1, - - /** Both participants are ready to start. */ - Ready: 2, - - /** The match is running. */ - Running: 3, - - /** The match is completed. */ - Completed: 4, -}; -export type Status = (typeof Status)[keyof typeof Status]; - -/** - * The results of a participant in a match. - */ -export interface ParticipantResult { - /** If `null`, the participant is to be determined. */ - id: number | null; - - /** Indicates where the participant comes from. */ - position?: number; - - /** If this participant forfeits, the other automatically wins. */ - forfeit?: boolean; - - /** The current score of the participant. */ - score?: number; - - /** How many points in total participant scored in total this set. KO = 100 points. Getting KO'd = 0 points. */ - totalPoints?: number; - - /** How many KO wins (100-0 games) the participant scored in this set. */ - totalKos?: number; - - /** Tells what is the result of a duel for this participant. */ - result?: Result; -} - -/** - * Only contains information about match status and results. - */ -export interface MatchResults { - /** Status of the match. */ - status: Status; - - /** First opponent of the match. */ - opponent1: ParticipantResult | null; - - /** Second opponent of the match. */ - opponent2: ParticipantResult | null; -} diff --git a/app/modules/brackets-model/storage.ts b/app/modules/brackets-model/storage.ts deleted file mode 100644 index 02c37ce5f..000000000 --- a/app/modules/brackets-model/storage.ts +++ /dev/null @@ -1,95 +0,0 @@ -/*-----------------------------------------------------------------| - * Contains the types which are persisted in the chosen storage. - *----------------------------------------------------------------*/ - -import type { TournamentRoundMaps } from "~/db/tables"; -import type { StageSettings } from "./input"; -import type { MatchResults } from "./other"; -import type { StageType } from "./unions"; - -/** - * A participant of a stage (team or individual). - */ - -/** - * A stage, which can be a round-robin stage or a single/double elimination stage. - */ -export interface Stage { - /** ID of the stage. */ - id: number; - - /** ID of the tournament this stage belongs to. */ - tournament_id: number; - - /** Name of the stage. */ - name: string; - - /** Type of the stage. */ - type: StageType; - - /** Settings of the stage. */ - settings: StageSettings; - - /** The number of the stage in its tournament. */ - number: number; - - createdAt?: number | null; -} - -/** - * A group of a stage. - */ -export interface Group { - /** ID of the group. */ - id: number; - - /** ID of the parent stage. */ - stage_id: number; - - /** The number of the group in its stage. */ - number: number; -} - -// The next levels don't have a `name` property. They are automatically named with their `number` and their context (parent levels). - -/** - * A round of a group. - */ -export interface Round { - /** ID of the round. */ - id: number; - - /** ID of the parent stage. */ - stage_id: number; - - /** ID of the parent group. */ - group_id: number; - - /** The number of the round in its group. */ - number: number; - - /** Info about the maps count */ - maps?: Pick | null; -} - -/** - * A match of a round. - */ -export interface Match extends MatchResults { - /** ID of the match. */ - id: number; - - /** ID of the parent stage. */ - stage_id: number; - - /** ID of the parent group. */ - group_id: number; - - /** ID of the parent round. */ - round_id: number; - - /** The number of the match in its round. */ - number: number; - - startedAt?: number | null; -} diff --git a/app/modules/brackets-model/unions.ts b/app/modules/brackets-model/unions.ts deleted file mode 100644 index 3cef1aca7..000000000 --- a/app/modules/brackets-model/unions.ts +++ /dev/null @@ -1,55 +0,0 @@ -/*----------------------------------------| - * Contains all the string union types. - *---------------------------------------*/ - -/** - * The only supported types of stage. - */ -export type StageType = - | "round_robin" - | "single_elimination" - | "double_elimination" - | "swiss"; - -/** - * All the possible types of group in an elimination stage. - * - * - `single_bracket` for single elimination. - * - `winner_bracket` and `loser_bracket` for double elimination. - * - `final_group` for both single and double elimination. - */ -export type GroupType = - | "single_bracket" - | "winner_bracket" - | "loser_bracket" - | "final_group"; - -/** - * The possible types for a double elimination stage's grand final. - */ -export type GrandFinalType = "none" | "simple" | "double"; - -/** - * The possible modes for a round-robin stage. - */ -export type RoundRobinMode = "simple" | "double"; - -/** - * Used to order seeds. - */ -export type SeedOrdering = - | "natural" - | "reverse" - | "half_shift" - | "reverse_half_shift" - | "pair_flip" - | "inner_outer" - | "space_between" - | "groups.effort_balanced" - | "groups.seed_optimized" - | "groups.bracket_optimized"; - -/** - * The possible results of a duel for a participant. - */ -export type Result = "win" | "loss";