mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-27 22:13:56 -05:00
485 lines
11 KiB
TypeScript
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");
|
|
}
|
|
}
|