mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-23 20:07:35 -05:00
- Fixes issue where sibling brackets at the same progression depth weren't matched when they had different source indices - Adds `bracketDepth()` function to calculate bracket depth in progression tree - Updates sibling bracket detection to compare depth instead of source bracket indices - Adds test coverage for multiple parallel starting groups scenario
195 lines
5.3 KiB
TypeScript
195 lines
5.3 KiB
TypeScript
import * as R from "remeda";
|
|
import type { PreparedMaps } from "~/db/tables";
|
|
import { nullFilledArray } from "~/utils/arrays";
|
|
import invariant from "~/utils/invariant";
|
|
import type { Bracket } from "./Bracket";
|
|
import * as Progression from "./Progression";
|
|
import type { Tournament } from "./Tournament";
|
|
|
|
/** Returns the prepared maps for one exact bracket index OR maps of a "sibling bracket" i.e. bracket that has the same depth in progression */
|
|
export function resolvePreparedForTheBracket({
|
|
preparedByBracket,
|
|
bracketIdx,
|
|
tournament,
|
|
}: {
|
|
preparedByBracket?: (PreparedMaps | null)[];
|
|
bracketIdx: number;
|
|
tournament: Tournament;
|
|
}) {
|
|
const bracketMaps = preparedByBracket?.[bracketIdx];
|
|
|
|
// maps exactly for this bracket have been prepared, use them
|
|
if (bracketMaps) {
|
|
return bracketMaps;
|
|
}
|
|
|
|
const bracketPreparingFor = tournament.bracketByIdx(bracketIdx)!;
|
|
const bracketProgression = tournament.ctx.settings.bracketProgression;
|
|
const targetDepth = Progression.bracketDepth(bracketIdx, bracketProgression);
|
|
|
|
// lets look for an "equivalent" prepared bracket to use
|
|
// e.g. SoS RR -> 4x SE style the SE brackets can share maps
|
|
for (const [anotherBracketIdx, bracket] of bracketProgression.entries()) {
|
|
const depth = Progression.bracketDepth(
|
|
anotherBracketIdx,
|
|
bracketProgression,
|
|
);
|
|
|
|
if (
|
|
bracket.type === bracketPreparingFor.type &&
|
|
depth === targetDepth &&
|
|
R.isDeepEqual(bracket.settings, bracketPreparingFor.settings)
|
|
) {
|
|
const bracketMaps = preparedByBracket?.[anotherBracketIdx];
|
|
|
|
if (bracketMaps) {
|
|
return bracketMaps;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
const ELIMINATION_BRACKET_TEAM_RANGES = [
|
|
{ min: 2, max: 2 },
|
|
{ min: 3, max: 4 },
|
|
{ min: 5, max: 8 },
|
|
{ min: 9, max: 16 },
|
|
{ min: 17, max: 32 },
|
|
{ min: 33, max: 64 },
|
|
{ min: 65, max: 128 },
|
|
{ min: 129, max: 256 },
|
|
] as const;
|
|
|
|
/** For single elimination and double elimination returns the amount of options that are the "steps" that affect the round count. Takes in currentCount as an argument, filtering out counts below that. */
|
|
export function eliminationTeamCountOptions(currentCount: number) {
|
|
return ELIMINATION_BRACKET_TEAM_RANGES.filter(
|
|
({ max }) => max >= currentCount,
|
|
);
|
|
}
|
|
|
|
/** Validates that given count is a known "max" elimination team count value */
|
|
export function isValidMaxEliminationTeamCount(count: number) {
|
|
return ELIMINATION_BRACKET_TEAM_RANGES.some(({ max }) => max === count);
|
|
}
|
|
|
|
interface TrimPreparedEliminationMapsAgs {
|
|
preparedMaps: PreparedMaps | null;
|
|
teamCount: number;
|
|
bracket: Bracket;
|
|
}
|
|
|
|
/** Trim prepared elimination bracket maps to match the actual number. If not prepared or prepared for too few returns null */
|
|
export function trimPreparedEliminationMaps({
|
|
preparedMaps,
|
|
teamCount,
|
|
...rest
|
|
}: TrimPreparedEliminationMapsAgs) {
|
|
if (!preparedMaps) {
|
|
// we did not prepare enough maps
|
|
return null;
|
|
}
|
|
|
|
// eliminationTeamCount should exist here, defensive check
|
|
if (
|
|
!preparedMaps.eliminationTeamCount ||
|
|
preparedMaps.eliminationTeamCount < teamCount
|
|
) {
|
|
// we did not prepared enough maps
|
|
return null;
|
|
}
|
|
|
|
const isPerfectCountMatch =
|
|
preparedMaps.eliminationTeamCount ===
|
|
eliminationTeamCountOptions(teamCount)[0].max;
|
|
|
|
if (isPerfectCountMatch) {
|
|
if (thirdPlaceMatchDisappeared({ preparedMaps, teamCount, ...rest })) {
|
|
return filterOutThirdPlaceMatch(preparedMaps);
|
|
}
|
|
|
|
return preparedMaps;
|
|
}
|
|
|
|
return trimMapsByTeamCount({ preparedMaps, teamCount, ...rest });
|
|
}
|
|
|
|
function trimMapsByTeamCount({
|
|
preparedMaps,
|
|
teamCount,
|
|
bracket,
|
|
}: TrimPreparedEliminationMapsAgs & { preparedMaps: PreparedMaps }) {
|
|
const actualRounds = bracket.generateMatchesData(
|
|
nullFilledArray(teamCount).map((_, i) => i + 1),
|
|
).round;
|
|
|
|
const groupIds = R.unique(preparedMaps.maps.map((r) => r.groupId));
|
|
|
|
const result = { ...preparedMaps };
|
|
for (const groupId of groupIds) {
|
|
const actualRoundsForGroup = actualRounds.filter(
|
|
(r) => r.group_id === groupId,
|
|
);
|
|
|
|
const preparedRoundsForGroup = preparedMaps.maps.filter(
|
|
(r) => r.groupId === groupId,
|
|
);
|
|
|
|
const actualRoundsCount = actualRoundsForGroup.length;
|
|
|
|
const trimmedRounds = roundsWithVirtualIds(
|
|
preparedRoundsForGroup.slice(
|
|
preparedRoundsForGroup.length - actualRoundsCount,
|
|
),
|
|
actualRoundsForGroup.map((r) => r.id).sort((a, b) => a - b),
|
|
);
|
|
|
|
result.maps = result.maps.filter((r) => r.groupId !== groupId);
|
|
result.maps.push(...trimmedRounds);
|
|
}
|
|
|
|
result.maps.sort((a, b) => {
|
|
if (a.groupId === b.groupId) {
|
|
return a.roundId - b.roundId;
|
|
}
|
|
|
|
return a.groupId - b.groupId;
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
function roundsWithVirtualIds<T extends { roundId: number }>(
|
|
rounds: T[],
|
|
virtualIds: number[],
|
|
) {
|
|
invariant(rounds.length === virtualIds.length, "Round id length mismatch");
|
|
|
|
return rounds.map((r, i) => ({ ...r, roundId: virtualIds[i] }));
|
|
}
|
|
|
|
function thirdPlaceMatchDisappeared({
|
|
bracket,
|
|
preparedMaps,
|
|
teamCount,
|
|
}: TrimPreparedEliminationMapsAgs & { preparedMaps: PreparedMaps }) {
|
|
if (
|
|
bracket.type !== "single_elimination" ||
|
|
!bracket.settings?.thirdPlaceMatch
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const preparedHasThirdPlace =
|
|
R.unique(preparedMaps.maps.map((r) => r.groupId)).length > 1;
|
|
|
|
return preparedHasThirdPlace && teamCount < 4;
|
|
}
|
|
|
|
function filterOutThirdPlaceMatch(prepared: PreparedMaps): PreparedMaps {
|
|
return {
|
|
...prepared,
|
|
maps: prepared.maps.filter((map) => map.groupId === 0),
|
|
};
|
|
}
|