sendou.ink/app/features/tournament-bracket/core/Bracket/Bracket.ts
2026-07-23 18:36:46 +03:00

485 lines
11 KiB
TypeScript

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 {
Round,
TournamentManagerDataSet,
} from "~/features/tournament-bracket/core/engine/types";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import * as AbDivisions from "../AbDivisions";
import * as Engine from "../engine";
import * as Progression from "../Progression";
import type { OptionalIdObject, Tournament } from "../Tournament";
import type { TournamentDataTeam } from "../Tournament.server";
import type { BracketMapCounts } from "../toMapList";
export interface CreateBracketArgs {
id: number;
idx: number;
preview: boolean;
data?: TournamentManagerDataSet;
type: Tables["TournamentStage"]["type"];
canBeStarted?: boolean;
name: string;
teamsPendingCheckIn?: number[];
tournament: Tournament;
createdAt?: number | null;
sources?: {
bracketIdx: number;
placements: number[];
}[];
seeding?: number[];
settings: TournamentStageSettings | null;
requiresCheckIn: boolean;
startTime: Date | null;
}
export interface Standing {
team: TournamentDataTeam;
placement: number;
groupId?: number;
stats?: {
setWins: number;
setLosses: number;
mapWins: number;
mapLosses: number;
koCount?: number;
winsAgainstTied: number;
lossesAgainstTied?: number;
opponentSetWinPercentage?: number;
opponentMapWinPercentage?: number;
};
}
export interface TeamTrackRecord {
wins: number;
losses: number;
}
export abstract class Bracket {
id;
idx;
preview;
data;
simulatedData: TournamentManagerDataSet | undefined;
canBeStarted;
name;
teamsPendingCheckIn;
tournament;
sources;
createdAt;
seeding;
settings;
requiresCheckIn;
startTime;
constructor({
id,
idx,
preview,
data,
canBeStarted,
name,
teamsPendingCheckIn,
tournament,
sources,
createdAt,
seeding,
settings,
requiresCheckIn,
startTime,
}: Omit<CreateBracketArgs, "format">) {
if (!data && !seeding) {
throw new Error("Bracket: seeding or data required");
}
this.id = id;
this.idx = idx;
this.preview = preview;
this.seeding = seeding;
this.tournament = tournament;
this.settings = settings;
this.data = data ?? this.generateMatchesData(this.seeding!);
this.canBeStarted = canBeStarted;
this.name = name;
this.teamsPendingCheckIn = teamsPendingCheckIn;
this.sources = sources;
this.createdAt = createdAt;
this.requiresCheckIn = requiresCheckIn;
this.startTime = startTime;
if (this.tournament.simulateBrackets) {
this.createdSimulation();
}
}
private createdSimulation() {
if (
this.type === "round_robin" ||
this.type === "swiss" ||
this.preview ||
this.tournament.ctx.isFinalized
)
return;
try {
let data = this.data as Engine.BracketData;
const teamOrder = this.teamOrderForSimulation();
let matchesToResolve = true;
let loopCount = 0;
while (matchesToResolve) {
if (loopCount > 100) {
logger.error("Bracket.createdSimulation: loopCount > 100");
break;
}
matchesToResolve = false;
loopCount++;
for (const match of data.match) {
if (!match) continue;
// we have a result already
if (
match.opponent1?.result === "win" ||
match.opponent2?.result === "win"
) {
continue;
}
// no opponent yet, let's simulate this in a coming loop
if (
(match.opponent1 && !match.opponent1.id) ||
(match.opponent2 && !match.opponent2.id)
) {
const isBracketReset =
this.type === "double_elimination" &&
match.id === this.data.match[this.data.match.length - 1].id;
if (!isBracketReset) {
matchesToResolve = true;
}
continue;
}
// BYE
if (match.opponent1 === null || match.opponent2 === null) {
continue;
}
const winner =
(teamOrder.get(match.opponent1.id!) ?? 0) <
(teamOrder.get(match.opponent2.id!) ?? 0)
? 1
: 2;
data = Engine.reportResult(data, {
matchId: match.id,
opponent1: {
score: winner === 1 ? 1 : 0,
result: winner === 1 ? "win" : undefined,
},
opponent2: {
score: winner === 2 ? 1 : 0,
result: winner === 2 ? "win" : undefined,
},
}).data;
}
}
this.simulatedData = data;
} catch (e) {
logger.error("Bracket.createdSimulation: ", e);
}
}
private teamOrderForSimulation() {
const result = new Map(this.tournament.ctx.teams.map((t, i) => [t.id, i]));
for (const match of this.data.match) {
if (
!match.opponent1?.id ||
!match.opponent2?.id ||
(match.opponent1?.result !== "win" && match.opponent2?.result !== "win")
) {
continue;
}
const opponent1Seed = result.get(match.opponent1.id) ?? -1;
const opponent2Seed = result.get(match.opponent2.id) ?? -1;
if (opponent1Seed === -1 || opponent2Seed === -1) {
logger.error("opponent1Seed or opponent2Seed not found");
continue;
}
if (opponent1Seed < opponent2Seed && match.opponent1?.result === "win") {
continue;
}
if (opponent2Seed < opponent1Seed && match.opponent2?.result === "win") {
continue;
}
if (opponent1Seed < opponent2Seed) {
result.set(match.opponent1.id, opponent1Seed + 0.1);
result.set(match.opponent2.id, opponent1Seed);
} else {
result.set(match.opponent2.id, opponent2Seed + 0.1);
result.set(match.opponent1.id, opponent2Seed);
}
}
return result;
}
simulatedMatch(matchId: number) {
if (!this.simulatedData) return;
return this.simulatedData.match
.filter(Boolean)
.find((match) => match.id === matchId);
}
/** Whether reporting a game in this bracket also records if the game was a KO win. */
get collectsKos() {
return false;
}
get type(): Tables["TournamentStage"]["type"] {
throw new Error("not implemented");
}
get standings(): Standing[] {
throw new Error("not implemented");
}
get participantTournamentTeamIds() {
return R.unique(
this.data.match
.flatMap((match) => [match.opponent1?.id, match.opponent2?.id])
.filter(Boolean),
) as number[];
}
currentStandings(_includeUnfinishedGroups: boolean) {
return this.standings;
}
winnersSourceRound(_roundNumber: number): Round | undefined {
return;
}
/** Returns true if this bracket is a starting bracket (i.e., teams in it start their tournament from this bracket). Note: there can be more than one starting bracket. */
get isStartingBracket() {
return !this.sources || this.sources.length === 0;
}
protected standingsWithoutNonParticipants(standings: Standing[]): Standing[] {
return standings.map((standing) => {
return {
...standing,
team: {
...standing.team,
members: standing.team.members.filter((member) =>
this.tournament.ctx.participatedUsers.includes(member.userId),
),
},
};
});
}
generateMatchesData(teams: number[]): TournamentManagerDataSet {
const virtualTournamentId = 1;
if (teams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START) {
const abDivisions =
this.type === "round_robin" && this.settings?.hasAbDivisions === true
? this.abDivisionsForPreview(
teams,
Engine.roundRobinGroupCount(this.settings, teams.length),
)
: undefined;
return Engine.create({
tournamentId: virtualTournamentId,
name: "Virtual",
type: this.type,
seeding: teams,
settings: abDivisions
? this.settings
: {
...this.settings,
hasAbDivisions: false,
},
independentRounds: this.tournament.isLeagueDivision,
abDivisions,
});
}
return { stage: [], group: [], round: [], match: [] };
}
private abDivisionsForPreview(
teams: number[],
groupCount: number,
): (0 | 1)[] | undefined {
const assignments = teams.map((teamId) => {
const team = this.tournament.teamById(teamId);
return team?.abDivision ?? null;
});
const allAssigned = assignments.every(
(value) => value === 0 || value === 1,
);
if (
allAssigned &&
AbDivisions.validate({
abDivisionsBySeedOrder: assignments,
groupCount,
}).isOk()
) {
return assignments as (0 | 1)[];
}
const fakeAssignments: (0 | 1)[] = teams.map((_, index) =>
index % 2 === 0 ? 0 : 1,
);
if (
AbDivisions.validate({
abDivisionsBySeedOrder: fakeAssignments,
groupCount,
}).isOk()
) {
return fakeAssignments;
}
return undefined;
}
get isUnderground() {
return Progression.isUnderground(
this.idx,
this.tournament.ctx.settings.bracketProgression,
);
}
get isFinals() {
return Progression.isFinals(
this.idx,
this.tournament.ctx.settings.bracketProgression,
);
}
get everyMatchOver() {
if (this.preview) return false;
for (const match of this.data.match) {
// BYE
if (match.opponent1 === null || match.opponent2 === null) {
continue;
}
if (
match.opponent1?.result !== "win" &&
match.opponent2?.result !== "win"
) {
return false;
}
}
return true;
}
get enoughTeams() {
return (
this.participantTournamentTeamIds.length >=
TOURNAMENT.ENOUGH_TEAMS_TO_START
);
}
canCheckIn(user: OptionalIdObject) {
// using regular check-in
if (!this.teamsPendingCheckIn) return false;
if (this.startTime) {
const checkInOpen =
sub(this.startTime.getTime(), { hours: 1 }).getTime() < Date.now() &&
this.startTime.getTime() > Date.now();
if (!checkInOpen) return false;
}
const team = this.tournament.teamMemberOfByUser(user);
if (!team) return false;
return this.teamsPendingCheckIn.includes(team.id);
}
source(_options: {
placements: number[];
advanceThreshold?: number;
rest?: boolean;
}): {
relevantMatchesFinished: boolean;
teams: number[];
} {
throw new Error("not implemented");
}
teamsWithNames(teams: { id: number }[]) {
return teams.map((team) => {
const name = this.tournament.ctx.teams.find(
(participant) => participant.id === team.id,
)?.name;
invariant(name, `Team name not found for id: ${team.id}`);
return {
id: team.id,
name,
};
});
}
/**
* Returns match IDs that are currently ongoing (ready to start).
* A match is ongoing when:
* - Both teams are defined
* - No team has an earlier match (lower number) currently in progress
* - Match is not completed
*/
ongoingMatches(): number[] {
const ongoingMatchIds: number[] = [];
const teamsWithOngoingMatches = new Set<number>();
for (const match of this.data.match.toSorted(
(a, b) => a.number - b.number,
)) {
if (!match.opponent1?.id || !match.opponent2?.id) continue;
if (
match.opponent1.result === "win" ||
match.opponent2.result === "win"
) {
continue;
}
if (
teamsWithOngoingMatches.has(match.opponent1.id) ||
teamsWithOngoingMatches.has(match.opponent2.id)
) {
continue;
}
ongoingMatchIds.push(match.id);
teamsWithOngoingMatches.add(match.opponent1.id);
teamsWithOngoingMatches.add(match.opponent2.id);
}
return ongoingMatchIds;
}
defaultRoundBestOfs(_data: TournamentManagerDataSet): BracketMapCounts {
throw new Error("not implemented");
}
}