mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
377 lines
10 KiB
TypeScript
377 lines
10 KiB
TypeScript
import type { TFunction } from "i18next";
|
|
import * as R from "remeda";
|
|
import type { TournamentRoundMaps } from "~/db/tables";
|
|
import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
|
|
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
|
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
|
import { sourceTypes } from "~/modules/tournament-map-list-generator/constants";
|
|
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types";
|
|
import { logger } from "~/utils/logger";
|
|
import { seededRandom } from "~/utils/random";
|
|
import type { TournamentLoaderData } from "../tournament/loaders/to.$id.server";
|
|
import type { FindMatchById } from "../tournament-bracket/queries/findMatchById.server";
|
|
import type { Standing } from "./core/Bracket";
|
|
import type { Tournament } from "./core/Tournament";
|
|
import type { TournamentDataTeam } from "./core/Tournament.server";
|
|
|
|
export const tournamentWebsocketRoom = (tournamentId: number) =>
|
|
`tournament__${tournamentId}`;
|
|
|
|
export const tournamentMatchWebsocketRoom = (matchId: number) =>
|
|
`match__${matchId}`;
|
|
|
|
const NUM_MAP = {
|
|
"1": ["1", "2", "4"],
|
|
"2": ["2", "1", "3", "5"],
|
|
"3": ["3", "2", "6"],
|
|
"4": ["4", "1", "5", "7"],
|
|
"5": ["5", "2", "4", "6", "8"],
|
|
"6": ["6", "3", "5", "9"],
|
|
"7": ["7", "4", "8"],
|
|
"8": ["8", "7", "5", "9", "0"],
|
|
"9": ["9", "6", "8"],
|
|
"0": ["0", "8"],
|
|
};
|
|
/**
|
|
* Generates a deterministic 4-digit Splatoon private battle room password based on the provided seed.
|
|
*
|
|
* Given the same seed, this function will always return the same password.
|
|
*/
|
|
export function resolveRoomPass(seed: number | string) {
|
|
let pass = "5";
|
|
for (let i = 0; i < 3; i++) {
|
|
const { seededShuffle } = seededRandom(`${seed}-${i}`);
|
|
|
|
const key = pass[i] as keyof typeof NUM_MAP;
|
|
const opts = NUM_MAP[key];
|
|
const next = seededShuffle(opts)[0];
|
|
pass += next;
|
|
}
|
|
|
|
// prevent 5555 since many use it as a default pass
|
|
// making it a bit more common guess
|
|
if (pass === "5555") return "5800";
|
|
|
|
return pass;
|
|
}
|
|
|
|
export function resolveHostingTeam(
|
|
teams: [TournamentDataTeam, TournamentDataTeam],
|
|
) {
|
|
if (teams[0].prefersNotToHost && !teams[1].prefersNotToHost) return teams[1];
|
|
if (!teams[0].prefersNotToHost && teams[1].prefersNotToHost) return teams[0];
|
|
if (!teams[0].seed && !teams[1].seed) return teams[0];
|
|
if (!teams[0].seed) return teams[1];
|
|
if (!teams[1].seed) return teams[0];
|
|
if (teams[0].seed < teams[1].seed) return teams[0];
|
|
if (teams[1].seed < teams[0].seed) return teams[1];
|
|
|
|
logger.error("resolveHostingTeam: unexpected default");
|
|
return teams[0];
|
|
}
|
|
|
|
export function mapCountPlayedInSetWithCertainty({
|
|
bestOf,
|
|
scores,
|
|
}: {
|
|
bestOf: number;
|
|
scores: [number, number];
|
|
}) {
|
|
const maxScore = Math.max(...scores);
|
|
const scoreSum = scores.reduce((acc, curr) => acc + curr, 0);
|
|
|
|
return scoreSum + (Math.ceil(bestOf / 2) - maxScore);
|
|
}
|
|
|
|
export function checkSourceIsValid({
|
|
source,
|
|
match,
|
|
}: {
|
|
source: string;
|
|
match: NonNullable<FindMatchById>;
|
|
}) {
|
|
if (sourceTypes.includes(source as any)) return true;
|
|
|
|
const asTeamId = Number(source);
|
|
|
|
if (match.opponentOne?.id === asTeamId) return true;
|
|
if (match.opponentTwo?.id === asTeamId) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
export function fillWithNullTillPowerOfTwo<T>(arr: T[]) {
|
|
const nextPowerOfTwo = 2 ** Math.ceil(Math.log2(arr.length));
|
|
const nullsToAdd = nextPowerOfTwo - arr.length;
|
|
|
|
return [...arr, ...new Array(nullsToAdd).fill(null)];
|
|
}
|
|
|
|
export function everyMatchIsOver(
|
|
bracket: Pick<TournamentManagerDataSet, "match">,
|
|
) {
|
|
// winners, losers & grand finals+bracket reset are all different stages
|
|
const isDoubleElimination =
|
|
R.unique(bracket.match.map((match) => match.group_id)).length === 3;
|
|
|
|
// tournament didn't start yet
|
|
if (bracket.match.length === 0) return false;
|
|
|
|
let lastWinner = -1;
|
|
for (const [i, match] of bracket.match.entries()) {
|
|
// special case - bracket reset might not be played depending on who wins in the grands
|
|
const isLast = i === bracket.match.length - 1;
|
|
if (isLast && lastWinner === 1 && isDoubleElimination) {
|
|
continue;
|
|
}
|
|
// BYE
|
|
if (match.opponent1 === null || match.opponent2 === null) {
|
|
continue;
|
|
}
|
|
if (
|
|
match.opponent1?.result !== "win" &&
|
|
match.opponent2?.result !== "win"
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
lastWinner = match.opponent1?.result === "win" ? 1 : 2;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
export function everyBracketOver(tournament: TournamentManagerDataSet) {
|
|
const stageIds = tournament.stage.map((stage) => stage.id);
|
|
|
|
for (const stageId of stageIds) {
|
|
const matches = tournament.match.filter(
|
|
(match) => match.stage_id === stageId,
|
|
);
|
|
|
|
if (!everyMatchIsOver({ match: matches })) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
export const bracketHasStarted = (bracket: TournamentManagerDataSet) =>
|
|
bracket.stage[0] && bracket.stage[0].id !== 0;
|
|
|
|
export function matchIsLocked({
|
|
tournament,
|
|
matchId,
|
|
scores,
|
|
}: {
|
|
tournament: Tournament;
|
|
matchId: number;
|
|
scores: [number, number];
|
|
}) {
|
|
if (scores[0] !== 0 || scores[1] !== 0) return false;
|
|
|
|
const locked = tournament.ctx.castedMatchesInfo?.lockedMatches ?? [];
|
|
|
|
return locked.includes(matchId);
|
|
}
|
|
|
|
export function pickInfoText({
|
|
map,
|
|
t,
|
|
teams,
|
|
}: {
|
|
map?: { stageId: StageId; mode: ModeShort; source: TournamentMaplistSource };
|
|
t: TFunction<["tournament"]>;
|
|
teams: [TournamentDataTeam, TournamentDataTeam];
|
|
}) {
|
|
if (!map) return "";
|
|
|
|
if (map.source === teams[0].id) {
|
|
return t("tournament:pickInfo.team", { number: 1 });
|
|
}
|
|
if (map.source === teams[1].id) {
|
|
return t("tournament:pickInfo.team", { number: 2 });
|
|
}
|
|
if (map.source === "TIEBREAKER") {
|
|
return t("tournament:pickInfo.tiebreaker");
|
|
}
|
|
if (map.source === "BOTH") return t("tournament:pickInfo.both");
|
|
if (map.source === "DEFAULT") return t("tournament:pickInfo.default");
|
|
if (map.source === "COUNTERPICK") {
|
|
return t("tournament:pickInfo.counterpick");
|
|
}
|
|
if (map.source === "TO") return "";
|
|
|
|
logger.error(`Unknown source: ${String(map.source)}`);
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Converts a group number to its corresponding letter representation.
|
|
*
|
|
* The function takes a one-based group number and converts it to a string
|
|
* of uppercase letters, similar to how Excel columns are labeled (e.g., 1 -> 'A', 26 -> 'Z', 27 -> 'AA').
|
|
*
|
|
* @param groupNumber - The one-based group number to convert.
|
|
* @returns The letter representation of the group number.
|
|
*/
|
|
export function groupNumberToLetters(groupNumber: number) {
|
|
let letters = "";
|
|
let num = groupNumber - 1; // Adjust for one-based input
|
|
while (num >= 0) {
|
|
letters = String.fromCharCode((num % 26) + 65) + letters;
|
|
num = Math.floor(num / 26) - 1;
|
|
}
|
|
return letters;
|
|
}
|
|
|
|
export function isSetOverByResults({
|
|
results,
|
|
count,
|
|
countType,
|
|
}: {
|
|
results: Array<{ winnerTeamId: number }>;
|
|
count: number;
|
|
countType: TournamentRoundMaps["type"];
|
|
}) {
|
|
const winCounts = new Map<number, number>();
|
|
|
|
for (const result of results) {
|
|
const count = winCounts.get(result.winnerTeamId) ?? 0;
|
|
winCounts.set(result.winnerTeamId, count + 1);
|
|
}
|
|
|
|
if (countType === "PLAY_ALL") {
|
|
return R.sum(Array.from(winCounts.values())) === count;
|
|
}
|
|
|
|
const maxWins = Math.max(...Array.from(winCounts.values()));
|
|
|
|
// best of
|
|
return maxWins >= Math.ceil(count / 2);
|
|
}
|
|
|
|
export function isSetOverByScore({
|
|
scores,
|
|
count,
|
|
countType,
|
|
}: {
|
|
scores: [number, number];
|
|
count: number;
|
|
countType: TournamentRoundMaps["type"];
|
|
}) {
|
|
if (countType === "PLAY_ALL") {
|
|
return R.sum(scores) === count;
|
|
}
|
|
|
|
const matchOverAtXWins = Math.ceil(count / 2);
|
|
return scores[0] === matchOverAtXWins || scores[1] === matchOverAtXWins;
|
|
}
|
|
|
|
export function matchEndedEarly({
|
|
opponentOne,
|
|
opponentTwo,
|
|
count,
|
|
countType,
|
|
}: {
|
|
opponentOne: { score?: number; result?: "win" | "loss" };
|
|
opponentTwo: { score?: number; result?: "win" | "loss" };
|
|
count: number;
|
|
countType: TournamentRoundMaps["type"];
|
|
}) {
|
|
if (opponentOne.result !== "win" && opponentTwo.result !== "win") {
|
|
return false;
|
|
}
|
|
|
|
const scores: [number, number] = [
|
|
opponentOne.score ?? 0,
|
|
opponentTwo.score ?? 0,
|
|
];
|
|
|
|
return !isSetOverByScore({ scores, count, countType });
|
|
}
|
|
|
|
export function tournamentTeamToActiveRosterUserIds(
|
|
team: TournamentLoaderData["tournament"]["ctx"]["teams"][number],
|
|
teamMinMemberCount: number,
|
|
) {
|
|
if (
|
|
team.activeRosterUserIds &&
|
|
team.activeRosterUserIds.length === teamMinMemberCount
|
|
) {
|
|
return team.activeRosterUserIds;
|
|
}
|
|
|
|
// they don't need to select active roster as they have no subs
|
|
if (team.members.length === teamMinMemberCount) {
|
|
return team.members.map((member) => member.userId);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// deal with user getting added to multiple teams by the TO
|
|
export function ensureOneStandingPerUser(standings: Standing[]) {
|
|
const userIds = new Set<number>();
|
|
|
|
return standings.map((standing) => {
|
|
return {
|
|
...standing,
|
|
team: {
|
|
...standing.team,
|
|
members: standing.team.members.filter((member) => {
|
|
if (userIds.has(member.userId)) return false;
|
|
userIds.add(member.userId);
|
|
return true;
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validates the assignment of badges to receivers in a tournament finalization context.
|
|
*
|
|
* Checks the following conditions:
|
|
* - Each badge receiver references a valid badge from the provided list.
|
|
* - Every badge has at least one assigned receiver (both team and at least one user).
|
|
* - No duplicate tournament team IDs exist among the badge receivers.
|
|
*
|
|
* Returns `null` if all validations pass.
|
|
*/
|
|
export function validateBadgeReceivers({
|
|
badgeReceivers,
|
|
badges,
|
|
}: {
|
|
badgeReceivers: TournamentBadgeReceivers;
|
|
badges: ReadonlyArray<{ id: number }>;
|
|
}) {
|
|
if (
|
|
badgeReceivers.some(
|
|
(receiver) => !badges.some((badge) => badge.id === receiver.badgeId),
|
|
)
|
|
) {
|
|
return "BADGE_NOT_FOUND";
|
|
}
|
|
|
|
for (const badge of badges) {
|
|
const owner = badgeReceivers.find(
|
|
(receiver) => receiver.badgeId === badge.id,
|
|
);
|
|
if (!owner || owner.userIds.length === 0) {
|
|
return "BADGE_NOT_ASSIGNED";
|
|
}
|
|
}
|
|
|
|
const tournamentTeamIds = badgeReceivers.map(
|
|
(receiver) => receiver.tournamentTeamId,
|
|
);
|
|
const uniqueTournamentTeamIds = new Set(tournamentTeamIds);
|
|
if (tournamentTeamIds.length !== uniqueTournamentTeamIds.size) {
|
|
return "DUPLICATE_TOURNAMENT_TEAM_ID";
|
|
}
|
|
|
|
return null;
|
|
}
|