mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-28 06:23:09 -05:00
Initial
This commit is contained in:
parent
f33c35c105
commit
3230907121
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<DataTypes>;
|
||||
type TournamentBracketData = TournamentManagerDataSet;
|
||||
|
||||
/** POST /api/tournament/{id}/seeds */
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof tournamentFromDB>>;
|
||||
manager: ReturnType<typeof getServerTournamentManager>;
|
||||
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({
|
||||
|
|
|
|||
353
app/features/tournament-bracket/BracketRepository.server.ts
Normal file
353
app/features/tournament-bracket/BracketRepository.server.ts
Normal file
|
|
@ -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<DB> | Transaction<DB>;
|
||||
|
||||
/**
|
||||
* 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<BracketData> {
|
||||
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<StageSettings>(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<BracketData["round"][number]["maps"]>
|
||||
>(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<number>`(select coalesce(max("number"), 0) + 1 from "TournamentStage" where "tournamentId" = ${args.tournamentId})`,
|
||||
createdAt: databaseTimestampNow(),
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const groupIdMapping = new Map<number, number>();
|
||||
const roundIdMapping = new Map<number, number>();
|
||||
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<string>`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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ParticipantResult & { totalPoints?: number }>(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<T>(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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<any>("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<any>("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<any>("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<any>("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<any>("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<any>("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<any>("group")!.map((group) => group.id),
|
||||
...bracket.groups().map((group) => group.id),
|
||||
);
|
||||
const thirdPlaceMatch = storage
|
||||
.select<any>("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<any>("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<EngineBracket["matches"]>[number]) => boolean,
|
||||
) =>
|
||||
storage
|
||||
.select<any>("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<any>("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<any>("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<any>("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 };
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<InputStage, "type" | "number" | "seeding"> & { 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<number>;
|
||||
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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<T extends Table>(table: T, value: OmitId<DataTypes[T]>): number;
|
||||
insert<T extends Table>(table: T, values: OmitId<DataTypes[T]>[]): boolean;
|
||||
insert<T extends Table>(
|
||||
table: T,
|
||||
arg: OmitId<DataTypes[T]> | OmitId<DataTypes[T]>[],
|
||||
): number | boolean {
|
||||
switch (table) {
|
||||
case "stage": {
|
||||
const value = arg as OmitId<DataTypes["stage"]>;
|
||||
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<DataTypes["group"]>;
|
||||
const group = new Group(undefined, value.stage_id, value.number);
|
||||
group.insert();
|
||||
return group.id!;
|
||||
}
|
||||
|
||||
case "round": {
|
||||
const value = arg as OmitId<DataTypes["round"]>;
|
||||
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<DataTypes["match"]>;
|
||||
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<T extends Table>(table: T): Array<DataTypes[T]> | null;
|
||||
select<T extends Table>(table: T, id: number): DataTypes[T] | null;
|
||||
select<T extends Table>(
|
||||
table: T,
|
||||
filter: Partial<DataTypes[T]>,
|
||||
): Array<DataTypes[T]> | null;
|
||||
select<T extends Table>(
|
||||
table: T,
|
||||
arg?: number | Partial<DataTypes[T]>,
|
||||
): DataTypes[T] | Array<DataTypes[T]> | null {
|
||||
switch (table) {
|
||||
case "stage": {
|
||||
if (typeof arg === "number") {
|
||||
return Stage.getById(arg) as DataTypes[T];
|
||||
}
|
||||
|
||||
const filter = arg as Partial<DataTypes["stage"]> | 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<DataTypes["group"]> | undefined;
|
||||
if (filter?.stage_id && filter.number) {
|
||||
const group = Group.getByStageAndNumber(
|
||||
filter.stage_id,
|
||||
filter.number,
|
||||
);
|
||||
return group ? ([group] as Array<DataTypes[T]>) : null;
|
||||
}
|
||||
|
||||
if (filter?.stage_id) {
|
||||
return Group.getByStageId(filter.stage_id) as Array<DataTypes[T]>;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "round": {
|
||||
if (typeof arg === "number") {
|
||||
return Round.getById(arg) as DataTypes[T];
|
||||
}
|
||||
|
||||
const filter = arg as Partial<DataTypes["round"]> | undefined;
|
||||
if (filter?.group_id && filter.number) {
|
||||
const round = Round.getByGroupAndNumber(
|
||||
filter.group_id,
|
||||
filter.number,
|
||||
);
|
||||
return round ? ([round] as Array<DataTypes[T]>) : null;
|
||||
}
|
||||
|
||||
if (filter?.group_id) {
|
||||
return Round.getByGroupId(filter.group_id) as Array<DataTypes[T]>;
|
||||
}
|
||||
|
||||
if (filter?.stage_id) {
|
||||
return Round.getByStageId(filter.stage_id) as Array<DataTypes[T]>;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "match": {
|
||||
if (typeof arg === "number") {
|
||||
return Match.getById(arg) as DataTypes[T];
|
||||
}
|
||||
|
||||
const filter = arg as Partial<DataTypes["match"]> | undefined;
|
||||
if (filter?.round_id && filter.number) {
|
||||
const match = Match.getByRoundAndNumber(
|
||||
filter.round_id,
|
||||
filter.number,
|
||||
);
|
||||
return match ? ([match] as Array<DataTypes[T]>) : null;
|
||||
}
|
||||
|
||||
if (filter?.stage_id) {
|
||||
return Match.getByStageId(filter.stage_id) as Array<DataTypes[T]>;
|
||||
}
|
||||
|
||||
if (filter?.round_id) {
|
||||
return Match.getByRoundId(filter.round_id) as Array<DataTypes[T]>;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
update<T extends Table>(table: T, id: number, value: DataTypes[T]): boolean;
|
||||
update<T extends Table>(
|
||||
table: T,
|
||||
filter: Partial<DataTypes[T]>,
|
||||
value: Partial<DataTypes[T]>,
|
||||
): boolean;
|
||||
update<T extends Table>(
|
||||
table: T,
|
||||
query: number | Partial<DataTypes[T]>,
|
||||
value: DataTypes[T] | Partial<DataTypes[T]>,
|
||||
): boolean {
|
||||
switch (table) {
|
||||
case "stage": {
|
||||
if (typeof query === "number") {
|
||||
const update = value as Partial<DataTypes["stage"]>;
|
||||
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<T extends Table>(table: T): boolean;
|
||||
delete<T extends Table>(table: T, filter: Partial<DataTypes[T]>): boolean;
|
||||
delete(): boolean {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { getTournamentManager } from "./manager";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
502
app/features/tournament-bracket/core/engine/create/builder.ts
Normal file
502
app/features/tournament-bracket/core/engine/create/builder.ts
Normal file
|
|
@ -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<GroupData, "id">): number {
|
||||
const id = this.data.group.length;
|
||||
this.data.group.push({ id, ...group });
|
||||
return id;
|
||||
}
|
||||
|
||||
insertRound(round: Omit<RoundData, "id">): number {
|
||||
const id = this.data.round.length;
|
||||
this.data.round.push({ id, ...round });
|
||||
return id;
|
||||
}
|
||||
|
||||
insertMatch(match: Omit<MatchData, "id">): 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
37
app/features/tournament-bracket/core/engine/create/index.ts
Normal file
37
app/features/tournament-bracket/core/engine/create/index.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<string>();
|
||||
|
||||
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.");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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: <T>(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<T>(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));
|
||||
}
|
||||
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
|
@ -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]);
|
||||
}
|
||||
145
app/features/tournament-bracket/core/engine/create/swiss.ts
Normal file
145
app/features/tournament-bracket/core/engine/create/swiss.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
368
app/features/tournament-bracket/core/engine/general.test.ts
Normal file
368
app/features/tournament-bracket/core/engine/general.test.ts
Normal file
|
|
@ -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.
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
@ -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<T>(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<T>(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<T>(array: T[]): [T, T][] {
|
|||
*
|
||||
* @param array A list of elements.
|
||||
*/
|
||||
export function ensureNoDuplicates<T>(array: Nullable<T>[]): void {
|
||||
export function ensureNoDuplicates<T>(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<MatchResults>): boolean {
|
|||
* @param match Partial match results.
|
||||
*/
|
||||
function isMatchCompleted(match: DeepPartial<MatchResults>): 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<MatchResults>): boolean {
|
||||
return isMatchWinCompleted(match);
|
||||
return isMatchByeCompleted(match) || isMatchWinCompleted(match);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -661,7 +539,9 @@ export function isMatchByeCompleted(match: DeepPartial<MatchResults>): 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<MatchResults>): 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<keyof Match> = [
|
||||
const ignoredKeys: Array<keyof MatchData> = [
|
||||
"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<MatchResults>,
|
||||
): 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<T>(array: Nullable<T>[]): T[] {
|
||||
function getNonNull<T>(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<T extends MatchResults>(
|
||||
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.
|
||||
12
app/features/tournament-bracket/core/engine/index.ts
Normal file
12
app/features/tournament-bracket/core/engine/index.ts
Normal file
|
|
@ -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";
|
||||
|
|
@ -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" } });
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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() };
|
||||
}
|
||||
|
|
@ -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() };
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<number>();
|
||||
|
||||
constructor(data: BracketData) {
|
||||
this.data = structuredClone(data);
|
||||
}
|
||||
|
||||
select<T extends Table>(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<T extends Table>(
|
||||
table: T,
|
||||
filter: Partial<TableTypes[T]>,
|
||||
): TableTypes[T][] {
|
||||
return (this.data[table] as TableTypes[T][])
|
||||
.filter(makeFilter(filter))
|
||||
.map((row) => structuredClone(row));
|
||||
}
|
||||
|
||||
selectFirst<T extends Table>(
|
||||
table: T,
|
||||
filter: Partial<TableTypes[T]>,
|
||||
): TableTypes[T] | null {
|
||||
const results = this.selectAll(table, filter);
|
||||
return results.length > 0 ? results[0] : null;
|
||||
}
|
||||
|
||||
selectLast<T extends Table>(
|
||||
table: T,
|
||||
filter: Partial<TableTypes[T]>,
|
||||
): 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<T extends object>(
|
||||
partial: Partial<T>,
|
||||
): (row: T) => boolean {
|
||||
const entries = Object.entries(partial);
|
||||
return (row) =>
|
||||
entries.every(([key, value]) => row[key as keyof T] === value);
|
||||
}
|
||||
|
|
@ -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<MatchData>,
|
||||
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,
|
||||
});
|
||||
|
|
@ -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<any>("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<any>("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<any>("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<any>("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<any>("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<any>("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<any>("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<any>("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<any>("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<any>("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<any>("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<any>("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<any>("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<any>("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<any>("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<any>("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<any>("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.
|
||||
});
|
||||
296
app/features/tournament-bracket/core/engine/swiss/pairing.ts
Normal file
296
app/features/tournament-bracket/core/engine/swiss/pairing.ts
Normal file
|
|
@ -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<GeneratedRound, string> {
|
||||
// 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<number>;
|
||||
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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
144
app/features/tournament-bracket/core/engine/test-utils.ts
Normal file
144
app/features/tournament-bracket/core/engine/test-utils.ts
Normal file
|
|
@ -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<ParticipantResult>;
|
||||
opponent2?: Partial<ParticipantResult>;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export class EngineBracket {
|
||||
data: BracketData | undefined;
|
||||
|
||||
create(
|
||||
input: Omit<CreateBracketInput, "settings"> &
|
||||
Partial<Pick<CreateBracketInput, "settings">>,
|
||||
): 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>): 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>): 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<T extends object>(rows: T[], filter?: Partial<T>): T[] {
|
||||
if (!filter) return rows;
|
||||
|
||||
const entries = Object.entries(filter);
|
||||
return rows.filter((row) =>
|
||||
entries.every(([key, value]) => row[key as keyof T] === value),
|
||||
);
|
||||
}
|
||||
340
app/features/tournament-bracket/core/engine/types.ts
Normal file
340
app/features/tournament-bracket/core/engine/types.ts
Normal file
|
|
@ -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<TournamentRoundMaps, "count" | "type" | "pickBan"> | 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<T> {
|
||||
even: T[];
|
||||
odd: T[];
|
||||
}
|
||||
|
||||
/** Type of an object implementing every ordering method. */
|
||||
export type OrderingMap = Record<
|
||||
SeedOrdering,
|
||||
<T>(array: T[], ...args: number[]) => T[]
|
||||
>;
|
||||
|
||||
/** Makes an object type deeply partial. */
|
||||
export type DeepPartial<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
}
|
||||
: 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<ParticipantResult, "id" | "score" | "result" | "forfeit">
|
||||
>;
|
||||
opponent2?: Partial<
|
||||
Pick<ParticipantResult, "id" | "score" | "result" | "forfeit">
|
||||
>;
|
||||
/**
|
||||
* 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;
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -351,7 +351,7 @@ function EditReportedScoresSection({
|
|||
|
||||
const withPoints = tournament.bracketByIdxOrDefault(
|
||||
tournament.matchIdToBracketIdx(data.match.id) ?? 0,
|
||||
).collectResultsWithPoints;
|
||||
).collectsKos;
|
||||
|
||||
return (
|
||||
<div className={styles.editSection}>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<DB["TournamentMatch"]>,
|
||||
"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,
|
||||
|
|
|
|||
|
|
@ -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<any>("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<any>("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<any>("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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<typeof getServerTournamentManager>;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
Taken from https://github.com/Drarig29/brackets-manager.js
|
||||
|
|
@ -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<Match>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Stage>): 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<Group>): 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<Round>): 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<Match>, 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<Stage> = {
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 = <T extends Table>(
|
||||
table: T,
|
||||
filter: Partial<DataTypes[T]>,
|
||||
): DataTypes[T] | null => {
|
||||
const results = this.storage.select<T>(table, filter);
|
||||
if (!results || results.length === 0) return null;
|
||||
return results[0];
|
||||
};
|
||||
|
||||
storage.selectLast = <T extends Table>(
|
||||
table: T,
|
||||
filter: Partial<DataTypes[T]>,
|
||||
): DataTypes[T] | null => {
|
||||
const results = this.storage.select<T>(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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<any>("stage", 0);
|
||||
expect(stage.name).toBe("Amateur");
|
||||
expect(stage.type).toBe("double_elimination");
|
||||
|
||||
expect(storage.select<any>("group")!.length).toBe(3);
|
||||
expect(storage.select<any>("round")!.length).toBe(4 + 6 + 1);
|
||||
expect(storage.select<any>("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<any>("group")!.length).toBe(3);
|
||||
expect(storage.select<any>("round")!.length).toBe(3 + 4 + 2);
|
||||
expect(storage.select<any>("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<any>("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<any>("match", 8).opponent1.id, // Determined opponent for WB round 2
|
||||
).toBe(storage.select<any>("match", 0).opponent1.id); // Winner of first match round 1
|
||||
|
||||
expect(
|
||||
storage.select<any>("match", 8).opponent2.id, // Determined opponent for WB round 2
|
||||
).toBe(storage.select<any>("match", 1).opponent2.id); // Winner of second match round 1
|
||||
|
||||
expect(
|
||||
storage.select<any>("match", 15).opponent2.id, // Determined opponent for LB round 1
|
||||
).toBe(storage.select<any>("match", 1).opponent1.id); // Loser of second match round 1
|
||||
|
||||
expect(
|
||||
storage.select<any>("match", 19).opponent2.id, // Determined opponent for LB round 2
|
||||
).toBe(storage.select<any>("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<any>("match", 1).opponent2.id;
|
||||
let matchSemiLB = storage.select<any>("match", 3);
|
||||
|
||||
expect(matchSemiLB.opponent2.id).toBe(loserId);
|
||||
expect(matchSemiLB.opponent2.result).toBe("win");
|
||||
expect(matchSemiLB.status).toBe(TournamentMatchStatus.Completed);
|
||||
|
||||
expect(
|
||||
storage.select<any>("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<any>("match", 3);
|
||||
expect(matchSemiLB.opponent2.id).toBeNull();
|
||||
expect(matchSemiLB.opponent2.result).toBeUndefined();
|
||||
expect(matchSemiLB.status).toBe(TournamentMatchStatus.Locked);
|
||||
|
||||
expect(storage.select<any>("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<any>("match", 5).opponent1.id, // Determined opponent for the grand final (round 1)
|
||||
).toBe(storage.select<any>("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<any>("match", 5).opponent2.id, // Determined opponent for the grand final (round 1)
|
||||
).toBe(storage.select<any>("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<any>("match", 6).opponent2.id, // Determined opponent for the grand final (round 2)
|
||||
).toBe(storage.select<any>("match", 1).opponent2.id); // Winner of LB Final
|
||||
|
||||
expect(storage.select<any>("match", 5).status).toBe(
|
||||
TournamentMatchStatus.Completed,
|
||||
); // Grand final (round 1)
|
||||
expect(storage.select<any>("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<any>("match", 3); // Determined opponent for LB round 1
|
||||
expect(beforeReset.opponent1.id).toBe(
|
||||
storage.select<any>("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<any>("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<any>("match", 18).opponent2.id, // Determined opponent for last match of LB round 1 (reverse ordering for losers)
|
||||
).toBe(storage.select<any>("match", 0).opponent2.id); // Loser of first match round 1
|
||||
|
||||
manager.update.match({ id: 1, opponent1: { result: "win" } }); // WB 1.2
|
||||
expect(
|
||||
storage.select<any>("match", 18).opponent1.id, // Determined opponent for last match of LB round 1 (reverse ordering for losers)
|
||||
).toBe(storage.select<any>("match", 1).opponent2.id); // Loser of second match round 1
|
||||
|
||||
manager.update.match({ id: 8, opponent1: { result: "win" } }); // WB 2.1
|
||||
expect(
|
||||
storage.select<any>("match", 22).opponent1.id, // Determined opponent for last match of LB round 2 (reverse ordering for losers)
|
||||
).toBe(storage.select<any>("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<any>("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<any>("match", 7).opponent1.position).toBe(1);
|
||||
expect(storage.select<any>("match", 7).opponent2.position).toBe(4);
|
||||
expect(storage.select<any>("match", 8).opponent1.position).toBe(2);
|
||||
expect(storage.select<any>("match", 8).opponent2.position).toBe(3);
|
||||
|
||||
// Match of position 1.
|
||||
manager.update.match({
|
||||
id: 0,
|
||||
opponent1: { result: "win" }, // Loser id: 7.
|
||||
});
|
||||
|
||||
expect(storage.select<any>("match", 7).opponent1.id).toBe(8);
|
||||
|
||||
// Match of position 2.
|
||||
manager.update.match({
|
||||
id: 1,
|
||||
opponent1: { result: "win" }, // Loser id: 4.
|
||||
});
|
||||
|
||||
expect(storage.select<any>("match", 8).opponent1.id).toBe(5);
|
||||
|
||||
// Match of position 3.
|
||||
manager.update.match({
|
||||
id: 2,
|
||||
opponent1: { result: "win" }, // Loser id: 6.
|
||||
});
|
||||
|
||||
expect(storage.select<any>("match", 8).opponent2.id).toBe(7);
|
||||
|
||||
// Match of position 4.
|
||||
manager.update.match({
|
||||
id: 3,
|
||||
opponent1: { result: "win" }, // Loser id: 5.
|
||||
});
|
||||
|
||||
expect(storage.select<any>("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<any>("group")!.length).toBe(3);
|
||||
expect(storage.select<any>("round")!.length).toBe(3 + 6 + 2); // One round less in WB.
|
||||
expect(storage.select<any>("match")!.length).toBe(
|
||||
4 + 2 + 1 + (4 + 4 + 2 + 2 + 1 + 1) + (1 + 1),
|
||||
);
|
||||
|
||||
expect(storage.select<any>("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<any>("match", 0).opponent1.id).toBe(1); // First match of WB.
|
||||
expect(storage.select<any>("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<any>("match", 7).opponent1.id).toBe(2); // First match of LB Round 1 (must stay).
|
||||
expect(storage.select<any>("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<any>("match", 7).opponent2.id).toBe(4); // First match of LB Round 1 (must stay).
|
||||
expect(storage.select<any>("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<any>("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<any>("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<any>("match", 15).opponent1.id).toBe(7); // First match of LB Round 3.
|
||||
|
||||
expect(storage.select<any>("match", 21).opponent1.id).toBe(1); // GF Round 1.
|
||||
expect(storage.select<any>("match", 21).opponent2.id).toBe(9); // GF Round 1.
|
||||
|
||||
manager.update.match({ id: 21, opponent2: { result: "win" } });
|
||||
|
||||
expect(storage.select<any>("match", 21).opponent1.id).toBe(1); // GF Round 2.
|
||||
expect(storage.select<any>("match", 22).opponent2.id).toBe(9); // GF Round 2.
|
||||
|
||||
manager.update.match({ id: 22, opponent2: { result: "win" } });
|
||||
});
|
||||
});
|
||||
|
|
@ -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<any>("match", 2).opponent1.id).toBe(1);
|
||||
expect(storage.select<any>("match", 2).opponent2).toBe(null);
|
||||
|
||||
expect(storage.select<any>("match", 3).opponent1).toBe(null);
|
||||
expect(storage.select<any>("match", 3).opponent2).toBe(null);
|
||||
|
||||
expect(storage.select<any>("match", 4).opponent1).toBe(null);
|
||||
expect(storage.select<any>("match", 4).opponent2).toBe(null);
|
||||
|
||||
expect(storage.select<any>("match", 5).opponent1.id).toBe(1);
|
||||
expect(storage.select<any>("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<any>("match", 0).opponent1.id).toBe(1);
|
||||
expect(storage.select<any>("match", 0).opponent2.id).toBe(2);
|
||||
|
||||
expect(storage.select<any>("match", 1).opponent1).toBe(null);
|
||||
expect(storage.select<any>("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<any>("match", 0).opponent1.id).toBe(1);
|
||||
expect(storage.select<any>("match", 0).opponent2).toBe(null);
|
||||
|
||||
expect(storage.select<any>("match", 1).opponent1.id).toBe(2);
|
||||
expect(storage.select<any>("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<any>("match", 4);
|
||||
expect(matchFromWbRound2.opponent1.position).toBe(undefined);
|
||||
expect(matchFromWbRound2.opponent2.position).toBe(undefined);
|
||||
|
||||
const matchFromLbRound2 = storage.select<any>("match", 9);
|
||||
expect(matchFromLbRound2.opponent2.position).toBe(undefined);
|
||||
|
||||
const matchFromGrandFinal = storage.select<any>("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<any>("match", 0);
|
||||
expect(matchFromWbRound1.opponent1.position).toBe(1);
|
||||
expect(matchFromWbRound1.opponent2.position).toBe(2);
|
||||
|
||||
const matchFromLbRound1 = storage.select<any>("match", 7);
|
||||
expect(matchFromLbRound1.opponent1.position).toBe(1);
|
||||
expect(matchFromLbRound1.opponent2.position).toBe(2);
|
||||
|
||||
const matchFromLbRound2 = storage.select<any>("match", 9);
|
||||
expect(matchFromLbRound2.opponent1.position).toBe(2);
|
||||
|
||||
const matchFromGrandFinal = storage.select<any>("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<any>("match", 0);
|
||||
expect(firstRoundMatchWB.opponent1.position).toBe(1);
|
||||
expect(firstRoundMatchWB.opponent2.position).toBe(16);
|
||||
|
||||
const firstRoundMatchLB = storage.select<any>("match", 15);
|
||||
expect(firstRoundMatchLB.opponent1.position).toBe(8);
|
||||
expect(firstRoundMatchLB.opponent2.position).toBe(7);
|
||||
|
||||
const secondRoundMatchLB = storage.select<any>("match", 19);
|
||||
expect(secondRoundMatchLB.opponent1.position).toBe(2);
|
||||
|
||||
const secondRoundSecondMatchLB = storage.select<any>("match", 20);
|
||||
expect(secondRoundSecondMatchLB.opponent1.position).toBe(1);
|
||||
|
||||
const fourthRoundMatchLB = storage.select<any>("match", 25);
|
||||
expect(fourthRoundMatchLB.opponent1.position).toBe(2);
|
||||
|
||||
const finalRoundMatchLB = storage.select<any>("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<any>("match", 0);
|
||||
expect(match.opponent1.score).toBe(16);
|
||||
expect(match.opponent2.score).toBe(12);
|
||||
expect(match.opponent1.result).toBe("win");
|
||||
|
||||
let semi1 = storage.select<any>("match", 4);
|
||||
expect(semi1.opponent1.result).toBe("win");
|
||||
expect(semi1.opponent2).toBe(null);
|
||||
|
||||
let final = storage.select<any>("match", 6);
|
||||
expect(final.opponent1.result).toBe("win");
|
||||
expect(final.opponent2).toBe(null);
|
||||
|
||||
manager.reset.matchResults(0); // Score stays as is.
|
||||
|
||||
match = storage.select<any>("match", 0);
|
||||
expect(match.opponent1.score).toBe(16);
|
||||
expect(match.opponent2.score).toBe(12);
|
||||
expect(match.opponent1.result).toBe(undefined);
|
||||
|
||||
semi1 = storage.select<any>("match", 4);
|
||||
expect(semi1.opponent1.result).toBe(undefined);
|
||||
expect(semi1.opponent2).toBe(null);
|
||||
|
||||
final = storage.select<any>("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<any>("match", 0).opponent1.result).toBe("win");
|
||||
expect(storage.select<any>("match", 1).opponent1.result).toBe("win");
|
||||
|
||||
manager.importData(initialData);
|
||||
|
||||
expect(storage.select<any>("match", 0).opponent1.result).toBe(undefined);
|
||||
expect(storage.select<any>("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<any>("stage")).toEqual(data.stage);
|
||||
expect(storage.select<any>("group")).toEqual(data.group);
|
||||
expect(storage.select<any>("round")).toEqual(data.round);
|
||||
expect(storage.select<any>("match")).toEqual(data.match);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<any>("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<any>("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<any>("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<any>("group")!.find((group: any) => {
|
||||
const matches = storage.select<any>("match", { group_id: group.id })!;
|
||||
return matches.filter(isRealMatch).length === 1;
|
||||
})!;
|
||||
|
||||
const rounds = storage.select<any>("round", {
|
||||
group_id: shortGroup.id,
|
||||
})!;
|
||||
const matches = storage.select<any>("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<any>("match", 0).opponent1.id).toBe(1);
|
||||
expect(storage.select<any>("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<string>();
|
||||
|
||||
for (const match of storage.select<any>("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<any>("match", 0)!;
|
||||
const round1Match2 = storage.select<any>("match", 1)!;
|
||||
const round2Match1 = storage.select<any>("match", 2)!;
|
||||
const round2Match2 = storage.select<any>("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<any>("match", 2)!;
|
||||
let round2Match2After = storage.select<any>("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<any>("match", 2)!;
|
||||
round2Match2After = storage.select<any>("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<any>("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<any>("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<any>("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<any>("match")!;
|
||||
const allRounds = storage.select<any>("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<any>("match", round2RealMatch.id)!;
|
||||
expect(round2AfterUpdate.status).toBe(2); // Ready
|
||||
});
|
||||
});
|
||||
|
|
@ -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<any>("stage", 0);
|
||||
expect(stage.name).toBe(example.name);
|
||||
expect(stage.type).toBe(example.type);
|
||||
|
||||
expect(storage.select<any>("group")!.length).toBe(1);
|
||||
expect(storage.select<any>("round")!.length).toBe(4);
|
||||
expect(storage.select<any>("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<any>("match", 4).opponent1.id).toBe(1);
|
||||
expect(storage.select<any>("match", 4).opponent2.id).toBe(null);
|
||||
expect(storage.select<any>("match", 5).opponent1).toBe(null);
|
||||
expect(storage.select<any>("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<any>("group")!.length).toBe(2);
|
||||
expect(storage.select<any>("round")!.length).toBe(4);
|
||||
expect(storage.select<any>("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<any>("match", 4).opponent1).toBe(null);
|
||||
expect(storage.select<any>("match", 4).opponent2.id).toBe(4);
|
||||
|
||||
// Consolation final
|
||||
expect(storage.select<any>("match", 7).opponent1).toBe(null);
|
||||
expect(storage.select<any>("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<any>("group")!.length).toBe(1);
|
||||
expect(storage.select<any>("round")!.length).toBe(3);
|
||||
expect(storage.select<any>("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<any>("match", 3).opponent1.id).toBe(
|
||||
storage.select<any>("match", 0).opponent2.id,
|
||||
);
|
||||
expect(storage.select<any>("match", 3).opponent2.id).toBe(
|
||||
storage.select<any>("match", 1).opponent1.id,
|
||||
);
|
||||
expect(storage.select<any>("match", 2).status).toBe(
|
||||
TournamentMatchStatus.Ready,
|
||||
);
|
||||
expect(storage.select<any>("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<any>("match", 2).status).toBe(
|
||||
TournamentMatchStatus.Running,
|
||||
);
|
||||
expect(storage.select<any>("match", 3).status).toBe(
|
||||
TournamentMatchStatus.Ready,
|
||||
);
|
||||
|
||||
manager.update.match({
|
||||
id: 3, // Consolation final
|
||||
opponent1: { score: 12 },
|
||||
opponent2: { score: 9 },
|
||||
});
|
||||
|
||||
expect(storage.select<any>("match", 2).status).toBe(
|
||||
TournamentMatchStatus.Running,
|
||||
);
|
||||
expect(storage.select<any>("match", 3).status).toBe(
|
||||
TournamentMatchStatus.Running,
|
||||
);
|
||||
|
||||
manager.update.match({
|
||||
id: 3, // Consolation final
|
||||
opponent1: { score: 16, result: "win" },
|
||||
opponent2: { score: 9 },
|
||||
});
|
||||
|
||||
expect(storage.select<any>("match", 2).status).toBe(
|
||||
TournamentMatchStatus.Running,
|
||||
);
|
||||
|
||||
manager.update.match({
|
||||
id: 2, // Final
|
||||
opponent1: { score: 16, result: "win" },
|
||||
opponent2: { score: 9 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
<T>(array: T[], ...args: number[]) => T[]
|
||||
>;
|
||||
|
||||
/**
|
||||
* Omits the `id` property of a type.
|
||||
*/
|
||||
export type OmitId<T> = Omit<T, "id">;
|
||||
|
||||
/**
|
||||
* Defines a T which can be null.
|
||||
*/
|
||||
export type Nullable<T> = T | null;
|
||||
|
||||
/**
|
||||
* An object which maps an ID to another ID.
|
||||
*/
|
||||
export type IdMapping = Record<number, number>;
|
||||
|
||||
/**
|
||||
* 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<T> {
|
||||
even: T[];
|
||||
odd: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes an object type deeply partial.
|
||||
*/
|
||||
export type DeepPartial<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
}
|
||||
: T;
|
||||
|
||||
/**
|
||||
* Converts all value types to array types.
|
||||
*/
|
||||
export type ValueToArray<T> = {
|
||||
[K in keyof T]: Array<T[K]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<DataTypes>;
|
||||
|
||||
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<T extends Table>(table: T, value: OmitId<DataTypes[T]>): number;
|
||||
|
||||
/**
|
||||
* Inserts multiple values in the database.
|
||||
*
|
||||
* @param table Where to insert.
|
||||
* @param values What to insert.
|
||||
*/
|
||||
insert<T extends Table>(table: T, values: OmitId<DataTypes[T]>[]): boolean;
|
||||
|
||||
/**
|
||||
* Gets all data from a table in the database.
|
||||
*
|
||||
* @param table Where to get from.
|
||||
*/
|
||||
select<T extends Table>(table: T): Array<DataTypes[T]> | null;
|
||||
|
||||
/**
|
||||
* Gets specific data from a table in the database.
|
||||
*
|
||||
* @param table Where to get from.
|
||||
* @param id What to get.
|
||||
*/
|
||||
select<T extends Table>(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<T extends Table>(
|
||||
table: T,
|
||||
filter: Partial<DataTypes[T]>,
|
||||
): Array<DataTypes[T]> | null;
|
||||
|
||||
/**
|
||||
* Updates data in a table.
|
||||
*
|
||||
* @param table Where to update.
|
||||
* @param id What to update.
|
||||
* @param value How to update.
|
||||
*/
|
||||
update<T extends Table>(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<T extends Table>(
|
||||
table: T,
|
||||
filter: Partial<DataTypes[T]>,
|
||||
value: Partial<DataTypes[T]>,
|
||||
): boolean;
|
||||
|
||||
/**
|
||||
* Empties a table completely.
|
||||
*
|
||||
* @param table Where to delete everything.
|
||||
*/
|
||||
delete<T extends Table>(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<T extends Table>(table: T, filter: Partial<DataTypes[T]>): boolean;
|
||||
}
|
||||
|
||||
export interface Storage extends CrudInterface {
|
||||
selectFirst<T extends Table>(
|
||||
table: T,
|
||||
filter: Partial<DataTypes[T]>,
|
||||
): DataTypes[T] | null;
|
||||
selectLast<T extends Table>(
|
||||
table: T,
|
||||
filter: Partial<DataTypes[T]>,
|
||||
): DataTypes[T] | 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<M extends Match = Match>(
|
||||
match: DeepPartial<M>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
Taken from https://github.com/Drarig29/brackets-storage
|
||||
|
|
@ -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<T>(table: Table, value: OmitId<T>): number;
|
||||
/**
|
||||
* Inserts multiple values in the database.
|
||||
*
|
||||
* @param table Where to insert.
|
||||
* @param values What to insert.
|
||||
*/
|
||||
insert<T>(table: Table, values: OmitId<T>[]): boolean;
|
||||
|
||||
/**
|
||||
* Implementation of insert
|
||||
*
|
||||
* @param table Where to insert.
|
||||
* @param values What to insert.
|
||||
*/
|
||||
insert<T>(table: Table, values: OmitId<T> | OmitId<T>[]): 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<T>(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<T>(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<T>(table: Table, filter: Partial<T>): T[] | null;
|
||||
|
||||
/**
|
||||
* @param table Where to get from.
|
||||
* @param arg Arg.
|
||||
*/
|
||||
select<T>(table: Table, arg?: number | Partial<T>): 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<T>(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<T>(table: Table, filter: Partial<T>, value: Partial<T>): boolean;
|
||||
|
||||
/**
|
||||
* Updates data in a table.
|
||||
*
|
||||
* @param table Where to update.
|
||||
* @param arg
|
||||
* @param value How to update.
|
||||
*/
|
||||
update<T>(
|
||||
table: Table,
|
||||
arg: number | Partial<T>,
|
||||
value?: Partial<T>,
|
||||
): 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<T>(table: Table, filter: Partial<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<T>(table: Table, filter?: Partial<T>): 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from "./input";
|
||||
export * from "./other";
|
||||
export * from "./storage";
|
||||
export * from "./unions";
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<TournamentRoundMaps, "count" | "type" | "pickBan"> | 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
Loading…
Reference in New Issue
Block a user