Refactor tournament data loading (#3298)

This commit is contained in:
Kalle
2026-08-04 22:05:49 +03:00
committed by GitHub
parent 45caf0e2c2
commit b45ecde4cc
116 changed files with 4444 additions and 28069 deletions

View File

@@ -359,13 +359,6 @@ async function persistSeeds(
await TournamentRepository.updateTeamSeeds({
tournamentId: tournament.ctx.id,
teamIds: tournament.ctx.teams.map((team) => team.id),
teamsWithMembers: tournament.ctx.teams.map((team) => ({
teamId: team.id,
members: team.members.map((member) => ({
userId: member.userId,
username: member.username,
})),
})),
});
}
@@ -418,18 +411,19 @@ async function setActiveRosters(tournamentId: number, match: PlayedMatch) {
const team = tournament.teamById(teamId);
invariant(team, `Team ${teamId} is not in the tournament`);
invariant(
team.members.length >= tournament.minMembersPerTeam,
team.memberUserIds.length >= tournament.minMembersPerTeam,
`Team ${teamId} has too few members to play a match`,
);
// a team without subs plays with everybody it has, so it is never asked
if (team.members.length === tournament.minMembersPerTeam) continue;
if (team.memberUserIds.length === tournament.minMembersPerTeam) continue;
await TournamentTeamRepository.setActiveRoster({
teamId,
activeRosterUserIds: team.members
.slice(0, tournament.minMembersPerTeam)
.map((member) => member.userId),
activeRosterUserIds: team.memberUserIds.slice(
0,
tournament.minMembersPerTeam,
),
});
}
}
@@ -481,13 +475,13 @@ async function finalize(tournamentId: number) {
? event.badgePrizes.map((badge) => ({
badgeId: badge.id,
tournamentTeamId: winner.team.id,
userIds: winner.team.members.map((member) => member.userId),
userIds: winner.team.memberUserIds,
}))
: undefined,
trophyReceiver: event?.trophy
? {
trophyId: event.trophy.id,
userIds: winner.team.members.map((member) => member.userId),
userIds: winner.team.memberUserIds,
}
: undefined,
});

View File

@@ -105,11 +105,16 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
? await TournamentRepository.findPickBanEventsByMatchId(match.id)
: [];
const mapPools = await TournamentTeamRepository.findMapPoolsByTeamIds([
opponentOne.id,
opponentTwo.id,
]);
return resolveMapList({
tournamentId: match.tournamentId,
matchId: id,
teams: [opponentOne.id, opponentTwo.id],
mapPoolByTeamId: (teamId) => tournament.teamById(teamId)?.mapPool ?? [],
mapPoolByTeamId: (teamId) => mapPools.get(teamId) ?? [],
mapPickingStyle: match.mapPickingStyle,
maps: match.maps,
tieBreakerMapPool: tournament.ctx.tieBreakerMapPool,

View File

@@ -97,8 +97,7 @@ export const action = async (args: ActionFunctionArgs) => {
userIds: [userId],
notification: {
type: "TO_ADDED_TO_TEAM",
pictureUrl:
tournament.tournamentTeamLogoSrc(team) ?? tournament.ctx.logoUrl,
pictureUrl: team.logoUrl ?? tournament.ctx.logoUrl,
meta: {
adderUsername: user.username,
teamName: team.name,

View File

@@ -43,18 +43,13 @@ export const action = async (args: ActionFunctionArgs) => {
errorToastIfFalsy(team, "Invalid team id");
errorToastIfFalsy(
team.checkIns.length === 0 ||
team.members.length > tournament.minMembersPerTeam,
team.memberUserIds.length > tournament.minMembersPerTeam,
"Can't remove last member from checked in team",
);
errorToastIfFalsy(
team.members.find((m) => m.userId === userId)?.role !== "OWNER",
"Cannot remove team owner",
);
errorToastIfFalsy(team.ownerUserId !== userId, "Cannot remove team owner");
errorToastIfFalsy(
!tournament.hasStarted ||
!tournament
.participatedPlayersByTeamId(teamId)
.some((p) => p.userId === userId),
!tournament.participatedPlayerUserIdsByTeamId(teamId).includes(userId),
"Cannot remove player that has participated in the tournament",
);

View File

@@ -6,9 +6,11 @@ import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import type { Tables } from "~/db/tables";
import { TournamentOverrideProvider } from "~/features/tournament/routes/to.$id";
import type { Bracket as BracketType } from "~/features/tournament-bracket/core/Bracket";
import * as Engine from "~/features/tournament-bracket/core/engine";
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
import type { Tournament as TournamentClass } from "~/features/tournament-bracket/core/Tournament";
import styles from "../bracket-test.module.css";
type FormatType = Tables["TournamentStage"]["type"];
@@ -60,13 +62,14 @@ export default function BracketTestLayout() {
],
},
bracketProgressionOverrides: [],
participatedUsers: teamIds,
},
participatedUserIds: teamIds,
brackets: [] as unknown[],
bracketsMeta: [] as unknown[],
bracketMetaByIdx: () => null,
teamById: (id: number) => teams.find((t) => t.id === id) ?? null,
teamMemberOfByUser: () => null,
isOrganizer: () => false,
tournamentTeamLogoSrc: () => null,
streamingParticipantIds: [] as number[],
streams: [] as unknown[],
isLeagueDivision: false,
@@ -178,16 +181,20 @@ export default function BracketTestLayout() {
</SendouSwitch>
</div>
</div>
<Outlet
context={{
tournament: mockTournament,
bracketExpanded,
setBracketExpanded,
hasChildTournaments: false,
preparedMaps: null,
bracket: mockBracket,
}}
/>
<TournamentOverrideProvider
tournament={mockTournament as unknown as TournamentClass}
>
<Outlet
context={{
tournament: mockTournament,
bracketExpanded,
setBracketExpanded,
hasChildTournaments: false,
preparedMaps: null,
bracket: mockBracket,
}}
/>
</TournamentOverrideProvider>
</Main>
);
}

View File

@@ -20,6 +20,7 @@ import type {
import { chatUsersSearchParams } from "./chat-search-params";
import type { ChatMessage, ChatUser } from "./chat-types";
import { messageTypeToSound, soundEnabled, soundVolume } from "./chat-utils";
import { revalidateWithScope } from "./revalidation-scope";
import { ChatContext } from "./useChatContext";
const PING_INTERVAL_MS = 60_000;
@@ -250,7 +251,9 @@ function ChatProviderInner({
// own form submission already reran loaders, so skip the duplicate fetch.
const isOwnRevalidate =
messageArr[0].revalidateOnly && messageArr[0].authorUserId === userId;
if (!isOwnRevalidate) revalidate();
if (!isOwnRevalidate) {
revalidateWithScope(revalidate, messageArr[0].revalidateScope);
}
}
const sound = messageTypeToSound(messageArr[0].type);

View File

@@ -27,7 +27,12 @@ function logSkalpError(action: string) {
type PartialChatMessage = Pick<
ChatMessage,
"type" | "context" | "room" | "revalidateOnly" | "authorUserId"
| "type"
| "context"
| "room"
| "revalidateOnly"
| "revalidateScope"
| "authorUserId"
>;
interface ChatSystemMessageService {
send: (msg: PartialChatMessage | PartialChatMessage[]) => undefined;
@@ -61,6 +66,7 @@ export const send: ChatSystemMessageService["send"] = (partialMsg) => {
context: partialMsg.context,
type: partialMsg.type,
revalidateOnly: partialMsg.revalidateOnly,
revalidateScope: partialMsg.revalidateScope,
authorUserId: partialMsg.authorUserId ?? actorIdOrNullSafe() ?? undefined,
};
});

View File

@@ -18,6 +18,8 @@ export type SystemMessageType =
export type SystemMessageContext = {
name: string;
};
export type RevalidateScope = "MATCH_RESULTS";
export interface ChatMessage {
id: string;
type?: SystemMessageType;
@@ -25,6 +27,8 @@ export interface ChatMessage {
context?: SystemMessageContext;
/** If true, the purpose of this message is just to run the data loaders again meaning the logic related to showing a new chat message is skipped. Defaults to false. */
revalidateOnly?: boolean;
/** Narrows what data a `revalidateOnly` message may have changed so that routes whose data is unaffected can skip revalidating. Unset means anything may have changed. */
revalidateScope?: RevalidateScope;
/** User id of the actor that triggered this message. Used to skip own-author revalidates so we don't double-fetch loaders right after a form submission. */
authorUserId?: number;
userId?: number;

View File

@@ -0,0 +1,73 @@
import type { ShouldRevalidateFunctionArgs } from "react-router";
import { describe, expect, test } from "vitest";
import {
isMatchResultsScopedRevalidation,
revalidateWithScope,
} from "./revalidation-scope";
const revalidationArgs = () =>
({
currentUrl: new URL("https://sendou.ink/to/1/brackets"),
nextUrl: new URL("https://sendou.ink/to/1/brackets"),
defaultShouldRevalidate: true,
formMethod: undefined,
}) as unknown as ShouldRevalidateFunctionArgs;
const deferred = () => {
let resolve!: () => void;
const promise = new Promise<void>((res) => {
resolve = res;
});
return { promise, resolve };
};
const flushMicrotasks = () => new Promise<void>((res) => setTimeout(res));
describe("revalidateWithScope", () => {
test("scope is active while a scoped revalidation is in flight and cleared after", async () => {
const { promise, resolve } = deferred();
revalidateWithScope(() => promise, "MATCH_RESULTS");
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(true);
resolve();
await flushMicrotasks();
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(false);
});
test("no scope is active for an unscoped revalidation", async () => {
const { promise, resolve } = deferred();
revalidateWithScope(() => promise, undefined);
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(false);
resolve();
await flushMicrotasks();
});
test("a scoped revalidation does not narrow an unscoped one in flight", async () => {
const unscoped = deferred();
const scoped = deferred();
revalidateWithScope(() => unscoped.promise, undefined);
revalidateWithScope(() => scoped.promise, "MATCH_RESULTS");
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(false);
unscoped.resolve();
scoped.resolve();
await flushMicrotasks();
});
test("an unscoped revalidation broadens a scoped one in flight", async () => {
const scoped = deferred();
const unscoped = deferred();
revalidateWithScope(() => scoped.promise, "MATCH_RESULTS");
revalidateWithScope(() => unscoped.promise, undefined);
expect(isMatchResultsScopedRevalidation(revalidationArgs())).toBe(false);
scoped.resolve();
unscoped.resolve();
await flushMicrotasks();
});
});

View File

@@ -0,0 +1,43 @@
import type { ShouldRevalidateFunctionArgs } from "react-router";
import { isRevalidation } from "~/utils/remix";
import type { RevalidateScope } from "./chat-types";
let activeScope: RevalidateScope | null = null;
let pendingRevalidations = 0;
/**
* Runs a websocket broadcast triggered revalidation, remembering the broadcast's scope
* while it is in flight so `shouldRevalidate` implementations can skip loaders whose
* data the broadcast can not have changed.
*/
export function revalidateWithScope(
revalidate: () => Promise<void>,
scope: RevalidateScope | undefined,
) {
if (!scope) {
activeScope = null;
} else if (pendingRevalidations === 0) {
// never narrow the scope of an unscoped revalidation already in flight
activeScope = scope;
}
pendingRevalidations++;
void revalidate().finally(() => {
pendingRevalidations--;
if (pendingRevalidations === 0) {
activeScope = null;
}
});
}
/**
* Whether the pending revalidation is a websocket broadcast scoped to match results,
* meaning only match data (reported scores, pick/ban events) changed. Loaders whose data
* does not derive from match data can return `false` from `shouldRevalidate` for these —
* during a live tournament this is the most frequent broadcast: one per reported game.
*/
export function isMatchResultsScopedRevalidation(
args: ShouldRevalidateFunctionArgs,
) {
return isRevalidation(args) && activeScope === "MATCH_RESULTS";
}

View File

@@ -172,19 +172,19 @@ function tournamentStreamUrl({
matchId: number;
opponentId: number;
}) {
const streamingParticipantIds = new Set(tournament.streamingParticipantIds);
const ownTeamMembers =
tournament.teamMemberOfByUser({ id: friendId })?.members ?? [];
const streamingParticipants = tournament.streamingParticipants;
const ownTeamUserIds =
tournament.teamMemberOfByUser({ id: friendId })?.memberUserIds ?? [];
const friendAccount = streamingTwitchAccount(
ownTeamMembers.filter((member) => member.userId === friendId),
streamingParticipantIds,
ownTeamUserIds.filter((userId) => userId === friendId),
streamingParticipants,
);
if (friendAccount) return twitchUrl(friendAccount);
const teammateAccount = streamingTwitchAccount(
ownTeamMembers.filter((member) => member.userId !== friendId),
streamingParticipantIds,
ownTeamUserIds.filter((userId) => userId !== friendId),
streamingParticipants,
);
if (teammateAccount) return twitchUrl(teammateAccount);
@@ -192,8 +192,8 @@ function tournamentStreamUrl({
if (castAccount) return twitchUrl(castAccount);
const opponentAccount = streamingTwitchAccount(
tournament.teamById(opponentId)?.members ?? [],
streamingParticipantIds,
tournament.teamById(opponentId)?.memberUserIds ?? [],
streamingParticipants,
);
return opponentAccount ? twitchUrl(opponentAccount) : null;
@@ -206,19 +206,22 @@ function liveCastAccount(tournament: Tournament, matchId: number) {
)?.twitchAccount;
if (!castAccount) return null;
const isLive = tournament.ctx.castStreams.some(
(stream) => stream.twitch?.toLowerCase() === castAccount.toLowerCase(),
const isLive = tournament.streams.some(
(stream) =>
stream.twitchUserName.toLowerCase() === castAccount.toLowerCase(),
);
return isLive ? castAccount : null;
}
function streamingTwitchAccount(
players: Array<{ userId: number; streamTwitch: string | null }>,
streamingParticipantIds: ReadonlySet<number>,
userIds: number[],
streamingParticipants: ReadonlyMap<number, string>,
) {
return players.find(
(player) =>
streamingParticipantIds.has(player.userId) && player.streamTwitch,
)?.streamTwitch;
for (const userId of userIds) {
const twitchAccount = streamingParticipants.get(userId);
if (twitchAccount) return twitchAccount;
}
return null;
}

View File

@@ -28,7 +28,7 @@ export const action: ActionFunction = async ({ request }) => {
await ImageRepository.validateById(imageId);
if (image.tournamentId) {
clearTournamentDataCache(imageId);
clearTournamentDataCache(image.tournamentId);
}
}
break;

View File

@@ -96,11 +96,11 @@ export const action: ActionFunction = async ({ request, params }) => {
await TournamentTeamRepository.deleteById(team.id);
for (const member of team.members) {
for (const userId of team.memberUserIds) {
ShowcaseTournaments.removeFromCached({
tournamentId,
type: "participant",
userId: member.userId,
userId,
});
ShowcaseTournaments.updateCachedTournamentTeamCount({
@@ -162,10 +162,11 @@ async function dropTeamOut({
// Set active roster only for teams with subs (can't infer which players played)
// Teams without subs have their roster trivially inferred in summarizer
const hasSubs = droppingTeam.members.length > tournament.minMembersPerTeam;
const hasSubs =
droppingTeam.memberUserIds.length > tournament.minMembersPerTeam;
if (hasSubs && !droppingTeam.activeRosterUserIds) {
const randomRoster = R.sample(
droppingTeam.members.map((m) => m.userId),
droppingTeam.memberUserIds,
tournament.minMembersPerTeam,
);
await TournamentTeamRepository.setActiveRoster({

View File

@@ -3,6 +3,7 @@ import { requireUser } from "~/features/auth/core/user.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import { notify } from "~/features/notifications/core/notify.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import {
clearTournamentDataCache,
@@ -45,10 +46,12 @@ export const action: ActionFunction = async ({ request, params }) => {
// linked teams source their logo from the sendou.ink team, so any pickup avatar is cleared
const avatarImgId = linkedTeamId ? null : data.logo;
let team: NonNullable<ReturnType<typeof tournament.teamById>> | undefined;
if (typeof data.tournamentTeamId === "number") {
team = tournament.teamById(data.tournamentTeamId);
}
const team =
typeof data.tournamentTeamId === "number"
? (
await TournamentRepository.findTeamsFullByTournamentId(tournamentId)
).find((t) => t.id === data.tournamentTeamId)
: undefined;
errorToastIfFalsy(team || !tournament.hasStarted, "Tournament has started");
@@ -120,7 +123,7 @@ export const action: ActionFunction = async ({ request, params }) => {
notification: {
type: "TO_ADDED_TO_TEAM",
pictureUrl:
tournament.tournamentTeamLogoSrc(team) ?? tournament.ctx.logoUrl,
team.team?.logoUrl ?? team.pickupAvatarUrl ?? tournament.ctx.logoUrl,
meta: {
adderUsername: user.username,
teamName: name,

View File

@@ -36,20 +36,9 @@ export const action: ActionFunction = async ({ request, params }) => {
requireTournamentOrganizer(tournament, user);
errorToastIfFalsy(!tournament.hasStarted, "Tournament has started");
const teamsWithMembers = tournament.ctx.teams
.filter((t) => data.seeds.includes(t.id))
.map((team) => ({
teamId: team.id,
members: team.members.map((m) => ({
userId: m.userId,
username: m.username,
})),
}));
await TournamentRepository.updateTeamSeeds({
tournamentId,
teamIds: data.seeds,
teamsWithMembers,
});
message = "Seeds saved successfully";

View File

@@ -1,28 +1,28 @@
import { describe, expect, it } from "vitest";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
import { scopedAndSortedTeams } from "./ExportDialog";
function team(
id: number,
checkIns: TournamentDataTeam["checkIns"],
): TournamentDataTeam {
checkIns: TournamentTeamFull["checkIns"],
): TournamentTeamFull {
return {
id,
name: `Team ${id}`,
seed: id,
createdAt: id,
checkIns,
} as unknown as TournamentDataTeam;
} as unknown as TournamentTeamFull;
}
function checkIns(
rows: Array<{ bracketIdx: number | null; isCheckOut?: number }>,
): TournamentDataTeam["checkIns"] {
): TournamentTeamFull["checkIns"] {
return rows.map((row, i) => ({
bracketIdx: row.bracketIdx,
checkedInAt: i + 1,
isCheckOut: row.isCheckOut ?? 0,
})) as unknown as TournamentDataTeam["checkIns"];
})) as unknown as TournamentTeamFull["checkIns"];
}
// Event-level check-in is stored with bracketIdx === null (see CHECK_IN action:

View File

@@ -6,7 +6,7 @@ import {
} from "~/components/elements/ChipRadio";
import { SendouDialog } from "~/components/elements/Dialog";
import { useTournament } from "~/features/tournament/routes/to.$id";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
import * as CSV from "~/modules/csv";
import { databaseTimestampToDate } from "~/utils/dates";
import { teamPage, userPage } from "~/utils/urls";
@@ -79,7 +79,13 @@ const DEFAULT_FIELDS: ExportField[] = [
"memberUsername",
];
export function ExportDialog({ close }: { close: () => void }) {
export function ExportDialog({
close,
teams: allTeams,
}: {
close: () => void;
teams: TournamentTeamFull[];
}) {
const tournament = useTournament();
const [format, setFormat] = React.useState<ExportFormat>("list");
@@ -104,10 +110,10 @@ export function ExportDialog({ close }: { close: () => void }) {
const onDownload = () => {
const selectedBracket =
bracketIdx !== null ? tournament.brackets[bracketIdx] : null;
bracketIdx !== null ? tournament.bracketsMeta[bracketIdx] : null;
const bracketRequiresOwnCheckIn = Boolean(selectedBracket?.requiresCheckIn);
const teams = scopedAndSortedTeams({
teams: tournament.ctx.teams,
teams: allTeams,
status,
sort,
bracketIdx,
@@ -179,7 +185,7 @@ export function ExportDialog({ close }: { close: () => void }) {
}))}
/>
{tournament.brackets.length > 1 ? (
{tournament.bracketsMeta.length > 1 ? (
<RadioRow
label="Bracket"
value={bracketIdx === null ? "all" : String(bracketIdx)}
@@ -188,7 +194,7 @@ export function ExportDialog({ close }: { close: () => void }) {
}
options={[
{ value: "all", label: "All brackets" },
...tournament.brackets.map((bracket, idx) => ({
...tournament.bracketsMeta.map((bracket, idx) => ({
value: String(idx),
label: bracket.name || `#${idx}`,
})),
@@ -258,7 +264,7 @@ function RadioRow<T extends string>({
}
function hasActiveCheckIn(
team: TournamentDataTeam,
team: TournamentTeamFull,
bracketIdx: number | null,
bracketRequiresOwnCheckIn: boolean,
) {
@@ -285,7 +291,7 @@ export function scopedAndSortedTeams({
bracketRequiresOwnCheckIn,
bracketParticipantIds,
}: {
teams: TournamentDataTeam[];
teams: TournamentTeamFull[];
status: ExportStatus;
sort: ExportSort;
bracketIdx: number | null;
@@ -323,7 +329,7 @@ export function scopedAndSortedTeams({
}
function teamFieldValue(
team: TournamentDataTeam,
team: TournamentTeamFull,
field: (typeof TEAM_FIELDS)[number],
opts: {
checkedInLabel: string;
@@ -355,7 +361,7 @@ function teamFieldValue(
}
function memberFieldValue(
member: TournamentDataTeam["members"][number],
member: TournamentTeamFull["members"][number],
field: (typeof MEMBER_FIELDS)[number],
) {
switch (field) {
@@ -380,7 +386,7 @@ function buildContent({
checkedInLabel,
notCheckedInLabel,
}: {
teams: TournamentDataTeam[];
teams: TournamentTeamFull[];
format: ExportFormat;
fields: Set<ExportField>;
captainsOnly: boolean;
@@ -391,7 +397,7 @@ function buildContent({
}) {
const teamFields = TEAM_FIELDS.filter((field) => fields.has(field));
const memberFields = MEMBER_FIELDS.filter((field) => fields.has(field));
const membersOf = (team: TournamentDataTeam) =>
const membersOf = (team: TournamentTeamFull) =>
captainsOnly
? team.members.filter((member) => member.role === "OWNER")
: team.members;

View File

@@ -0,0 +1,24 @@
import type { LoaderFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import {
requireTournamentOrganizer,
tournamentSharedCached,
tournamentTeamsFullInSeedOrder,
} from "~/features/tournament-bracket/core/Tournament.server";
import type { SerializeFrom } from "~/utils/remix";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
export type TournamentAdminTeamsLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ params }: LoaderFunctionArgs) => {
const user = requireUser();
const { id: tournamentId } = parseParams({ params, schema: idObject });
const tournament = await tournamentSharedCached(tournamentId);
requireTournamentOrganizer({ tournament, user });
return {
teams: await tournamentTeamsFullInSeedOrder({ tournament, user }),
};
};

View File

@@ -0,0 +1,35 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import {
requireTournamentOrganizer,
tournamentSharedCached,
tournamentTeamsFullCached,
} from "~/features/tournament-bracket/core/Tournament.server";
import type { SerializeFrom } from "~/utils/remix";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
export type TournamentAdminRegistrationLoaderData = SerializeFrom<
typeof loader
>;
export const loader = async ({ params }: LoaderFunctionArgs) => {
const user = requireUser();
const { id: tournamentId, tid: tournamentTeamId } = parseParams({
params,
schema: z.object({ id, tid: id.optional() }),
});
const tournament = await tournamentSharedCached(tournamentId);
requireTournamentOrganizer({ tournament, user });
if (typeof tournamentTeamId !== "number") return { team: null };
const team =
(await tournamentTeamsFullCached({ tournamentId, user })).find(
(t) => t.id === tournamentTeamId,
) ?? null;
return { team };
};

View File

@@ -1,26 +1,31 @@
import type { LoaderFunctionArgs } from "react-router";
import * as R from "remeda";
import { requireUser } from "~/features/auth/core/user.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
import {
requireTournamentOrganizer,
tournamentSharedCached,
tournamentTeamsFullInSeedOrder,
} from "~/features/tournament-bracket/core/Tournament.server";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
export const loader = async ({ params }: LoaderFunctionArgs) => {
const user = requireUser();
const { id: tournamentId } = parseParams({ params, schema: idObject });
const tournament = await tournamentFromDBCached({
tournamentId,
user: undefined,
});
const tournament = await tournamentSharedCached(tournamentId);
requireTournamentOrganizer({ tournament, user });
const teams = await tournamentTeamsFullInSeedOrder({ tournament, user });
const userIds = R.unique(
tournament.ctx.teams.flatMap((team) =>
team.members.map((member) => member.userId),
),
teams.flatMap((team) => team.members.map((member) => member.userId)),
);
return {
teams,
seedingSnapshot:
await TournamentRepository.findSeedingSnapshotById(tournamentId),
...(await UserCardRepository.findAllByUserIds({

View File

@@ -13,7 +13,7 @@ import {
X,
} from "lucide-react";
import * as React from "react";
import { Link, useFetcher } from "react-router";
import { Link, useFetcher, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
@@ -27,8 +27,11 @@ import {
} from "~/components/SortableTableHeader";
import { Table } from "~/components/Table";
import { useTournament } from "~/features/tournament/routes/to.$id";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import type {
BracketMeta,
Tournament,
} from "~/features/tournament-bracket/core/Tournament";
import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
import { addSubForUserFormSchema } from "~/features/tournament-lfg/tournament-lfg-schemas";
import { SendouForm } from "~/form/SendouForm";
import {
@@ -40,15 +43,18 @@ import {
} from "~/utils/urls";
import { queryToUserIdentifier } from "~/utils/users";
import { ExportDialog } from "../components/ExportDialog";
import type { TournamentAdminTeamsLoaderData } from "../loaders/to.$id.admin.index.server";
import styles from "./to.$id.admin._index.module.css";
export { action } from "../actions/to.$id.admin.index.server";
export { loader } from "../loaders/to.$id.admin.index.server";
type SortKey = "name" | "checkIn";
export default function TournamentAdminTeamsPage() {
const tournament = useTournament();
const { teams } = useLoaderData<TournamentAdminTeamsLoaderData>();
const [search, setSearch] = React.useState("");
const [sort, setSort] = React.useState<SortState<SortKey>>(null);
@@ -56,12 +62,10 @@ export default function TournamentAdminTeamsPage() {
const maxRosterSize = Math.max(
1,
...tournament.ctx.teams.map((team) => team.members.length),
...teams.map((team) => team.members.length),
);
const filteredTeams = tournament.ctx.teams.filter((team) =>
teamMatchesQuery(team, search),
);
const filteredTeams = teams.filter((team) => teamMatchesQuery(team, search));
const sortedTeams = sortTeams(filteredTeams, sort);
return (
@@ -136,7 +140,7 @@ export default function TournamentAdminTeamsPage() {
colSpan={maxRosterSize + (tournament.ctx.isFinalized ? 2 : 3)}
className={styles.noResults}
>
{tournament.ctx.teams.length === 0
{teams.length === 0
? "No registrations yet"
: "No registrations match your search"}
</td>
@@ -145,7 +149,9 @@ export default function TournamentAdminTeamsPage() {
</tbody>
</Table>
{exportOpen ? <ExportDialog close={() => setExportOpen(false)} /> : null}
{exportOpen ? (
<ExportDialog teams={teams} close={() => setExportOpen(false)} />
) : null}
</div>
);
}
@@ -192,14 +198,14 @@ function TeamRow({
maxRosterSize,
editPage,
}: {
team: TournamentDataTeam;
team: TournamentTeamFull;
maxRosterSize: number;
editPage: string;
}) {
const tournament = useTournament();
const members = sortedMembers(team);
const logoSrc = tournament.tournamentTeamLogoSrc(team);
const logoSrc = team.logoUrl;
return (
<tr
@@ -252,7 +258,7 @@ function TeamRow({
);
}
function CheckInCell({ team }: { team: TournamentDataTeam }) {
function CheckInCell({ team }: { team: TournamentTeamFull }) {
const tournament = useTournament();
const scopes = checkInScopes(tournament, team);
@@ -307,7 +313,7 @@ function TeamRowMenu({
team,
editPage,
}: {
team: TournamentDataTeam;
team: TournamentTeamFull;
editPage: string;
}) {
const tournament = useTournament();
@@ -322,7 +328,7 @@ function TeamRowMenu({
const checkInOpen = tournament.regularCheckInStartInThePast;
const checkedIn = isTournamentCheckedIn(team);
const bracketsRequiringCheckIn = checkInBracketsForTeam(tournament, team);
const eventLabelSuffix = tournament.brackets.some(isCheckInBracket)
const eventLabelSuffix = tournament.bracketsMeta.some(isCheckInBracket)
? " (event)"
: "";
@@ -455,7 +461,7 @@ function TeamRowMenu({
);
}
function sortedMembers(team: TournamentDataTeam) {
function sortedMembers(team: TournamentTeamFull) {
return team.members.toSorted((a, b) => {
if (a.role === "OWNER" && b.role !== "OWNER") return -1;
if (b.role === "OWNER" && a.role !== "OWNER") return 1;
@@ -463,7 +469,7 @@ function sortedMembers(team: TournamentDataTeam) {
});
}
function isTournamentCheckedIn(team: TournamentDataTeam) {
function isTournamentCheckedIn(team: TournamentTeamFull) {
const tournamentLevel = team.checkIns.filter(
(checkIn) => checkIn.bracketIdx === null,
);
@@ -473,22 +479,19 @@ function isTournamentCheckedIn(team: TournamentDataTeam) {
);
}
function isBracketCheckedIn(team: TournamentDataTeam, bracketIdx: number) {
function isBracketCheckedIn(team: TournamentTeamFull, bracketIdx: number) {
return team.checkIns.some(
(checkIn) => checkIn.bracketIdx === bracketIdx && !checkIn.isCheckOut,
);
}
/** Does this bracket have its own opt-in check-in (besides the event check-in)? */
function isCheckInBracket(bracket: Tournament["brackets"][number]) {
function isCheckInBracket(bracket: BracketMeta) {
return bracket.requiresCheckIn;
}
/** Is the team going to play (or pending check-in) in this bracket? */
function isTeamInBracket(
bracket: Tournament["brackets"][number],
teamId: number,
) {
function isTeamInBracket(bracket: BracketMeta, teamId: number) {
return Boolean(
bracket.seeding?.includes(teamId) ||
bracket.teamsPendingCheckIn?.includes(teamId),
@@ -498,15 +501,15 @@ function isTeamInBracket(
/** Check-in brackets the given team is a participant of. */
function checkInBracketsForTeam(
tournament: Tournament,
team: TournamentDataTeam,
team: TournamentTeamFull,
) {
return tournament.brackets.filter(
return tournament.bracketsMeta.filter(
(bracket) => isCheckInBracket(bracket) && isTeamInBracket(bracket, team.id),
);
}
/** The event and the team's check-in brackets paired with its status in each. */
function checkInScopes(tournament: Tournament, team: TournamentDataTeam) {
function checkInScopes(tournament: Tournament, team: TournamentTeamFull) {
return [
{ label: "Event", checkedIn: isTournamentCheckedIn(team) },
...checkInBracketsForTeam(tournament, team).map((bracket) => ({
@@ -517,7 +520,7 @@ function checkInScopes(tournament: Tournament, team: TournamentDataTeam) {
}
function activeCheckInLabels(
team: TournamentDataTeam,
team: TournamentTeamFull,
labelFor: (bracketIdx: number | null) => string,
) {
const byBracket = new Map<number | null, { in: boolean; out: boolean }>();
@@ -543,11 +546,11 @@ function activeCheckInLabels(
return labels;
}
function activeCheckInCount(team: TournamentDataTeam) {
function activeCheckInCount(team: TournamentTeamFull) {
return activeCheckInLabels(team, () => "").length;
}
function teamMatchesQuery(team: TournamentDataTeam, search: string) {
function teamMatchesQuery(team: TournamentTeamFull, search: string) {
const query = search.trim();
if (!query) return true;
@@ -577,7 +580,7 @@ function teamMatchesQuery(team: TournamentDataTeam, search: string) {
return false;
}
function sortTeams(teams: TournamentDataTeam[], sort: SortState<SortKey>) {
function sortTeams(teams: TournamentTeamFull[], sort: SortState<SortKey>) {
const bySeed = teams.toSorted((a, b) => {
const aSeed = a.seed ?? Number.POSITIVE_INFINITY;
const bSeed = b.seed ?? Number.POSITIVE_INFINITY;

View File

@@ -85,7 +85,7 @@ function AuditLogRow({ event }: { event: AuditLogEvent }) {
const detail =
typeof event.metadata?.bracketIdx === "number"
? tournament.brackets[event.metadata.bracketIdx]?.name
? tournament.bracketsMeta[event.metadata.bracketIdx]?.name
: event.metadata?.inGameName;
return (

View File

@@ -59,7 +59,7 @@ export default function TournamentAdminBracketsPage() {
function BracketReset() {
const tournament = useTournament();
const fetcher = useFetcher();
const inProgressBrackets = tournament.brackets.filter((b) => !b.preview);
const inProgressBrackets = tournament.bracketsMeta.filter((b) => !b.preview);
const [_bracketToDelete, setBracketToDelete] = React.useState(
inProgressBrackets[0]?.id,
);
@@ -129,7 +129,7 @@ function BracketProgressionEdit() {
Progression.ParsedBracket[] | null
>(tournament.ctx.settings.bracketProgression);
const disabledBracketIdxs = tournament.brackets
const disabledBracketIdxs = tournament.bracketsMeta
.filter((bracket) => !bracket.preview)
.map((bracket) => bracket.idx);

View File

@@ -1,6 +1,10 @@
import type { LoaderFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import {
requireTournamentVisible,
tournamentDataCached,
tournamentTeamsFullCached,
} from "~/features/tournament-bracket/core/Tournament.server";
import type { SerializeFrom } from "~/utils/remix";
import { badRequestIfFalsy } from "~/utils/remix.server";
import { tournamentImportTeamsSearchParams } from "../tournament-admin-search-params";
@@ -18,13 +22,18 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
tournamentImportTeamsSearchParams.parse(request).fromTournamentId,
);
const fromTournament = await tournamentFromDB({
const { ctx } = await tournamentDataCached({
tournamentId: fromTournamentId,
});
requireTournamentVisible({ ctx, user });
const fromTournamentTeams = await tournamentTeamsFullCached({
tournamentId: fromTournamentId,
user,
});
return {
teams: fromTournament.ctx.teams.map((team) => ({
teams: fromTournamentTeams.map((team) => ({
id: team.id,
name: team.name,
avatarImgId: team.avatarImgId,

View File

@@ -3,18 +3,18 @@ import { describe, expect, test, vi } from "vitest";
import { userEvent } from "vitest/browser";
import { render } from "vitest-browser-react";
const { mockTournament } = vi.hoisted(() => ({
const { mockTournament, mockLoaderData } = vi.hoisted(() => ({
mockTournament: {
ctx: { id: 1, settings: { requireInGameNames: false } },
teamById: vi.fn(),
},
mockLoaderData: { team: null as unknown },
}));
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useParams: () => ({ tid: "10" }),
useLoaderData: () => mockLoaderData,
useFetcher: () => ({
data: undefined,
state: "idle",
@@ -35,6 +35,11 @@ vi.mock(
() => ({ action: vi.fn() }),
);
vi.mock(
"~/features/tournament-admin/loaders/to.$id.admin.registration.$tid.server",
() => ({ loader: vi.fn() }),
);
import TournamentAdminRegistrationPage from "./to.$id.admin.registration.$tid";
function renderPage() {
@@ -51,7 +56,7 @@ const CAPTAIN_NOT_A_MEMBER_ERROR = "The captain must be one of the players";
describe("tournament admin registration - captain field", () => {
test("removing the captain's roster row does not leave a stale captain that fails validation", async () => {
// A linked/edited team whose captain (OWNER) is the first roster member.
mockTournament.teamById.mockReturnValue({
mockLoaderData.team = {
id: 10,
name: "low ink buddies",
team: undefined,
@@ -61,7 +66,7 @@ describe("tournament admin registration - captain field", () => {
{ userId: 1, username: "sanu", inGameName: null, role: "OWNER" },
{ userId: 2, username: "Jolt", inGameName: null, role: "MEMBER" },
],
});
};
const screen = await renderPage();

View File

@@ -7,7 +7,6 @@ import { render } from "vitest-browser-react";
const { mockTournament } = vi.hoisted(() => ({
mockTournament: {
ctx: { id: 1, settings: { requireInGameNames: false } },
teamById: vi.fn(),
},
}));
@@ -15,8 +14,8 @@ vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
// no tid -> "add new team" flow, where the roster is built via user search
useParams: () => ({}),
// no team -> "add new team" flow, where the roster is built via user search
useLoaderData: () => ({ team: null }),
};
});
@@ -29,6 +28,11 @@ vi.mock(
() => ({ action: vi.fn() }),
);
vi.mock(
"~/features/tournament-admin/loaders/to.$id.admin.registration.$tid.server",
() => ({ loader: vi.fn() }),
);
import TournamentAdminRegistrationPage from "./to.$id.admin.registration.$tid";
const GREY = {

View File

@@ -1,11 +1,11 @@
import { ArrowLeft, Import } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useFetcher, useParams } from "react-router";
import { useFetcher, useLoaderData } from "react-router";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { useTournament } from "~/features/tournament/routes/to.$id";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
import { FormField } from "~/form/FormField";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import type {
@@ -19,6 +19,7 @@ import {
tournamentAdminImportTeamsPage,
tournamentAdminPage,
} from "~/utils/urls";
import type { TournamentAdminRegistrationLoaderData } from "../loaders/to.$id.admin.registration.$tid.server";
import {
type AdminRegistrationFormValues,
adminRegistrationFormSchema,
@@ -28,6 +29,7 @@ import {
import type { ImportTeamsLoaderData } from "./to.$id.admin.import-teams";
export { action } from "../actions/to.$id.admin.registration.server";
export { loader } from "../loaders/to.$id.admin.registration.$tid.server";
type RosterMemberValue = {
userId?: number;
@@ -45,10 +47,7 @@ type LinkedTeamPrefill = {
export default function TournamentAdminRegistrationPage() {
const { t } = useTranslation(["common"]);
const tournament = useTournament();
const { tid } = useParams();
const team =
typeof tid === "string" ? tournament.teamById(Number(tid)) : undefined;
const { team } = useLoaderData<TournamentAdminRegistrationLoaderData>();
const adminPage = tournamentAdminPage(tournament.ctx.id);
@@ -102,7 +101,7 @@ export default function TournamentAdminRegistrationPage() {
);
}
function RegistrationFields({ team }: { team?: TournamentDataTeam }) {
function RegistrationFields({ team }: { team: TournamentTeamFull | null }) {
const { t } = useTranslation(["forms"]);
const tournament = useTournament();
const { values, setValue, revalidateAll, hasSubmitted } =

View File

@@ -35,7 +35,7 @@ import { useTournament } from "~/features/tournament/routes/to.$id";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import * as AbDivisions from "~/features/tournament-bracket/core/AbDivisions";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
import { UserCard } from "~/features/user-card/components/UserCard";
import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
@@ -60,11 +60,10 @@ const AB_DIVISION_RADIO_OPTIONS = [
export default function TournamentAdminSeedsPage() {
const tournament = useTournament();
const { seedingSnapshot, teams } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const [teamOrder, setTeamOrder] = React.useState(
tournament.ctx.teams.map((t) => t.id),
);
const [activeTeam, setActiveTeam] = React.useState<TournamentDataTeam | null>(
const [teamOrder, setTeamOrder] = React.useState(teams.map((t) => t.id));
const [activeTeam, setActiveTeam] = React.useState<TournamentTeamFull | null>(
null,
);
const sensors = useSensors(
@@ -84,24 +83,17 @@ export default function TournamentAdminSeedsPage() {
}),
);
const { seedingSnapshot } = useLoaderData<typeof loader>();
const newTeamIds = computeNewTeamIds(tournament.ctx.teams, seedingSnapshot);
const newPlayersByTeam = computeNewPlayers(
tournament.ctx.teams,
seedingSnapshot,
);
const removedPlayersByTeam = computeRemovedPlayers(
tournament.ctx.teams,
seedingSnapshot,
);
const newTeamIds = computeNewTeamIds(teams, seedingSnapshot);
const newPlayersByTeam = computeNewPlayers(teams, seedingSnapshot);
const removedPlayersByTeam = computeRemovedPlayers(teams, seedingSnapshot);
const teamsSorted = [...tournament.ctx.teams].sort(
const teamsSorted = [...teams].sort(
(a, b) => teamOrder.indexOf(a.id) - teamOrder.indexOf(b.id),
);
const isOutOfOrder = (
team: TournamentDataTeam,
previousTeam?: TournamentDataTeam,
team: TournamentTeamFull,
previousTeam?: TournamentTeamFull,
) => {
if (!previousTeam) return false;
@@ -115,9 +107,7 @@ export default function TournamentAdminSeedsPage() {
return Boolean(previousTeam.avgSeedingSkillOrdinal);
};
const noOrganizerSetSeeding = tournament.ctx.teams.every(
(team) => !team.seed,
);
const noOrganizerSetSeeding = teams.every((team) => !team.seed);
const handleSeedChange = (teamId: number, newSeed: number) => {
if (newSeed < 1) return;
@@ -135,7 +125,7 @@ export default function TournamentAdminSeedsPage() {
};
const sortAllBySp = () => {
const sortedTeams = [...tournament.ctx.teams].sort((a, b) => {
const sortedTeams = [...teams].sort((a, b) => {
if (
a.avgSeedingSkillOrdinal !== null &&
b.avgSeedingSkillOrdinal !== null
@@ -174,17 +164,13 @@ export default function TournamentAdminSeedsPage() {
</div>
{tournament.isMultiStartingBracket ? (
<StartingBracketDialog
key={tournament.ctx.teams
.map((team) => team.startingBracketIdx ?? 0)
.join()}
key={teams.map((team) => team.startingBracketIdx ?? 0).join()}
/>
) : null}
{hasAbDivisionsStartingBracket(tournament) ? (
<>
<AbDivisionsDialog
key={tournament.ctx.teams
.map((team) => team.abDivision ?? -1)
.join()}
key={teams.map((team) => team.abDivision ?? -1).join()}
/>
<AbDivisionImbalanceWarning />
</>
@@ -357,7 +343,7 @@ function StartingBracketDialog() {
const startingBrackets = tournament.ctx.settings.bracketProgression
.flatMap((bracket, bracketIdx) => (!bracket.sources ? [bracketIdx] : []))
.map((bracketIdx) => tournament.bracketByIdx(bracketIdx)!);
.map((bracketIdx) => tournament.bracketsMeta[bracketIdx]);
return (
<div>
@@ -677,7 +663,7 @@ function RowContents({
removedPlayers,
onSeedChange,
}: {
team: TournamentDataTeam;
team: TournamentTeamFull;
seed?: number;
teamSeedingSkill: {
sp: number | null;
@@ -688,7 +674,6 @@ function RowContents({
removedPlayers?: Array<{ userId: number; username: string }>;
onSeedChange?: (newSeed: number) => void;
}) {
const tournament = useTournament();
const [draft, setDraft] = React.useState<string | null>(null);
const inputValue = draft ?? String(seed ?? "");
@@ -700,7 +685,7 @@ function RowContents({
setDraft(null);
};
const logoUrl = tournament.tournamentTeamLogoSrc(team);
const logoUrl = team.logoUrl;
return (
<>
@@ -775,7 +760,7 @@ function RowContents({
}
function computeNewTeamIds(
teams: TournamentDataTeam[],
teams: TournamentTeamFull[],
snapshot: SeedingSnapshot | null,
): Set<number> {
if (!snapshot) return new Set();
@@ -784,7 +769,7 @@ function computeNewTeamIds(
}
function computeNewPlayers(
teams: TournamentDataTeam[],
teams: TournamentTeamFull[],
snapshot: SeedingSnapshot | null,
): Map<number, Set<number>> {
const result = new Map<number, Set<number>>();
@@ -814,7 +799,7 @@ function computeNewPlayers(
}
function computeRemovedPlayers(
teams: TournamentDataTeam[],
teams: TournamentTeamFull[],
snapshot: SeedingSnapshot | null,
): Map<number, Array<{ userId: number; username: string }>> {
const result = new Map<number, Array<{ userId: number; username: string }>>();

View File

@@ -53,7 +53,7 @@ export function adminRegistrationFormSchemaServer({
typeof data.tournamentTeamId === "number"
? tournament.teamById(data.tournamentTeamId)
: undefined;
const currentMemberIds = team?.members.map((member) => member.userId) ?? [];
const currentMemberIds = team?.memberUserIds ?? [];
if (team) {
const submittedMemberIds = data.members.map((member) => member.userId);
@@ -62,9 +62,8 @@ export function adminRegistrationFormSchemaServer({
);
if (tournament.hasStarted) {
const participatedPlayerIds = tournament
.participatedPlayersByTeamId(team.id)
.map((player) => player.userId);
const participatedPlayerIds =
tournament.participatedPlayerUserIdsByTeamId(team.id);
const removingParticipatedPlayer = membersToRemove.some((memberId) =>
participatedPlayerIds.includes(memberId),
);

View File

@@ -1,8 +1,10 @@
import { subDays, subHours } from "date-fns";
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import { sql } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { commonUserSelect } from "~/utils/kysely.server";
import { TOURNAMENT } from "../tournament/tournament-constants";
export type VodsByTournamentId = Awaited<
@@ -21,7 +23,7 @@ export function findVodsByTournamentId(tournamentId: number) {
"TournamentStage.id",
"TournamentMatch.stageId",
)
.select([
.select((eb) => [
"TournamentMatchVod.matchId",
"TournamentMatchVod.userId",
"TournamentMatchVod.platform",
@@ -29,6 +31,29 @@ export function findVodsByTournamentId(tournamentId: number) {
"TournamentMatchVod.platformVideoId",
"TournamentMatchVod.timestampSeconds",
"TournamentMatchVod.viewCount",
jsonObjectFrom(
eb
.selectFrom("User")
.select((innerEb) => commonUserSelect(innerEb))
.whereRef("User.id", "=", "TournamentMatchVod.userId"),
).as("user"),
eb
.selectFrom("TournamentTeam")
.innerJoin(
"TournamentTeamMember",
"TournamentTeamMember.tournamentTeamId",
"TournamentTeam.id",
)
.select("TournamentTeam.name")
.whereRef(
"TournamentTeamMember.userId",
"=",
"TournamentMatchVod.userId",
)
.where(
sql<boolean>`"TournamentTeam"."id" in (json_extract("TournamentMatch"."opponentOne", '$.id'), json_extract("TournamentMatch"."opponentTwo", '$.id'))`,
)
.as("teamName"),
])
.where("TournamentStage.tournamentId", "=", tournamentId)
.orderBy("TournamentMatchVod.viewCount", "desc")

View File

@@ -133,7 +133,7 @@ function requireValidTrophyReceiver({
trophy: { id: number };
finalStandings: Array<{
placement: number;
team: { members: Array<{ userId: number }> };
team: { memberUserIds: number[] };
}>;
tournament: Tournament;
}) {
@@ -157,9 +157,7 @@ function requireValidTrophyReceiver({
return false;
}
const firstPlaceUserIds = new Set(
firstPlace.team.members.map((m) => m.userId),
);
const firstPlaceUserIds = new Set(firstPlace.team.memberUserIds);
const invalidUserId = trophyReceiver.userIds.find(
(userId) => !firstPlaceUserIds.has(userId),
);

View File

@@ -139,13 +139,6 @@ export const action: ActionFunction = async ({ params, request }) => {
await TournamentRepository.updateTeamSeeds({
tournamentId: tournament.ctx.id,
teamIds: tournament.ctx.teams.map((team) => team.id),
teamsWithMembers: tournament.ctx.teams.map((team) => ({
teamId: team.id,
members: team.members.map((m) => ({
userId: m.userId,
username: m.username,
})),
})),
});
}
@@ -169,8 +162,9 @@ export const action: ActionFunction = async ({ params, request }) => {
if (!tournament.isTest && !tournament.isDraft) {
notify({
userIds: seeding.flatMap((tournamentTeamId) =>
tournament.teamById(tournamentTeamId)!.members.map((m) => m.userId),
userIds: seeding.flatMap(
(tournamentTeamId) =>
tournament.teamById(tournamentTeamId)!.memberUserIds,
),
notification: {
type: "TO_BRACKET_STARTED",

View File

@@ -84,14 +84,17 @@ const mockTournament = {
],
},
bracketProgressionOverrides: [],
participatedUsers: [1, 2, 3, 4, 5, 6, 7, 8],
},
participatedUserIds: [1, 2, 3, 4, 5, 6, 7, 8],
streamingParticipantIds: [],
brackets: [],
bracketsMeta: [],
bracketMetaByIdx: () => null,
isLeagueDivision: false,
teamById: (id: number) =>
mockTournament.ctx.teams.find((t) => t.id === id) ?? null,
teamMemberOfByUser: () => null,
isOrganizer: () => false,
tournamentTeamLogoSrc: () => null,
};
vi.mock("~/features/auth/core/user", () => ({

View File

@@ -105,6 +105,7 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
>
<RoundHeader
roundId={round.id}
bracketIdx={props.bracket.idx}
name={round.name}
bestOf={bestOf}
showInfos={someMatchOngoing}

View File

@@ -23,6 +23,7 @@ import {
import type { Bracket } from "../../core/Bracket";
import * as Deadline from "../../core/Deadline";
import type { TournamentData } from "../../core/Tournament.server";
import type { VodsByTournamentId } from "../../TournamentMatchVodRepository.server";
import parentStyles from "../../tournament-bracket.module.css";
import styles from "./bracket.module.css";
@@ -81,7 +82,7 @@ export function Match(props: MatchProps) {
function MatchHeader({ match, type, roundNumber, group }: MatchProps) {
const tournament = useTournament();
const vods = useTournamentVods();
const streamingParticipants = tournament.streamingParticipantIds ?? [];
const streamingParticipants = tournament.streamingParticipantIds;
const prefix = () => {
if (type === "winners") return "WB ";
@@ -104,8 +105,7 @@ function MatchHeader({ match, type, roundNumber, group }: MatchProps) {
}
const matchParticipants = [match.opponent1.id, match.opponent2.id].flatMap(
(teamId) =>
tournament.teamById(teamId)?.members.map((m) => m.userId) ?? [],
(teamId) => tournament.teamById(teamId)?.memberUserIds ?? [],
);
return streamingParticipants.some((p) => matchParticipants.includes(p));
@@ -261,7 +261,7 @@ function MatchRow({
const ownTeam = tournament.teamMemberOfByUser(user);
const logoSrc = team ? tournament.tournamentTeamLogoSrc(team) : null;
const logoSrc = team ? team.logoUrl : null;
const showAvatar = spoilerCensor === "full" ? false : !simulated && team;
const isBigSeedNumber =
@@ -271,15 +271,13 @@ function MatchRow({
const displayedName =
spoilerCensor === "full" ? "???" : (team?.name ?? "???");
// xxx: used to have a title tooltip listing the team's members, removed when rosters
// left the tournament layout data. Check later if it can be brought back e.g. by
// loading the roster on demand when hovering
return (
<div
className={clsx("stack horizontal", { "text-lighter": isLoser })}
data-participant-id={team?.id}
title={
spoilerCensor === "full"
? undefined
: team?.members.map((m) => m.username).join(", ")
}
>
<div
className={clsx(styles.matchSeed, {
@@ -330,7 +328,7 @@ function MatchStreams({ match }: Pick<MatchProps, "match">) {
)?.twitchAccount;
const matchParticipants = [match.opponent1.id, match.opponent2.id].flatMap(
(teamId) => tournament.teamById(teamId)?.members.map((m) => m.userId) ?? [],
(teamId) => tournament.teamById(teamId)?.memberUserIds ?? [],
);
const streamsOfThisMatch = tournament.streams.filter(
@@ -366,29 +364,14 @@ function MatchStreams({ match }: Pick<MatchProps, "match">) {
}
interface MatchVodsProps {
vods: Array<{
matchId: number;
userId: number | null;
platform: string;
account: string;
platformVideoId: string;
timestampSeconds: number;
viewCount: number;
}>;
vods: VodsByTournamentId;
}
function MatchVods({ vods }: MatchVodsProps) {
const tournament = useTournament();
return (
<div className={parentStyles.vodGrid}>
{vods.map((vod) => {
const team = vod.userId
? tournament.ctx.teams.find((t) =>
t.members.some((m) => m.userId === vod.userId),
)
: null;
const user = team?.members.find((m) => m.userId === vod.userId);
const user = vod.user;
return (
<a
@@ -410,7 +393,7 @@ function MatchVods({ vods }: MatchVodsProps) {
<span
className={clsx("text-theme-secondary", parentStyles.vodTeamName)}
>
{user ? team?.name : null}
{user ? vod.teamName : null}
</span>
<span className="text-lighter stack horizontal xs items-center">
<Eye size={12} />

View File

@@ -13,6 +13,7 @@ import { useUser } from "../../../auth/core/user";
import type { Bracket, Standing } from "../../core/Bracket";
import * as Swiss from "../../core/engine/swiss/team-status";
import * as Progression from "../../core/Progression";
import type { BracketMeta } from "../../core/Tournament";
import styles from "./bracket.module.css";
export function PlacementsTable({
@@ -39,7 +40,7 @@ export function PlacementsTable({
wins: stats.setWins,
roundCount: bracket.swissRoundCount,
}) === "advanced"
? bracket.tournament.brackets.find((otherBracket) =>
? bracket.tournament.bracketsMeta.find((otherBracket) =>
otherBracket.sources?.some(
(source) => source.bracketIdx === bracket.idx,
),
@@ -47,7 +48,7 @@ export function PlacementsTable({
: undefined;
}
return bracket.tournament.brackets.find(
return bracket.tournament.bracketsMeta.find(
(b) =>
b.idx ===
Progression.destinationByPlacement({
@@ -61,7 +62,7 @@ export function PlacementsTable({
const possibleDestinationBrackets = Progression.destinationsFromBracketIdx(
bracket.idx,
bracket.tournament.ctx.settings.bracketProgression,
).map((idx) => bracket.tournament.bracketByIdx(idx)!);
).map((idx) => bracket.tournament.bracketsMeta[idx]);
const canEditDestination = (() => {
if (possibleDestinationBrackets.length === 0) return false;
@@ -139,8 +140,8 @@ function StandingsTable({
destinationBracket: (
standing: Standing,
placement: number,
) => Bracket | undefined;
possibleDestinationBrackets: Bracket[];
) => BracketMeta | undefined;
possibleDestinationBrackets: BracketMeta[];
canEditDestination: boolean;
allMatchesFinished: boolean;
}) {
@@ -211,7 +212,7 @@ function StandingsTable({
override.tournamentTeamId === s.team.id,
);
const overridenDestinationBracket = overridenDestination
? bracket.tournament.bracketByIdx(
? bracket.tournament.bracketMetaByIdx(
overridenDestination.destinationBracketIdx,
)
: undefined;
@@ -359,9 +360,9 @@ function EditableDestination({
droppedOut,
}: {
source: Bracket;
destination?: Bracket;
overridenDestination?: Bracket | null;
possibleDestinations: Bracket[];
destination?: BracketMeta;
overridenDestination?: BracketMeta | null;
possibleDestinations: BracketMeta[];
allMatchesFinished: boolean;
canEditDestination: boolean;
tournamentTeamId: number;

View File

@@ -13,6 +13,7 @@ import styles from "./bracket.module.css";
export function RoundHeader({
roundId,
bracketIdx,
name,
bestOf,
showInfos,
@@ -21,6 +22,7 @@ export function RoundHeader({
matches = [],
}: {
roundId: number;
bracketIdx: number;
name: string;
bestOf?: number;
showInfos?: boolean;
@@ -28,7 +30,7 @@ export function RoundHeader({
roundStartedAt?: number | null;
matches?: Array<Unpacked<TournamentData["data"]["match"]>>;
}) {
const leagueRoundStartDate = useLeagueWeekStart(roundId);
const leagueRoundStartDate = useLeagueWeekStart(bracketIdx, roundId);
const countPrefix = maps?.type === "PLAY_ALL" ? "Play all " : "Bo";
@@ -139,13 +141,14 @@ function RoundTimer({
return <div style={{ color: statusColor }}>{displayText}</div>;
}
function useLeagueWeekStart(roundId: number) {
function useLeagueWeekStart(bracketIdx: number, roundId: number) {
const tournament = useTournament();
const bracketIdx = tournament.brackets.findIndex((b) =>
b.data.round.some((r) => r.id === roundId),
);
if (bracketIdx !== 0) return null;
if (bracketIdx !== 0 || !tournament.isLeagueDivision) return null;
return resolveLeagueRoundStartDate(tournament, roundId);
return resolveLeagueRoundStartDate(
tournament,
tournament.bracketByIdx(bracketIdx) ?? undefined,
roundId,
);
}

View File

@@ -49,6 +49,7 @@ export function RoundRobinBracket({ bracket }: { bracket: BracketType }) {
<div key={round.id} className={styles.elimRoundColumn}>
<RoundHeader
roundId={round.id}
bracketIdx={bracket.idx}
name={`Round ${round.number}`}
bestOf={bestOf}
showInfos={someMatchOngoing}

View File

@@ -172,6 +172,7 @@ export function SwissBracket({
<div className="stack sm horizontal">
<RoundHeader
roundId={round.id}
bracketIdx={bracket.idx}
name={`Round ${round.number}`}
bestOf={bestOf}
showInfos={someMatchOngoing(matches)}

View File

@@ -10,6 +10,7 @@ import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { soundEnabled, soundVolume } from "~/features/chat/chat-utils";
import { useTournament } from "~/features/tournament/routes/to.$id";
import type { TournamentTeamMemberProgressStatus } from "~/features/tournament-bracket/core/Tournament";
import { logger } from "~/utils/logger";
import {
soundPath,
@@ -18,13 +19,15 @@ import {
} from "~/utils/urls";
import styles from "../tournament-bracket.module.css";
export function TournamentTeamActions() {
export function TournamentTeamActions({
status,
}: {
status: TournamentTeamMemberProgressStatus | null;
}) {
const tournament = useTournament();
const user = useUser();
const fetcher = useFetcher();
const status = tournament.teamMemberOfProgressStatus(user);
useMatchReadySound(status?.type);
if (!status) return null;
@@ -47,7 +50,10 @@ export function TournamentTeamActions() {
);
}
if (status.type === "CHECKIN") {
const bracket = tournament.brackets[status.bracketIdx ?? -1];
const bracket =
typeof status.bracketIdx === "number"
? tournament.bracketMetaByIdx(status.bracketIdx)
: null;
if (!bracket) {
return (
@@ -89,7 +95,7 @@ export function TournamentTeamActions() {
return (
<Container spaced="very">
{bracket.name} check-in
{bracket.canCheckIn(user) ? (
{tournament.canCheckInToBracket(bracket.idx, user) ? (
<fetcher.Form method="post">
<input type="hidden" name="bracketIdx" value={status.bracketIdx} />
<SubmitButton

View File

@@ -16,7 +16,6 @@ describe("swiss standings - losses against tied", () => {
it("should calculate losses against tied", () => {
const tournament = new Tournament({
...LOW_INK_DECEMBER_2024(),
simulateBrackets: false,
});
const standing = tournament
@@ -31,7 +30,6 @@ describe("swiss standings - losses against tied", () => {
it("breaks ties on losses against tied, not wins against tied", () => {
const tournament = new Tournament({
...LOW_INK_DECEMBER_2024(),
simulateBrackets: false,
});
const standings = tournament.bracketByIdx(0)!.standings;
@@ -59,7 +57,6 @@ describe("swiss standings - losses against tied", () => {
it("ranks fewer losses against tied above a higher opponent set win %", () => {
const tournament = new Tournament({
...LOW_INK_DECEMBER_2024(),
simulateBrackets: false,
});
const standings = tournament.bracketByIdx(0)!.standings;
@@ -84,7 +81,6 @@ describe("swiss standings - losses against tied", () => {
it("should ignore early dropped out teams for standings (losses against tied)", () => {
const tournament = new Tournament({
...LOW_INK_DECEMBER_2024(),
simulateBrackets: false,
});
const standing = tournament

View File

@@ -1,4 +1,3 @@
import { sub } from "date-fns";
import * as R from "remeda";
import type { Tables } from "~/db/tables";
import type { TournamentStageSettings } from "~/db/tables-json";
@@ -22,7 +21,7 @@ export interface CreateBracketArgs {
preview: boolean;
data?: BracketData;
type: Tables["TournamentStage"]["type"];
canBeStarted?: boolean;
participantsReady?: boolean;
name: string;
teamsPendingCheckIn?: number[];
tournament: Tournament;
@@ -64,8 +63,7 @@ export abstract class Bracket {
idx;
preview;
data;
simulatedData: BracketData | undefined;
canBeStarted;
participantsReady;
name;
teamsPendingCheckIn;
tournament;
@@ -76,13 +74,16 @@ export abstract class Bracket {
requiresCheckIn;
startTime;
private _matchStatuses: Map<number, Engine.MatchStatus> | undefined;
private _simulatedData: { value: BracketData | undefined } | undefined;
private _standings: Standing[] | undefined;
private _liveStandings: Standing[] | undefined;
constructor({
id,
idx,
preview,
data,
canBeStarted,
participantsReady,
name,
teamsPendingCheckIn,
tournament,
@@ -104,17 +105,38 @@ export abstract class Bracket {
this.tournament = tournament;
this.settings = settings;
this.data = data ?? this.generateMatchesData(this.seeding!);
this.canBeStarted = canBeStarted;
this.participantsReady = participantsReady;
this.name = name;
this.teamsPendingCheckIn = teamsPendingCheckIn;
this.sources = sources;
this.createdAt = createdAt;
this.requiresCheckIn = requiresCheckIn;
this.startTime = startTime;
}
if (this.tournament.simulateBrackets) {
this.createdSimulation();
/**
* Can the organizer start this bracket at this moment? Evaluated on access rather than
* stored because it depends on the current time, and a bracket can be built (and cached)
* long before the clock reaches its start time.
*/
get canBeStarted() {
if (!this.participantsReady) return false;
if (this.startTime && this.startTime > new Date()) return false;
if (this.sources) return true;
return this.tournament.regularCheckInHasEnded;
}
/**
* Bracket data with the results of the unplayed matches filled in, showing how teams are
* expected to advance. Simulating is expensive so it happens on first access only.
*/
get simulatedData(): BracketData | undefined {
if (!this._simulatedData) {
this._simulatedData = { value: this.createdSimulation() };
}
return this._simulatedData.value;
}
private createdSimulation() {
@@ -180,9 +202,11 @@ export abstract class Bracket {
}
}
this.simulatedData = data;
return data;
} catch (e) {
logger.error("Bracket.createdSimulation: ", e);
return;
}
}
@@ -238,7 +262,15 @@ export abstract class Bracket {
* Standings that are settled i.e. teams still playing are left out. Safe to
* use for deciding who advances to another bracket.
*/
abstract get standings(): Standing[];
get standings(): Standing[] {
if (!this._standings) {
this._standings = this.calculateStandings();
}
return this._standings;
}
protected abstract calculateStandings(): Standing[];
/**
* How many rounds a swiss bracket has. Comes from the bracket's own stage
@@ -262,6 +294,14 @@ export abstract class Bracket {
* bracket's current state, not for deciding who advances.
*/
get liveStandings(): Standing[] {
if (!this._liveStandings) {
this._liveStandings = this.calculateLiveStandings();
}
return this._liveStandings;
}
protected calculateLiveStandings(): Standing[] {
return this.standings;
}
@@ -312,13 +352,17 @@ export abstract class Bracket {
}
protected standingsWithoutNonParticipants(standings: Standing[]): Standing[] {
const participatedUserIds = this.tournament.participatedUserIds;
// views that did not load participated user ids show full rosters
if (!participatedUserIds) return standings;
return standings.map((standing) => {
return {
...standing,
team: {
...standing.team,
members: standing.team.members.filter((member) =>
this.tournament.ctx.participatedUsers.includes(member.userId),
memberUserIds: standing.team.memberUserIds.filter((userId) =>
participatedUserIds.includes(userId),
),
},
};
@@ -435,21 +479,7 @@ export abstract class Bracket {
}
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);
return this.tournament.canCheckInToBracket(this.idx, user);
}
abstract source(options: {

View File

@@ -71,7 +71,7 @@ export class DoubleEliminationBracket extends Bracket {
);
}
get standings(): Standing[] {
protected calculateStandings(): Standing[] {
if (!this.enoughTeams) return [];
const losersGroupId = this.data.group.find((g) => g.number === 2)?.id;

View File

@@ -86,11 +86,11 @@ export class RoundRobinBracket extends Bracket {
return teams;
}
get standings(): Standing[] {
protected calculateStandings(): Standing[] {
return this.computeStandings({ includeUnfinishedGroups: false });
}
get liveStandings(): Standing[] {
protected calculateLiveStandings(): Standing[] {
return this.computeStandings({ includeUnfinishedGroups: true });
}

View File

@@ -64,7 +64,7 @@ export class SingleEliminationBracket extends Bracket {
return R.unique(this.data.match.map((m) => m.groupId)).length > 1;
}
get standings(): Standing[] {
protected calculateStandings(): Standing[] {
const teams: { id: number; lostAt: number }[] = [];
const matches = (() => {

View File

@@ -101,11 +101,11 @@ export class SwissBracket extends Bracket {
});
}
get standings(): Standing[] {
protected calculateStandings(): Standing[] {
return this.computeStandings({ includeUnfinishedGroups: false });
}
get liveStandings(): Standing[] {
protected calculateLiveStandings(): Standing[] {
return this.computeStandings({ includeUnfinishedGroups: true });
}

View File

@@ -1,5 +1,5 @@
import { assertUnreachable } from "~/utils/types";
import type { CreateBracketArgs } from "./Bracket";
import type { Bracket, CreateBracketArgs } from "./Bracket";
import { DoubleEliminationBracket } from "./DoubleEliminationBracket";
import { RoundRobinBracket } from "./RoundRobinBracket";
import { SingleEliminationBracket } from "./SingleEliminationBracket";
@@ -8,9 +8,7 @@ import { SwissBracket } from "./SwissBracket";
export type { CreateBracketArgs, Standing } from "./Bracket";
export { Bracket } from "./Bracket";
export function createBracket(
args: CreateBracketArgs,
): SingleEliminationBracket | DoubleEliminationBracket | RoundRobinBracket {
export function createBracket(args: CreateBracketArgs): Bracket {
switch (args.type) {
case "single_elimination": {
return new SingleEliminationBracket(args);

View File

@@ -15,7 +15,6 @@ import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import { seededRandom } from "~/utils/random";
import { assertUnreachable } from "~/utils/types";
import type { TournamentDataTeam } from "./Tournament.server";
export const types = [
"COUNTERPICK",
@@ -541,11 +540,17 @@ export interface PickBanEvent {
mode: ModeShort | null;
}
/** A tournament team as far as map pool based pick/ban legality is concerned. */
export type MapPoolTeam = {
id: number;
mapPool: Array<{ mode: ModeShort; stageId: StageId }> | null;
};
interface MapListWithStatusesArgs {
results: Array<{ mode: ModeShort; stageId: StageId; winnerTeamId: number }>;
maps: TournamentRoundMaps | null;
mapList: TournamentMapListMap[] | null;
teams: [TournamentDataTeam, TournamentDataTeam];
teams: [MapPoolTeam, MapPoolTeam];
pickerTeamId: number;
tieBreakerMapPool: ModeWithStage[];
toSetMapPool: Array<{ mode: ModeShort; stageId: StageId }>;

View File

@@ -2,33 +2,13 @@ import { beforeEach, describe, expect, it } from "vitest";
import { RunningTournaments } from "./RunningTournaments.server";
import { testTournament, tournamentCtxTeam } from "./tests/test-utils";
const createMember = (userId: number) =>
({
userId,
username: `User ${userId}`,
discordId: String(userId),
discordAvatar: null,
customUrl: null,
country: null,
twitch: null,
plusTier: null,
createdAt: 0,
inGameName: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
}) as const;
const createTestTournament = (
tournamentId: number,
teamMembers: { teamId: number; userIds: number[] }[],
) => {
const teams = teamMembers.map(({ teamId, userIds }) =>
tournamentCtxTeam(teamId, {
members: userIds.map(createMember),
memberUserIds: userIds,
}),
);

View File

@@ -112,7 +112,6 @@ describe("Swiss", () => {
describe("Zones Weekly 38", () => {
const tournament = new Tournament({
...ZONES_WEEKLY_38(),
simulateBrackets: false,
});
const bracket = tournament.bracketByIdx(0)!;

View File

@@ -1,16 +1,31 @@
import { sub } from "date-fns";
import { redirect } from "react-router";
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 { BracketData } from "~/features/tournament-bracket/core/engine/types";
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
import { isAdmin } from "~/modules/permissions/utils";
import { databaseTimestampToDate } from "~/utils/dates";
import { LRUCache } from "~/modules/cache";
import { IN_MILLISECONDS } from "~/utils/cache.server";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import { notFoundIfNullish } from "~/utils/remix.server";
import type { Unwrapped } from "~/utils/types";
import { tournamentPage } from "~/utils/urls";
import type { Bracket } from "./Bracket";
import { RunningTournaments } from "./RunningTournaments.server";
import { Tournament } from "./Tournament";
import {
type BracketDerivedMeta,
isTournamentOrganizer,
type OptionalIdObject,
type SerializedBracket,
Tournament,
type TournamentOrganizerCtx,
type TournamentStream,
} from "./Tournament";
const combinedTournamentData = async (tournamentId: number) => {
const ctx = await TournamentRepository.findById(tournamentId);
@@ -19,11 +34,122 @@ const combinedTournamentData = async (tournamentId: number) => {
return {
data: await BracketRepository.findByTournamentId(tournamentId),
ctx,
participatedUsers:
await TournamentRepository.findParticipatedUserIdsById(tournamentId),
streams: await fetchTournamentStreams(tournamentId),
};
};
/**
* Live streams of the tournament read fresh from the database, bypassing the tournament
* data cache. The streams view and bracket views ship these; the cached copy (read once
* per cache fill) only serves the server-side consumers of running tournaments.
*/
export async function fetchTournamentStreams(
tournamentId: number,
): Promise<TournamentStream[]> {
const { participantStreams, castStreams } =
await TournamentRepository.findStreamsByTournamentId(tournamentId);
const memberStreams = participantStreams.map((stream) => ({
thumbnailUrl: stream.thumbnailUrl,
twitchUserName: stream.twitch,
viewerCount: stream.viewerCount,
userId: stream.userId as number | null,
teamName: stream.teamName as string | null,
user: {
id: stream.id,
username: stream.username,
discordId: stream.discordId,
discordAvatar: stream.discordAvatar,
customUrl: stream.customUrl,
customAvatarUrl: stream.customAvatarUrl,
},
}));
const casts = castStreams.map((stream) => ({
thumbnailUrl: stream.thumbnailUrl,
twitchUserName: stream.twitch!,
viewerCount: stream.viewerCount,
userId: null,
teamName: null,
user: null,
}));
return [...memberStreams, ...casts].sort(
(a, b) => b.viewerCount - a.viewerCount,
);
}
export type TournamentData = NonNullable<Unwrapped<typeof tournamentData>>;
export type TournamentDataTeam = TournamentData["ctx"]["teams"][number];
/**
* What the tournament layout ships: everything every view needs and nothing a single view
* needs. Match data is loaded per bracket by the views that render brackets.
*/
export type TournamentLayoutData = {
ctx: TournamentData["ctx"];
bracketsMeta: BracketDerivedMeta[];
};
/**
* A tournament team as the tournament layout ships it: no per member profile data,
* map pool or invite code. See {@link tournamentTeamsFullCached} for those.
*/
export type TournamentDataTeam = Omit<
TournamentRepository.FindById["teams"][number],
"teamLogoUrl" | "pickupAvatarUrl" | "inviteCode"
> & {
/** Logo of the linked team, falling back to the pickup avatar when it may be revealed. */
logoUrl: string | null;
/** Only set for the viewer's own team. */
inviteCode: string | null;
};
/** The parts of a tournament that decide whether it may be seen at all. */
type TournamentVisibilityCtx = TournamentOrganizerCtx &
Pick<TournamentData["ctx"], "settings">;
/**
* Ensures the tournament may be seen by the given user. Draft tournaments are only visible
* to their organizers.
*
* Every loader under the tournament layout route must call this. They are each reachable on
* their own via single fetch, without the layout loader (and its check) ever running.
*
* @throws {Response} 404 if the tournament is a draft the user is not an organizer of
*/
export function requireTournamentVisible({
ctx,
user,
}: {
ctx: TournamentVisibilityCtx;
user: OptionalIdObject;
}) {
if (!ctx.settings.isDraft) return;
if (isTournamentOrganizer({ ctx, user })) return;
throw new Response(null, { status: 404 });
}
/**
* Ensures the given user organizes the tournament, sending non-organizers back to the
* tournament's front page. Admin view loaders call this after {@link tournamentSharedCached}.
*
* @throws {Response} redirect to the tournament page for non-organizers
*/
export function requireTournamentOrganizer({
tournament,
user,
}: {
tournament: Tournament;
user: OptionalIdObject;
}) {
if (tournament.isOrganizer(user)) return;
throw redirect(tournamentPage(tournament.ctx.id));
}
export async function tournamentData({
user,
tournamentId,
@@ -40,20 +166,21 @@ export async function tournamentData({
function dataMapped({
data,
ctx,
participatedUsers,
streams,
user,
}: {
data: BracketData;
ctx: TournamentRepository.FindById;
participatedUsers: number[];
streams: TournamentStream[];
user?: { id: number };
}) {
const tournamentHasStarted = data.stage.length > 0;
const isOrganizer =
ctx.author.id === user?.id ||
ctx.staff.some(
(staff) => staff.id === user?.id && staff.role === "ORGANIZER",
) ||
isAdmin(user);
const revealInfo = tournamentHasStarted || isOrganizer;
const revealInfo = shouldRevealInfo({
tournamentHasStarted: data.stage.length > 0,
ctx,
user,
});
const tentativeTier =
!ctx.tier && ctx.organization?.id
@@ -62,33 +189,48 @@ function dataMapped({
return {
data,
participatedUsers,
streams,
ctx: {
...ctx,
tentativeTier,
teams: ctx.teams.map((team) => {
const isOwnTeam = team.members.some(
(member) => member.userId === user?.id,
);
teams: ctx.teams.map(
({ teamLogoUrl, pickupAvatarUrl, ...team }): TournamentDataTeam => {
const isOwnTeam =
typeof user?.id === "number" &&
team.memberUserIds.includes(user.id);
return {
...team,
mapPool: revealInfo || isOwnTeam ? team.mapPool : null,
pickupAvatarUrl:
revealInfo || isOwnTeam ? team.pickupAvatarUrl : null,
inviteCode: isOwnTeam ? team.inviteCode : null,
};
}),
return {
...team,
inviteCode: isOwnTeam ? team.inviteCode : null,
logoUrl:
teamLogoUrl ?? (revealInfo || isOwnTeam ? pickupAvatarUrl : null),
};
},
),
},
};
}
function shouldRevealInfo({
tournamentHasStarted,
ctx,
user,
}: {
tournamentHasStarted: boolean;
ctx: TournamentOrganizerCtx;
user?: { id: number };
}) {
return tournamentHasStarted || isTournamentOrganizer({ ctx, user });
}
export async function tournamentFromDB(args: {
user: { id: number } | undefined;
tournamentId: number;
}) {
const data = notFoundIfNullish(await tournamentData(args));
const tournament = new Tournament({ ...data, simulateBrackets: false });
const tournament = new Tournament(data);
syncTournamentToRegistry(tournament);
return tournament;
@@ -100,15 +242,28 @@ export async function tournamentFromDBCached(args: {
}) {
const data = notFoundIfNullish(await tournamentDataCached(args));
return new Tournament({ ...data, simulateBrackets: false });
return new Tournament(data);
}
// caching promise ensures that if many requests are made for the same tournament
// at the same time they reuse the same resolving promise
const tournamentDataCache = new Map<
number,
ReturnType<typeof combinedTournamentData>
>();
const TOURNAMENT_DATA_CACHE_MAX_ENTRIES = 250;
const TOURNAMENT_DATA_CACHE_TTL_MS = IN_MILLISECONDS.HALF_HOUR;
type TournamentDataCacheEntry = {
storedAt: number;
// caching promise ensures that if many requests are made for the same tournament
// at the same time they reuse the same resolving promise
combined: ReturnType<typeof combinedTournamentData>;
// the vast majority of viewers are logged out and get the exact same censoring applied
anonymousMapped?: ReturnType<typeof dataMapped>;
// brackets are expensive to build (preview brackets are generated from scratch) and what
// they derive from is the same for every viewer, so one instance serves them all
anonymousTournament?: Tournament;
};
const tournamentDataCache = new LRUCache<number, TournamentDataCacheEntry>({
max: TOURNAMENT_DATA_CACHE_MAX_ENTRIES,
});
export async function tournamentDataCached({
user,
tournamentId,
@@ -120,21 +275,222 @@ export async function tournamentDataCached({
return notFoundIfNullish(await tournamentData({ user, tournamentId }));
}
if (!tournamentDataCache.has(tournamentId)) {
tournamentDataCache.set(tournamentId, combinedTournamentData(tournamentId));
const entry = tournamentDataCacheEntry(tournamentId);
const data = notFoundIfNullish(await entry.combined);
if (user) return dataMapped({ user, ...data });
if (!entry.anonymousMapped) {
entry.anonymousMapped = dataMapped({ user: undefined, ...data });
}
const data = notFoundIfNullish(await tournamentDataCache.get(tournamentId));
return entry.anonymousMapped;
}
return dataMapped({ user, ...data });
/**
* A `Tournament` shared by every request for the lifetime of the cache entry. The bracket
* level derivations (bracket state, standings, one bracket's data) are the same for every
* viewer, so building the brackets happens once per cache fill instead of once per request.
*/
export async function tournamentSharedCached(tournamentId: number) {
if (ServerConfig.disableCache) {
return new Tournament(
notFoundIfNullish(await tournamentData({ tournamentId })),
);
}
const entry = tournamentDataCacheEntry(tournamentId);
const data = notFoundIfNullish(await entry.combined);
if (!entry.anonymousMapped) {
entry.anonymousMapped = dataMapped({ user: undefined, ...data });
}
if (!entry.anonymousTournament) {
entry.anonymousTournament = new Tournament(entry.anonymousMapped);
}
return entry.anonymousTournament;
}
/** State of every bracket of the tournament, without any of the match data it derives from. */
export async function bracketsMetaCached(
tournamentId: number,
): Promise<BracketDerivedMeta[]> {
return (await tournamentSharedCached(tournamentId)).bracketsDerivedMeta;
}
/** One bracket with its match data, in the shape {@link Tournament.withBrackets} revives. */
export function serializeBracket(bracket: Bracket): SerializedBracket {
return {
id: bracket.id,
idx: bracket.idx,
preview: bracket.preview,
data: bracket.data,
type: bracket.type,
participantsReady: bracket.participantsReady,
name: bracket.name,
teamsPendingCheckIn: bracket.teamsPendingCheckIn,
createdAt: bracket.createdAt ?? null,
sources: bracket.sources,
seeding: bracket.seeding,
settings: bracket.settings,
requiresCheckIn: bracket.requiresCheckIn,
startTime: bracket.startTime
? dateToDatabaseTimestamp(bracket.startTime)
: null,
};
}
function tournamentDataCacheEntry(tournamentId: number) {
const cached = tournamentDataCache.get(tournamentId);
if (cached && Date.now() - cached.storedAt < TOURNAMENT_DATA_CACHE_TTL_MS) {
return cached;
}
const entry: TournamentDataCacheEntry = {
storedAt: Date.now(),
combined: combinedTournamentData(tournamentId),
};
entry.combined.catch(() => {
if (tournamentDataCache.get(tournamentId) === entry) {
tournamentDataCache.delete(tournamentId);
}
});
tournamentDataCache.set(tournamentId, entry);
return entry;
}
/** A tournament team with its full roster, as the views that render rosters get it. */
export type TournamentTeamFull = Unwrapped<typeof tournamentTeamsFullCached>;
type TournamentTeamsCacheEntry = {
storedAt: number;
teams: ReturnType<typeof TournamentRepository.findTeamsFullByTournamentId>;
anonymousCensored?: ReturnType<typeof censoredTeams>;
};
const tournamentTeamsCache = new LRUCache<number, TournamentTeamsCacheEntry>({
max: TOURNAMENT_DATA_CACHE_MAX_ENTRIES,
});
/**
* Full rosters of a tournament's teams, censored for the given viewer. Its own cache
* slice so that the (much smaller) tournament layout data does not have to carry them.
*/
export async function tournamentTeamsFullCached({
user,
tournamentId,
}: {
user?: { id: number };
tournamentId: number;
}) {
const ctx = notFoundIfNullish(await tournamentDataCached({ tournamentId }));
const revealInfo = shouldRevealInfo({
tournamentHasStarted: ctx.data.stage.length > 0,
ctx: ctx.ctx,
user,
});
if (ServerConfig.disableCache) {
return censoredTeams({
teams:
await TournamentRepository.findTeamsFullByTournamentId(tournamentId),
revealInfo,
user,
});
}
const entry = tournamentTeamsCacheEntry(tournamentId);
const teams = await entry.teams;
if (user) return censoredTeams({ teams, revealInfo, user });
if (!entry.anonymousCensored) {
entry.anonymousCensored = censoredTeams({ teams, revealInfo });
}
return entry.anonymousCensored;
}
/**
* {@link tournamentTeamsFullCached} in the tournament's own seed order, which is not
* the order the team rows come back in.
*/
export async function tournamentTeamsFullInSeedOrder({
tournament,
user,
}: {
tournament: Tournament;
user?: { id: number };
}) {
const rosterByTeamId = new Map(
(
await tournamentTeamsFullCached({ tournamentId: tournament.ctx.id, user })
).map((team) => [team.id, team]),
);
return tournament.ctx.teams.flatMap((team) => {
const withRoster = rosterByTeamId.get(team.id);
return withRoster ? [withRoster] : [];
});
}
function tournamentTeamsCacheEntry(tournamentId: number) {
const cached = tournamentTeamsCache.get(tournamentId);
if (cached && Date.now() - cached.storedAt < TOURNAMENT_DATA_CACHE_TTL_MS) {
return cached;
}
const entry: TournamentTeamsCacheEntry = {
storedAt: Date.now(),
teams: TournamentRepository.findTeamsFullByTournamentId(tournamentId),
};
entry.teams.catch(() => {
if (tournamentTeamsCache.get(tournamentId) === entry) {
tournamentTeamsCache.delete(tournamentId);
}
});
tournamentTeamsCache.set(tournamentId, entry);
return entry;
}
function censoredTeams({
teams,
revealInfo,
user,
}: {
teams: TournamentRepository.TeamFull[];
revealInfo: boolean;
user?: { id: number };
}) {
return teams.map((team) => {
const isOwnTeam = team.members.some((member) => member.userId === user?.id);
const pickupAvatarUrl =
revealInfo || isOwnTeam ? team.pickupAvatarUrl : null;
return {
...team,
mapPool: revealInfo || isOwnTeam ? team.mapPool : null,
pickupAvatarUrl,
logoUrl: team.team?.logoUrl ?? pickupAvatarUrl,
inviteCode: isOwnTeam ? team.inviteCode : null,
};
});
}
export function clearTournamentDataCache(tournamentId: number) {
tournamentDataCache.delete(tournamentId);
tournamentTeamsCache.delete(tournamentId);
}
export function clearAllTournamentDataCache() {
tournamentDataCache.clear();
tournamentTeamsCache.clear();
}
const RUNNING_TOURNAMENT_MAX_AGE_HOURS = 6;
@@ -206,7 +562,7 @@ async function primeRunningTournamentsCache() {
const data = await tournamentData({ user: undefined, tournamentId });
if (!data) continue;
const tournament = new Tournament({ ...data, simulateBrackets: false });
const tournament = new Tournament(data);
syncTournamentToRegistry(tournament);
}
}

View File

@@ -439,3 +439,61 @@ describe("Adjusting team starting bracket", () => {
expect(tournament.brackets[0].participantTournamentTeamIds).toHaveLength(4);
});
});
describe("Resolving the team a user is a member of", () => {
const USER_ID = 1;
const tournamentWithTeams = (
teams: Array<{ id: number; createdAt: number }>,
latestTeamIdByDuplicatedUserId: Record<number, number> = {},
) =>
testTournament({
ctx: {
teams: teams.map((team) =>
tournamentCtxTeam(team.id, {
createdAt: team.createdAt,
memberUserIds: [USER_ID],
}),
),
latestTeamIdByDuplicatedUserId,
},
});
it("resolves the only team the user is a member of", () => {
const tournament = tournamentWithTeams([{ id: 1, createdAt: 1 }]);
expect(tournament.teamMemberOfByUser({ id: USER_ID })?.id).toBe(1);
});
it("resolves the team the user joined most recently when on many teams", () => {
// e.g. the user's first team dropped out and the organizer added them to an
// older team afterwards
const tournament = tournamentWithTeams(
[
{ id: 1, createdAt: 1 },
{ id: 2, createdAt: 100 },
],
{ [USER_ID]: 1 },
);
expect(tournament.teamMemberOfByUser({ id: USER_ID })?.id).toBe(1);
});
it("falls back to the first team when the most recently joined one is not visible", () => {
const tournament = tournamentWithTeams(
[
{ id: 1, createdAt: 1 },
{ id: 2, createdAt: 2 },
],
{ [USER_ID]: 3 },
);
expect(tournament.teamMemberOfByUser({ id: USER_ID })?.id).toBe(1);
});
it("returns null if the user is not a member of any team", () => {
const tournament = tournamentWithTeams([{ id: 1, createdAt: 1 }]);
expect(tournament.teamMemberOfByUser({ id: USER_ID + 1 })).toBeNull();
});
});

View File

@@ -1,4 +1,6 @@
import { sub } from "date-fns";
import type { Tables } from "~/db/tables";
import type { TournamentStageSettings } from "~/db/tables-json";
import {
LEAGUES,
TOURNAMENT,
@@ -9,15 +11,11 @@ import {
tournamentInWeaponReportingWindow,
tournamentIsRanked,
} from "~/features/tournament/tournament-utils";
import type {
BracketData,
MatchData,
} from "~/features/tournament-bracket/core/engine/types";
import type * as Progression from "~/features/tournament-bracket/core/Progression";
import type { MatchData } from "~/features/tournament-bracket/core/engine/types";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import type { ModeShort } from "~/modules/in-game-lists/types";
import { isAdmin } from "~/modules/permissions/utils";
import {
databaseTimestampNow,
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
@@ -28,10 +26,91 @@ import { groupNumberToLetters } from "../tournament-bracket-utils";
import { type Bracket, createBracket } from "./Bracket";
import { getRounds } from "./rounds";
import * as Seeding from "./Seeding";
import type { TournamentData, TournamentDataTeam } from "./Tournament.server";
import type { TournamentData } from "./Tournament.server";
export type OptionalIdObject = { id: number } | undefined;
/**
* The state of one bracket that can only be derived from its match data. Shipped to the
* views that render a tournament without loading any of its match data.
*/
export type BracketDerivedMeta = {
/** Stage id of a started bracket, placeholder id of a bracket that has not been started. */
id: number;
createdAt: number | null;
preview: boolean;
everyMatchOver: boolean;
/** False only while a swiss bracket still has rounds whose matches have not been generated. */
allRoundsHaveMatches: boolean;
participantTournamentTeamIds: number[];
teamsPendingCheckIn: number[] | null;
seeding: number[] | null;
};
/** A bracket's identity and state without its match data. See {@link Tournament.bracketsMeta}. */
export type BracketMeta = BracketDerivedMeta & {
idx: number;
name: string;
type: Tables["TournamentStage"]["type"];
sources: Progression.ParsedBracket["sources"];
settings: TournamentStageSettings | null;
requiresCheckIn: boolean;
startTime: Date | null;
isUnderground: boolean;
isFinals: boolean;
isStartingBracket: boolean;
enoughTeams: boolean;
};
/** One bracket as a route loader ships it, ready to be revived by {@link Tournament.withBrackets}. */
export type SerializedBracket = {
id: number;
idx: number;
preview: boolean;
data: TournamentData["data"];
type: Tables["TournamentStage"]["type"];
participantsReady?: boolean;
name: string;
teamsPendingCheckIn?: number[];
createdAt: number | null;
sources?: { bracketIdx: number; placements: number[] }[];
seeding?: number[];
settings: TournamentStageSettings | null;
requiresCheckIn: boolean;
startTime: number | null;
};
/** One live stream of the tournament: a participant's stream or an official cast stream. */
export type TournamentStream = {
thumbnailUrl: string;
twitchUserName: string;
viewerCount: number;
userId: number | null;
teamName: string | null;
user: {
id: number;
username: string;
discordId: string;
discordAvatar: string | null;
customUrl: string | null;
customAvatarUrl: string | null;
} | null;
};
type TournamentArgs = {
/** Match data of every bracket. Absent in the views that only got {@link bracketsMeta}. */
data?: TournamentData["data"];
ctx: TournamentData["ctx"];
/** Derived bracket state, when the match data it was derived from is not shipped. */
bracketsMeta?: BracketDerivedMeta[];
/** Brackets whose match data this view loaded on its own. */
brackets?: SerializedBracket[];
/** User ids of everyone who played at least one map. */
participatedUsers?: number[];
/** Live streams of the tournament. Absent in the views whose loader does not ship them. */
streams?: TournamentStream[];
};
/** The progress status of a team member in a running tournament, as resolved by {@link Tournament.teamMemberOfProgressStatus}. */
export type TournamentTeamMemberProgressStatus = NonNullable<
ReturnType<Tournament["teamMemberOfProgressStatus"]>
@@ -39,26 +118,31 @@ export type TournamentTeamMemberProgressStatus = NonNullable<
/** Extends and providers utility functions on top of the bracket-manager library. Updating data after the bracket has started is responsibility of bracket-manager. */
export class Tournament {
brackets: Bracket[] = [];
ctx;
simulateBrackets;
/** See {@link TournamentArgs.participatedUsers}, null when this view did not get them. */
readonly participatedUserIds: number[] | null;
private args;
private data;
private _brackets: Array<Bracket | undefined> = [];
private _allBrackets: Bracket[] | undefined;
private _derivedMeta: BracketDerivedMeta[] | undefined;
private _bracketsMeta: BracketMeta[] | undefined;
private bracketIdxsBeingBuilt = new Set<number>();
constructor({
data,
ctx,
simulateBrackets = true,
}: {
data: TournamentData["data"];
ctx: TournamentData["ctx"];
/** Should the bracket results be simulated (showing how teams are expected to advance), skipping it is a performance optimization if it's not needed */
simulateBrackets?: boolean;
}) {
const hasStarted = data.stage.length > 0;
constructor(args: TournamentArgs) {
const { data, ctx, bracketsMeta, brackets } = args;
const hasStarted = data
? data.stage.length > 0
: Boolean(bracketsMeta?.some((meta) => !meta.preview));
const minMembersPerTeam = ctx.settings.minMembersPerTeam ?? 4;
const teamsInSeedOrder = sortTeamsBySeeding(ctx.teams, minMembersPerTeam);
this.simulateBrackets = simulateBrackets;
this.args = args;
this.data = data;
this.participatedUserIds = args.participatedUsers ?? null;
this._derivedMeta = bracketsMeta;
this.ctx = {
...ctx,
teams: hasStarted
@@ -68,102 +152,219 @@ export class Tournament {
startsAt: databaseTimestampToDate(ctx.startsAt),
};
this.initBrackets(data);
for (const bracket of brackets ?? []) {
this._brackets[bracket.idx] = createBracket({
...bracket,
tournament: this,
startTime: bracket.startTime
? databaseTimestampToDate(bracket.startTime)
: null,
});
}
}
private initBrackets(data: BracketData) {
for (const [
bracketIdx,
{
type,
/**
* The same tournament with the match data of the given brackets available. Used by the views
* that load one bracket's data of their own, the layout only shipping {@link bracketsMeta}.
*/
withBrackets(
brackets: SerializedBracket[],
extras?: {
participatedUsers?: number[] | null;
streams?: TournamentStream[];
},
) {
return new Tournament({
...this.args,
brackets,
participatedUsers:
extras?.participatedUsers ?? this.args.participatedUsers,
streams: extras?.streams ?? this.args.streams,
});
}
/**
* Every bracket of the tournament. Building a bracket is expensive (preview brackets
* are generated from scratch) so prefer {@link bracketByIdx} when only one is needed.
*/
get brackets(): Bracket[] {
if (!this._allBrackets) {
this._allBrackets = this.ctx.settings.bracketProgression.map(
(_, bracketIdx) => this.builtBracketByIdx(bracketIdx),
);
}
return this._allBrackets;
}
/**
* State of every bracket without its match data. Available in every view, unlike
* {@link brackets} which needs the match data the bracket views load.
*/
get bracketsMeta(): BracketMeta[] {
if (this._bracketsMeta) return this._bracketsMeta;
const progression = this.ctx.settings.bracketProgression;
const derived = this.bracketsDerivedMeta;
this._bracketsMeta = progression.map((bracket, idx) => ({
...derived[idx],
idx,
name: bracket.name,
type: bracket.type,
sources: bracket.sources,
settings: bracket.settings ?? null,
requiresCheckIn: bracket.requiresCheckIn ?? false,
startTime: bracket.startTime
? databaseTimestampToDate(bracket.startTime)
: null,
isUnderground: Progression.isUnderground(idx, progression),
isFinals: Progression.isFinals(idx, progression),
isStartingBracket: !bracket.sources || bracket.sources.length === 0,
enoughTeams:
derived[idx].participantTournamentTeamIds.length >=
TOURNAMENT.ENOUGH_TEAMS_TO_START,
}));
return this._bracketsMeta;
}
/**
* {@link bracketsMeta} of the brackets the user can switch between. Brackets that never
* started are not shown once the tournament has been finalized.
*/
get visibleBracketsMeta(): BracketMeta[] {
return this.bracketsMeta.filter(
(bracket) => !this.ctx.isFinalized || !bracket.preview,
);
}
/** {@link bracketsMeta} in the shape it is shipped in, i.e. only what match data is needed for. */
get bracketsDerivedMeta(): BracketDerivedMeta[] {
if (!this._derivedMeta) {
this._derivedMeta = this.brackets.map((bracket) => ({
id: bracket.id,
createdAt: bracket.createdAt ?? null,
preview: bracket.preview,
everyMatchOver: bracket.everyMatchOver,
allRoundsHaveMatches: bracket.data.round.every((round) =>
bracket.data.match.some((match) => match.roundId === round.id),
),
participantTournamentTeamIds: bracket.participantTournamentTeamIds,
teamsPendingCheckIn: bracket.teamsPendingCheckIn ?? null,
seeding: bracket.seeding ?? null,
}));
}
return this._derivedMeta;
}
/** State of one bracket without its match data, or null if there is no such bracket. */
bracketMetaByIdx(idx: number): BracketMeta | null {
return this.bracketsMeta[idx] ?? null;
}
private builtBracketByIdx(bracketIdx: number): Bracket {
const memoized = this._brackets[bracketIdx];
if (memoized) return memoized;
this.bracketIdxsBeingBuilt.add(bracketIdx);
try {
const bracket = this.buildBracket(bracketIdx);
this._brackets[bracketIdx] = bracket;
return bracket;
} finally {
this.bracketIdxsBeingBuilt.delete(bracketIdx);
}
}
private buildBracket(bracketIdx: number): Bracket {
invariant(
this.data,
`Bracket ${bracketIdx} has no match data loaded, use bracketsMeta or load the bracket in this view's loader`,
);
const data = this.data;
const {
type,
name,
sources,
requiresCheckIn = false,
startTime = null,
settings,
} = this.ctx.settings.bracketProgression[bracketIdx];
const inProgressStage = data.stage.find((stage) => stage.name === name);
if (inProgressStage) {
return createBracket({
id: inProgressStage.id,
idx: bracketIdx,
tournament: this,
preview: false,
name,
sources,
requiresCheckIn = false,
startTime = null,
settings,
},
] of this.ctx.settings.bracketProgression.entries()) {
const inProgressStage = data.stage.find((stage) => stage.name === name);
if (inProgressStage) {
const match = data.match.filter(
(match) => match.stageId === inProgressStage.id,
);
this.brackets.push(
createBracket({
id: inProgressStage.id,
idx: bracketIdx,
tournament: this,
preview: false,
name,
sources,
createdAt: inProgressStage.createdAt,
requiresCheckIn,
startTime: startTime ? databaseTimestampToDate(startTime) : null,
settings: settings ?? null,
data: {
...data,
group: data.group.filter(
(group) => group.stageId === inProgressStage.id,
),
match,
stage: data.stage.filter(
(stage) => stage.id === inProgressStage.id,
),
round: data.round.filter(
(round) => round.stageId === inProgressStage.id,
),
},
type,
}),
);
} else {
const { teams, relevantMatchesFinished } = sources
? this.resolveTeamsFromSources(sources, bracketIdx)
: this.resolveTeamsFromSignups(bracketIdx);
const { checkedInTeams, notCheckedInTeams } =
this.divideTeamsToCheckedInAndNotCheckedIn({
teams,
bracketIdx,
usesRegularCheckIn: !sources,
requiresCheckIn,
});
const checkedInTeamsWithReplaysAvoided = this.followUpBracketSeeding(
checkedInTeams,
{
sources,
type,
},
);
this.brackets.push(
createBracket({
id: -1 * bracketIdx,
idx: bracketIdx,
tournament: this,
seeding: checkedInTeamsWithReplaysAvoided,
preview: true,
name,
requiresCheckIn,
startTime: startTime ? databaseTimestampToDate(startTime) : null,
settings: settings ?? null,
type,
sources,
createdAt: null,
canBeStarted:
(!startTime || startTime < databaseTimestampNow()) &&
checkedInTeamsWithReplaysAvoided.length >=
TOURNAMENT.ENOUGH_TEAMS_TO_START &&
(sources ? relevantMatchesFinished : this.regularCheckInHasEnded),
teamsPendingCheckIn:
bracketIdx !== 0 ? notCheckedInTeams : undefined,
}),
);
}
createdAt: inProgressStage.createdAt,
requiresCheckIn,
startTime: startTime ? databaseTimestampToDate(startTime) : null,
settings: settings ?? null,
data: {
...data,
group: data.group.filter(
(group) => group.stageId === inProgressStage.id,
),
match: data.match.filter(
(match) => match.stageId === inProgressStage.id,
),
stage: data.stage.filter((stage) => stage.id === inProgressStage.id),
round: data.round.filter(
(round) => round.stageId === inProgressStage.id,
),
},
type,
});
}
const { teams, relevantMatchesFinished } = sources
? this.resolveTeamsFromSources(sources, bracketIdx)
: this.resolveTeamsFromSignups(bracketIdx);
const { checkedInTeams, notCheckedInTeams } =
this.divideTeamsToCheckedInAndNotCheckedIn({
teams,
bracketIdx,
usesRegularCheckIn: !sources,
requiresCheckIn,
});
const checkedInTeamsWithReplaysAvoided = this.followUpBracketSeeding(
checkedInTeams,
{
sources,
type,
},
);
return createBracket({
id: -1 * bracketIdx,
idx: bracketIdx,
tournament: this,
seeding: checkedInTeamsWithReplaysAvoided,
preview: true,
name,
requiresCheckIn,
startTime: startTime ? databaseTimestampToDate(startTime) : null,
settings: settings ?? null,
type,
sources,
createdAt: null,
participantsReady:
checkedInTeamsWithReplaysAvoided.length >=
TOURNAMENT.ENOUGH_TEAMS_TO_START &&
(!sources || relevantMatchesFinished),
teamsPendingCheckIn: bracketIdx !== 0 ? notCheckedInTeams : undefined,
});
}
private resolveTeamsFromSources(
@@ -438,11 +639,6 @@ export class Tournament {
);
}
/** Tournament teams logo image path, either from the team or the pickup avatar uploaded specifically for this tournament */
tournamentTeamLogoSrc(team: TournamentDataTeam) {
return team.team?.logoUrl ?? team.pickupAvatarUrl;
}
/** Generates a Splatoon 3 pool code to join the tournament match. It tries to make it so that teams don't need to change the pool all the time, but provides different ones not to run into the in-game limit of max people in a pool at a time. */
resolvePoolCode({
hostingTeamId,
@@ -489,14 +685,14 @@ export class Tournament {
/** Has tournament started, meaning that at least one bracket has started. Also finalized tournaments are considered started. */
get hasStarted() {
return this.brackets.some((bracket) => !bracket.preview);
return this.bracketsMeta.some((bracket) => !bracket.preview);
}
/** Is every bracket over (bracket is over when every match is over). */
get everyBracketOver() {
if (this.ctx.isFinalized) return true;
return this.brackets.every((bracket) => bracket.everyMatchOver);
return this.bracketsMeta.every((bracket) => bracket.everyMatchOver);
}
teamById(id: number) {
@@ -523,12 +719,15 @@ export class Tournament {
return { ...result, seed };
}
participatedPlayersByTeamId(id: number) {
/** User ids of the given team's members who played at least one map of the tournament. */
participatedPlayerUserIdsByTeamId(id: number) {
const team = this.teamById(id);
invariant(team, "Team not found");
const participatedUserIds = this.participatedUserIds;
invariant(participatedUserIds, "Participated user ids not loaded");
return team.members.filter((member) =>
this.ctx.participatedUsers.includes(member.userId),
return team.memberUserIds.filter((userId) =>
participatedUserIds.includes(userId),
);
}
@@ -559,7 +758,7 @@ export class Tournament {
/** Should it be possible for the given user to finalize this tournament at this time? */
canFinalize(user: OptionalIdObject) {
// can skip underground bracket
const relevantBrackets = this.brackets.filter(
const relevantBrackets = this.bracketsMeta.filter(
(b) => !b.preview || !b.isUnderground,
);
@@ -572,13 +771,7 @@ export class Tournament {
return true;
}
return this.brackets[0].data.round.every((round) => {
const hasMatches = this.brackets[0].data.match.some(
(match) => match.roundId === round.id,
);
return hasMatches;
});
return this.bracketsMeta[0].allRoundsHaveMatches;
};
return (
@@ -600,14 +793,14 @@ export class Tournament {
return { isFulfilled: false, reason: "Check in has not yet started" };
}
if (team.members.length < this.minMembersPerTeam) {
if (team.memberUserIds.length < this.minMembersPerTeam) {
return {
isFulfilled: false,
reason: `Team needs at least ${this.minMembersPerTeam} members`,
};
}
if (this.teamsPrePickMaps && (!team.mapPool || team.mapPool.length === 0)) {
if (this.teamsPrePickMaps && !team.hasMapPool) {
return { isFulfilled: false, reason: "Team has no map pool set" };
}
@@ -746,6 +939,26 @@ export class Tournament {
return count > 1;
}
/** Can the given user's team check in to the bracket at this time? */
canCheckInToBracket(bracketIdx: number, user: OptionalIdObject) {
const bracket = this.bracketMetaByIdx(bracketIdx);
// using regular check-in
if (!bracket?.teamsPendingCheckIn) return false;
if (bracket.startTime) {
const checkInOpen =
sub(bracket.startTime.getTime(), { hours: 1 }).getTime() < Date.now() &&
bracket.startTime.getTime() > Date.now();
if (!checkInOpen) return false;
}
const team = this.teamMemberOfByUser(user);
if (!team) return false;
return bracket.teamsPendingCheckIn.includes(team.id);
}
/** Returns the bracket and round names for the given match ID.
* @example
* tournament.matchNameById(123) // { bracketName: "Groups Stage", roundName: "Round 1.1", roundNameWithoutMatchIdentifier: "Round 1" }
@@ -849,37 +1062,20 @@ export class Tournament {
};
}
/** Returns a `Bracket` with the given index or the first bracket if not found. */
bracketByIdxOrDefault(idx: number): Bracket {
const bracket = this.brackets[idx];
if (bracket) return bracket;
const defaultBracket = this.brackets[0];
invariant(defaultBracket, "No brackets found");
logger.warn("Bracket not found, using fallback bracket");
return defaultBracket;
}
/** Returns a `Bracket` with the given index or null if not found. */
bracketByIdx(idx: number) {
const bracket = this.brackets[idx];
if (!bracket) return null;
if (!this.ctx.settings.bracketProgression[idx]) return null;
// a bracket that sources teams from itself (directly or via another bracket) can't be built
if (this.bracketIdxsBeingBuilt.has(idx)) return null;
return bracket;
return this.builtBracketByIdx(idx);
}
/** Returns the team that the user is the owner of, or null if not found. Includes invite code (only owner should see this, logic in the loader function). */
ownedTeamByUser(
user: OptionalIdObject,
): ((typeof this.ctx.teams)[number] & { inviteCode: string }) | null {
/** Returns the team that the user is the owner of, or null if not found. */
ownedTeamByUser(user: OptionalIdObject) {
if (!user) return null;
return this.ctx.teams.find((team) =>
team.members.some(
(member) => member.userId === user.id && member.role === "OWNER",
),
) as (typeof this.ctx.teams)[number] & { inviteCode: string };
return this.ctx.teams.find((team) => team.ownerUserId === user.id) ?? null;
}
/**
@@ -890,40 +1086,46 @@ export class Tournament {
if (!user) return null;
const teams = this.ctx.teams.filter((team) =>
team.members.some((member) => member.userId === user.id),
team.memberUserIds.includes(user.id),
);
if (teams.length <= 1) return teams[0] ?? null;
let result: (typeof teams)[number] | null = null;
let latestCreatedAt = 0;
for (const team of teams) {
const member = team.members.find((member) => member.userId === user.id)!;
if (member.createdAt > latestCreatedAt) {
result = team;
latestCreatedAt = member.createdAt;
}
}
return result;
const latestTeamId = this.ctx.latestTeamIdByDuplicatedUserId[user.id];
return teams.find((team) => team.id === latestTeamId) ?? teams[0];
}
/**
* Returns the progress status of the user in the tournament, or null if not participating.
* e.g. might return "WAITING_FOR_MATCH" if the user is waiting for their next match or "WAITING_FOR_CAST" if the match is ready to be played but locked waiting for the cast.
*/
/**
* The started brackets, built without generating the previews of the others.
* Generating a preview bracket is expensive, so prefer this over filtering
* {@link brackets} when only the started ones are needed.
*/
private get startedBrackets(): Bracket[] {
const data = this.data;
if (!data) return this.brackets.filter((bracket) => !bracket.preview);
return this.ctx.settings.bracketProgression.flatMap(
(progressionBracket, idx) =>
data.stage.some((stage) => stage.name === progressionBracket.name)
? [this.builtBracketByIdx(idx)]
: [],
);
}
teamMemberOfProgressStatus(user: OptionalIdObject) {
const team = this.teamMemberOfByUser(user);
if (!team) return null;
if (
this.brackets.every((bracket) => bracket.preview) &&
!this.regularCheckInIsOpen
) {
const startedBrackets = this.startedBrackets;
if (startedBrackets.length === 0 && !this.regularCheckInIsOpen) {
return null;
}
for (const bracket of this.brackets) {
if (bracket.preview) continue;
for (const bracket of startedBrackets) {
for (const match of bracket.data.match) {
const isParticipant =
match.opponent1?.id === team.id || match.opponent2?.id === team.id;
@@ -989,8 +1191,8 @@ export class Tournament {
}
}
for (const bracket of this.brackets) {
if (bracket.preview || bracket.type !== "swiss") continue;
for (const bracket of startedBrackets) {
if (bracket.type !== "swiss") continue;
// TODO: both seeding and participantTournamentTeamIds are used for the same thing
const isParticipant = bracket.participantTournamentTeamIds.includes(
@@ -1022,22 +1224,19 @@ export class Tournament {
if (team.checkIns.length === 0) return null;
if (!team.droppedOut) {
for (const bracket of this.brackets) {
if (
bracket.type !== "round_robin" ||
bracket.preview ||
bracket.everyMatchOver
) {
for (const bracket of startedBrackets) {
if (bracket.type !== "round_robin" || bracket.everyMatchOver) {
continue;
}
const isParticipant = bracket.participantTournamentTeamIds.includes(
team.id,
);
const hasFollowUpBrackets = this.brackets.some((otherBracket) =>
otherBracket.sources?.some(
(source) => source.bracketIdx === bracket.idx,
),
const hasFollowUpBrackets = this.ctx.settings.bracketProgression.some(
(progressionBracket) =>
progressionBracket.sources?.some(
(source) => source.bracketIdx === bracket.idx,
),
);
if (isParticipant && hasFollowUpBrackets) {
@@ -1215,24 +1414,7 @@ export class Tournament {
/** Checks if the given user is an organizer of the tournament. */
isOrganizer(user: OptionalIdObject) {
if (!user) return false;
if (isAdmin(user)) return true;
if (this.ctx.author.id === user.id) return true;
if (
this.ctx.organization?.members.some(
(member) =>
member.userId === user.id &&
["ADMIN", "ORGANIZER"].includes(member.role),
)
) {
return true;
}
return this.ctx.staff.some(
(staff) => staff.id === user.id && staff.role === "ORGANIZER",
);
return isTournamentOrganizer({ ctx: this.ctx, user });
}
/** Checks if the given user is an organizer or streamer of the tournament. */
@@ -1258,32 +1440,62 @@ export class Tournament {
);
}
get streams() {
const memberStreams = this.ctx.teams
.filter((team) => team.checkIns.length > 0)
.flatMap((team) => team.members)
.filter((member) => member.streamTwitch)
.map((member) => ({
thumbnailUrl: member.streamThumbnailUrl!,
twitchUserName: member.streamTwitch!,
viewerCount: member.streamViewerCount!,
userId: member.userId,
}));
/** Live streams of the tournament, empty in the views whose loader did not ship them. */
get streams(): TournamentStream[] {
return this.args.streams ?? [];
}
const castStreams = this.ctx.castStreams.map((stream) => ({
thumbnailUrl: stream.thumbnailUrl,
twitchUserName: stream.twitch!,
viewerCount: stream.viewerCount,
userId: null as number | null,
}));
/** Twitch account of every participant streaming the tournament right now, keyed by their user id. */
get streamingParticipants(): Map<number, string> {
if (!this.hasStarted || this.everyBracketOver) return new Map();
return [...memberStreams, ...castStreams].sort(
(a, b) => b.viewerCount - a.viewerCount,
return new Map(
this.streams.flatMap((stream) =>
stream.userId !== null
? [[stream.userId, stream.twitchUserName] as const]
: [],
),
);
}
get streamingParticipantIds(): number[] {
if (!this.hasStarted || this.everyBracketOver) return [];
return this.streams.filter((s) => s.userId !== null).map((s) => s.userId!);
return [...this.streamingParticipants.keys()];
}
}
/** The parts of a tournament that decide who organizes it. */
export type TournamentOrganizerCtx = Pick<
TournamentData["ctx"],
"author" | "staff" | "organization"
>;
/**
* Checks if the given user is an organizer of the tournament, off its context alone.
* {@link Tournament.isOrganizer} is the same check for when a `Tournament` is at hand.
*/
export function isTournamentOrganizer({
ctx,
user,
}: {
ctx: TournamentOrganizerCtx;
user: OptionalIdObject;
}) {
if (!user) return false;
if (isAdmin(user)) return true;
if (ctx.author.id === user.id) return true;
if (
ctx.organization?.members.some(
(member) =>
member.userId === user.id &&
["ADMIN", "ORGANIZER"].includes(member.role),
)
) {
return true;
}
return ctx.staff.some(
(staff) => staff.id === user.id && staff.role === "ORGANIZER",
);
}

View File

@@ -40,7 +40,7 @@ export interface TournamentSummary {
type TeamsArg = Array<{
id: number;
members: Array<{ userId: number }>;
memberUserIds: number[];
startingBracketIdx?: number | null;
abDivision?: number | null;
}>;
@@ -658,12 +658,12 @@ function tournamentResults({
).length;
}
for (const player of standing.team.members) {
for (const userId of standing.team.memberUserIds) {
result.push({
participantCount: divisionParticipantCount,
placement: standing.placement,
tournamentTeamId: standing.team.id,
userId: player.userId,
userId,
div,
});
}
@@ -750,5 +750,5 @@ function teamIdToMembersUserIds(teams: TeamsArg, teamId: number) {
const team = teams.find((t) => t.id === teamId);
invariant(team, `Team with id ${teamId} not found`);
return team.members.map((m) => m.userId);
return team.memberUserIds;
}

View File

@@ -29,37 +29,19 @@ describe("tournamentSummary()", () => {
checkIns: [],
createdAt: 0,
id: teamId,
inviteCode: null,
avgSeedingSkillOrdinal: null,
startingBracketIdx: null,
abDivision: null,
mapPool: [],
members: userIds.map((userId) => ({
country: null,
customUrl: null,
discordAvatar: null,
discordId: "123",
username: "test",
inGameName: "test",
twitch: null,
plusTier: null,
createdAt: 0,
userId,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
})),
hasMapPool: false,
inviteCode: null,
memberUserIds: userIds,
ownerUserId: userIds[0] ?? null,
name: `Team ${teamId}`,
prefersNotToHost: 0,
droppedOut: 0,
team: null,
logoUrl: null,
seed: 1,
activeRosterUserIds: [],
avatarImgId: null,
pickupAvatarUrl: null,
});
function summarize({
@@ -89,38 +71,10 @@ describe("tournamentSummary()", () => {
}>;
} = {}) {
const defaultTeams = [
{
id: 1,
members: [
{ userId: 1 },
{ userId: 2 },
{ userId: 3 },
{ userId: 4 },
{ userId: 20 },
],
},
{
id: 2,
members: [{ userId: 5 }, { userId: 6 }, { userId: 7 }, { userId: 8 }],
},
{
id: 3,
members: [
{ userId: 9 },
{ userId: 10 },
{ userId: 11 },
{ userId: 12 },
],
},
{
id: 4,
members: [
{ userId: 13 },
{ userId: 14 },
{ userId: 15 },
{ userId: 16 },
],
},
{ id: 1, memberUserIds: [1, 2, 3, 4, 20] },
{ id: 2, memberUserIds: [5, 6, 7, 8] },
{ id: 3, memberUserIds: [9, 10, 11, 12] },
{ id: 4, memberUserIds: [13, 14, 15, 16] },
];
const teams = defaultTeams.map((team) => {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import type { TournamentData } from "../Tournament.server";
/** Zones Weekly 38 with every round of swiss finished, last round's matches not generated */
export const ZONES_WEEKLY_38 = (): TournamentData => ({
streams: [],
data: {
stage: [
{
@@ -264,6 +265,11 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
},
],
},
participatedUsers: [
45, 1843, 2072, 2899, 3147, 5662, 6114, 10757, 11484, 11780, 13370, 13590,
17855, 21689, 26992, 30176, 31148, 33047, 33491, 33578, 33611, 37632, 37901,
43518, 43662, 45879, 46006, 46467, 46813,
],
ctx: {
id: 891,
eventId: 2698,
@@ -366,105 +372,13 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
seed: 1,
prefersNotToHost: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1734656039,
activeRosterUserIds: [5662, 2899, 6114, 30176],
startingBracketIdx: null,
abDivision: null,
avatarImgId: null,
pickupAvatarUrl: null,
members: [
{
userId: 5662,
username: "Plus",
discordId: "461787942478675978",
discordAvatar: "b6ab568d3f51973e934892ee8b9f743e",
customUrl: "plussy",
country: "AU",
twitch: "plus218",
createdAt: 1734656039,
inGameName: "Plussy#1291",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "OWNER",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 2899,
username: "CHIMERA",
discordId: "326295468931940353",
discordAvatar: "a6aed066cec53c079e3585d0c7007be9",
customUrl: "chimera_",
country: "AU",
twitch: "mikamikax_",
createdAt: 1734656044,
inGameName: "CHIMERA#1263",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 6114,
username: "Zalph",
discordId: "359945819433992193",
discordAvatar: "8f4fbd64d8949d418f023682e4161afb",
customUrl: "zalph",
country: "SO",
twitch: null,
createdAt: 1734656047,
inGameName: "CountMeOut#1985",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 33963,
username: "Tiger",
discordId: "653478041993084938",
discordAvatar: "d67221267888ac818ae4197077d236b7",
customUrl: null,
country: "ST",
twitch: "tigersplat",
createdAt: 1734664082,
inGameName: "BIDOOFGMAX#8251",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 30176,
username: "Jorjay",
discordId: "820142030654275644",
discordAvatar: "fb1e6be299a9c37f30c2ab76e55151f6",
customUrl: "2021_spl",
country: "AU",
twitch: null,
createdAt: 1734674285,
inGameName: "Bugha 33#1316",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
],
inviteCode: null,
memberUserIds: [5662, 2899, 6114, 33963, 30176],
ownerUserId: 5662,
checkIns: [
{
bracketIdx: null,
@@ -472,13 +386,8 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isCheckOut: 0,
},
],
mapPool: [],
team: {
id: 4259,
customUrl: "bamboo-pirates",
logoUrl: "qv1Zyp4EE72lPghCRheqt-1733399357944.webp",
deletedAt: 1741481029,
},
hasMapPool: false,
logoUrl: "qv1Zyp4EE72lPghCRheqt-1733399357944.webp",
avgSeedingSkillOrdinal: 19.23368373822438,
},
{
@@ -487,87 +396,13 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
seed: 2,
prefersNotToHost: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1734423187,
activeRosterUserIds: null,
startingBracketIdx: null,
abDivision: null,
avatarImgId: null,
pickupAvatarUrl: "pickup-logo-rZYQMu8ELjiFkeiAVGJUt-1734424882431.webp",
members: [
{
userId: 17855,
username: "konj",
discordId: "916459853738819624",
discordAvatar: "8d5559ef3e67b5ec4dd1fdcf79f6092c",
customUrl: "kojuke",
country: "NZ",
twitch: null,
createdAt: 1734423187,
inGameName: "☆ SD-J ☆#2947",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "OWNER",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 21689,
username: "Para",
discordId: "233896056327372802",
discordAvatar: "98f8fc7277ac6664f3db8f5bea0f2785",
customUrl: "voidedparadigm",
country: "AU",
twitch: "voidedparadigm",
createdAt: 1734424893,
inGameName: "parasyka#2169",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 3147,
username: "Cookie",
discordId: "267963609924108288",
discordAvatar: "85090cfe2e0da693355bcec9740c1eaa",
customUrl: "cookie",
country: "AU",
twitch: "cookie_spl",
createdAt: 1734426984,
inGameName: "cookie♪#1006",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 2072,
username: "suıseı",
discordId: "297233823937069068",
discordAvatar: "19dfa11a14bbf85f52f167d318c4a9df",
customUrl: "qu",
country: "BF",
twitch: null,
createdAt: 1734426986,
inGameName: null,
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
],
inviteCode: null,
memberUserIds: [17855, 21689, 3147, 2072],
ownerUserId: 17855,
checkIns: [
{
bracketIdx: null,
@@ -575,8 +410,8 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isCheckOut: 0,
},
],
mapPool: [],
team: null,
hasMapPool: false,
logoUrl: "pickup-logo-rZYQMu8ELjiFkeiAVGJUt-1734424882431.webp",
avgSeedingSkillOrdinal: 17.028587732217623,
},
{
@@ -585,87 +420,13 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
seed: 3,
prefersNotToHost: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1734660846,
activeRosterUserIds: null,
startingBracketIdx: null,
abDivision: null,
avatarImgId: null,
pickupAvatarUrl: null,
members: [
{
userId: 11484,
username: "Telethia",
discordId: "100913388141441024",
discordAvatar: "5c761e51aea48ffef7d40248f76fa2dc",
customUrl: "telethia",
country: "AU",
twitch: null,
createdAt: 1734660846,
inGameName: "Telethia#6611",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "OWNER",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 13370,
username: "Puma",
discordId: "308483655515373570",
discordAvatar: "a5fff2b4706d99364e646cab28c8085b",
customUrl: "puma",
country: "AU",
twitch: null,
createdAt: 1734660856,
inGameName: "Puma#2209",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 45,
username: "ShockWavee",
discordId: "332893262966685696",
discordAvatar: "ecc26794744c45604c4dc0c12829a178",
customUrl: "shockwavee",
country: "AU",
twitch: "shockwavee03",
createdAt: 1734660882,
inGameName: "ShockWavee#3003",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 1843,
username: "hp",
discordId: "600255055836217364",
discordAvatar: "531392eaa7f7706c67a67b0e9c3c8fe1",
customUrl: null,
country: "AU",
twitch: null,
createdAt: 1734663143,
inGameName: null,
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
],
inviteCode: null,
memberUserIds: [11484, 13370, 45, 1843],
ownerUserId: 11484,
checkIns: [
{
bracketIdx: null,
@@ -673,8 +434,8 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isCheckOut: 0,
},
],
mapPool: [],
team: null,
hasMapPool: false,
logoUrl: null,
avgSeedingSkillOrdinal: 23.38512079140615,
},
{
@@ -683,105 +444,13 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
seed: 5,
prefersNotToHost: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1734683349,
activeRosterUserIds: [37632, 13590, 10757, 33047],
startingBracketIdx: null,
abDivision: null,
avatarImgId: null,
pickupAvatarUrl: null,
members: [
{
userId: 37632,
username: "mitsi",
discordId: "690098887913898051",
discordAvatar: "92904ce37ba00b98173388d26a668075",
customUrl: "mitsi",
country: "PS",
twitch: null,
createdAt: 1734683349,
inGameName: "mitsi#2589",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "OWNER",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 13590,
username: "Canary",
discordId: "229149474436415488",
discordAvatar: "b5370ece354a643266d3eb2fb798c250",
customUrl: "canary",
country: "AU",
twitch: "sanityzed",
createdAt: 1734683352,
inGameName: "☆ SD-N ☆#2936",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 10757,
username: "Wilds ♪",
discordId: "335966886393151488",
discordAvatar: "a8fcb6c37957349865532149153ef306",
customUrl: "wilds",
country: "AU",
twitch: "whilds",
createdAt: 1734683356,
inGameName: "Wilds ♪#6274",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 33047,
username: "layden haw",
discordId: "260167849916497920",
discordAvatar: "aa1f9e79977626dabc7b0f6a1ce33934",
customUrl: null,
country: "AU",
twitch: null,
createdAt: 1734683966,
inGameName: "2F Law#1355",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 41024,
username: "Silly",
discordId: "807118113266204682",
discordAvatar: "40ecf7715f974c9c77fab988b7925f02",
customUrl: "silly_b3",
country: "AU",
twitch: "silly_b3",
createdAt: 1734685180,
inGameName: "His Silly#2385",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
],
inviteCode: null,
memberUserIds: [37632, 13590, 10757, 33047, 41024],
ownerUserId: 37632,
checkIns: [
{
bracketIdx: null,
@@ -789,13 +458,8 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isCheckOut: 0,
},
],
mapPool: [],
team: {
id: 3327,
customUrl: "fruitea",
logoUrl: "-u2c96lIxBLHuiSaafPgx-1721110885919.webp",
deletedAt: null,
},
hasMapPool: false,
logoUrl: "-u2c96lIxBLHuiSaafPgx-1721110885919.webp",
avgSeedingSkillOrdinal: 13.208083181946204,
},
{
@@ -804,123 +468,13 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
seed: 6,
prefersNotToHost: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1734608907,
activeRosterUserIds: [11780, 46006, 43518, 33483],
startingBracketIdx: null,
abDivision: null,
avatarImgId: null,
pickupAvatarUrl: "pickup-logo-FOfFcEbo2OJxIJIJxNJqu-1734608907317.webp",
members: [
{
userId: 43518,
username: "Veemo.ai",
discordId: "1207579465580937226",
discordAvatar: "26fed7e18436f4acf2a3d69be8c7ef6e",
customUrl: null,
country: "AU",
twitch: "veemo_ai",
createdAt: 1734608907,
inGameName: "H! Veems#3106",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "OWNER",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 29665,
username: "SounKade",
discordId: "456319133886185493",
discordAvatar: "84fe04756965b996b60bbd259e1e949f",
customUrl: null,
country: "AU",
twitch: "sounkade",
createdAt: 1734608923,
inGameName: "H!PwPwPew#2889",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 46006,
username: "Ozzysquid",
discordId: "568696527892250644",
discordAvatar: "6cf81e68116d09422cf1c0b92bffac35",
customUrl: null,
country: "AU",
twitch: "ozzysqid",
createdAt: 1734608925,
inGameName: "H!Ozzysqid#2558",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 33483,
username: "Koifu",
discordId: "835478379670405171",
discordAvatar: "8d8549f96de026e6c892fc6ae0533ba2",
customUrl: "koifu",
country: "AU",
twitch: "koifu_spl",
createdAt: 1734608931,
inGameName: "DrkXWolf17#3326",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 11780,
username: "𝘚𝘭𝘢𝘯𝘵𝘦𝘥",
discordId: "383285856024264728",
discordAvatar: "1e5da05391f497a80ff8f250e8a48d78",
customUrl: null,
country: "PH",
twitch: null,
createdAt: 1734659216,
inGameName: "Slanted#1646",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 37901,
username: "Scuffy",
discordId: "782140779765170177",
discordAvatar: "a_4068013243c9541df51f3505f4865491",
customUrl: null,
country: null,
twitch: "shade_is_special",
createdAt: 1734684084,
inGameName: null,
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
],
inviteCode: null,
memberUserIds: [43518, 29665, 46006, 33483, 11780, 37901],
ownerUserId: 43518,
checkIns: [
{
bracketIdx: null,
@@ -928,8 +482,8 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isCheckOut: 0,
},
],
mapPool: [],
team: null,
hasMapPool: false,
logoUrl: "pickup-logo-FOfFcEbo2OJxIJIJxNJqu-1734608907317.webp",
avgSeedingSkillOrdinal: -1.2336066064166205,
},
{
@@ -938,105 +492,13 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
seed: 7,
prefersNotToHost: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1734397954,
activeRosterUserIds: [46467, 46813, 33491, 43662],
startingBracketIdx: null,
abDivision: null,
avatarImgId: null,
pickupAvatarUrl: "pickup-logo-y79k_HOVmjv4KfhTjuSqh-1734398099266.webp",
members: [
{
userId: 45879,
username: "Albonchap",
discordId: "366392868065247234",
discordAvatar: "2cbf7ae01b702297a2521a171e0e3b78",
customUrl: "albonchap",
country: "AU",
twitch: null,
createdAt: 1734397954,
inGameName: "Albonchap#9998",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "OWNER",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 43662,
username: "FoolLime",
discordId: "605972217007702036",
discordAvatar: "7bf218fd71fce58c9ba1467ed88e7b4b",
customUrl: null,
country: "AU",
twitch: null,
createdAt: 1734397970,
inGameName: "FoolLime#1864",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 33491,
username: "Moth",
discordId: "799230134069755904",
discordAvatar: "cbc9cae07dd9f12cc406b74b926e09d7",
customUrl: null,
country: "AU",
twitch: null,
createdAt: 1734397973,
inGameName: "snowy#2709",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 46467,
username: "Veryneggy",
discordId: "293329775920152579",
discordAvatar: "e63a83d27a950d02f665d676133c15b3",
customUrl: "verygoodnegg",
country: "AU",
twitch: null,
createdAt: 1734398287,
inGameName: "Veryneggy#1494",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 46813,
username: "Mikil",
discordId: "644853671498088468",
discordAvatar: "ebe43708538e1b6e9c5678f0e341abce",
customUrl: null,
country: "AU",
twitch: null,
createdAt: 1734398628,
inGameName: "Mikil#2961",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
],
inviteCode: null,
memberUserIds: [45879, 43662, 33491, 46467, 46813],
ownerUserId: 45879,
checkIns: [
{
bracketIdx: null,
@@ -1044,13 +506,8 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isCheckOut: 0,
},
],
mapPool: [],
team: {
id: 4327,
customUrl: "monkey-barrel",
logoUrl: "-5sDkwde5xLxRfbu4Ww1C-1738819882151.webp",
deletedAt: null,
},
hasMapPool: false,
logoUrl: "-5sDkwde5xLxRfbu4Ww1C-1738819882151.webp",
avgSeedingSkillOrdinal: -3.4737419768092437,
},
{
@@ -1059,87 +516,13 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
seed: 8,
prefersNotToHost: 0,
droppedOut: 0,
inviteCode: null,
createdAt: 1734598652,
activeRosterUserIds: null,
startingBracketIdx: null,
abDivision: null,
avatarImgId: null,
pickupAvatarUrl: "pickup-logo-IGXFtjFMa_dxQqAe2dqIR-1734598652684.webp",
members: [
{
userId: 26992,
username: "Dit-toe",
discordId: "584595353370755083",
discordAvatar: "5e922d377f75683321f08004cfc5f6a6",
customUrl: "dit-toad",
country: "AU",
twitch: null,
createdAt: 1734598652,
inGameName: "ЯR Dit-toe#3315",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "OWNER",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 33611,
username: "Samkat",
discordId: "656420619251875870",
discordAvatar: "c6fc6c98ffea28d513c52e8de52f6157",
customUrl: null,
country: "AU",
twitch: null,
createdAt: 1734598655,
inGameName: "ЯR Samkat #3138",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 31148,
username: "smart.png",
discordId: "858608618186342420",
discordAvatar: "abec0f2d5dee5754ef02b71aee25bb73",
customUrl: "cakeatstake",
country: "AU",
twitch: null,
createdAt: 1734598656,
inGameName: "ЯR smart!!#1424",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
{
userId: 33578,
username: "Mat",
discordId: "988370975542353960",
discordAvatar: "29784a769af34943558199a7f3e012cb",
customUrl: null,
country: "AU",
twitch: null,
createdAt: 1734612388,
inGameName: "Mat#1561",
plusTier: null,
streamTwitch: null,
streamViewerCount: null,
streamThumbnailUrl: null,
role: "REGULAR",
isSub: 0,
customAvatarUrl: null,
},
],
inviteCode: null,
memberUserIds: [26992, 33611, 31148, 33578],
ownerUserId: 26992,
checkIns: [
{
bracketIdx: null,
@@ -1147,8 +530,8 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
isCheckOut: 0,
},
],
mapPool: [],
team: null,
hasMapPool: false,
logoUrl: "pickup-logo-IGXFtjFMa_dxQqAe2dqIR-1734598652684.webp",
avgSeedingSkillOrdinal: -6.382139240461566,
},
],
@@ -1251,11 +634,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
stageId: 23,
},
],
participatedUsers: [
45, 1843, 2072, 2899, 3147, 5662, 6114, 10757, 11484, 11780, 13370, 13590,
17855, 21689, 26992, 30176, 31148, 33047, 33491, 33578, 33611, 37632,
37901, 43518, 43662, 45879, 46006, 46467, 46813,
],
castStreams: [],
latestTeamIdByDuplicatedUserId: {},
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -12,16 +12,15 @@ export const tournamentCtxTeam = (
checkIns: [{ checkedInAt: 1705858841, bracketIdx: null, isCheckOut: 0 }],
createdAt: 0,
id: teamId,
inviteCode: null,
avgSeedingSkillOrdinal: null,
startingBracketIdx: null,
abDivision: null,
team: null,
mapPool: [],
members: [],
hasMapPool: false,
inviteCode: null,
memberUserIds: [],
ownerUserId: null,
activeRosterUserIds: [],
avatarImgId: null,
pickupAvatarUrl: null,
logoUrl: null,
name: `Team ${teamId}`,
prefersNotToHost: 0,
droppedOut: 0,
@@ -77,8 +76,7 @@ export const testTournament = ({
staff: [],
tieBreakerMapPool: [],
toSetMapPool: [],
participatedUsers: [],
castStreams: [],
latestTeamIdByDuplicatedUserId: {},
mapPickingStyle: "AUTO_SZ",
settings: {
bracketProgression: [
@@ -116,6 +114,7 @@ export const testTournament = ({
})),
},
ctx: tournamentCtx,
participatedUsers: [],
});
};

View File

@@ -4,6 +4,7 @@ import * as CalendarRepository from "~/features/calendar/CalendarRepository.serv
import * as Seasons from "~/features/mmr/core/Seasons";
import { seasonRatings, seedingRatings } from "~/features/mmr/mmr-utils.server";
import * as Standings from "~/features/tournament/core/Standings";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import {
summaryRatingTargets,
tournamentSummary,
@@ -93,16 +94,21 @@ async function standingsWithSetParticipation(tournament: Tournament) {
progression: tournament.ctx.settings.bracketProgression,
});
return finalStandings.map((standing) => {
standing.team.members;
return {
placement: standing.placement,
tournamentTeamId: standing.team.id,
name: standing.team.name,
members: standing.team.members.map((member) => ({
const rostersByTeamId = new Map(
(
await TournamentRepository.findTeamsFullByTournamentId(tournament.ctx.id)
).map((team) => [team.id, team.members]),
);
return finalStandings.map((standing) => ({
placement: standing.placement,
tournamentTeamId: standing.team.id,
name: standing.team.name,
members: (rostersByTeamId.get(standing.team.id) ?? [])
.filter((member) => standing.team.memberUserIds.includes(member.userId))
.map((member) => ({
...member,
setResults: setResults.get(member.userId) ?? [],
})),
};
});
}));
}

View File

@@ -0,0 +1,69 @@
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import type { SerializeFrom } from "~/utils/remix";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
import {
requireTournamentVisible,
serializeBracket,
tournamentSharedCached,
} from "../core/Tournament.server";
import { tournamentBracketsSearchParams } from "../tournament-bracket-search-params";
export type TournamentBracketsLoaderData = SerializeFrom<typeof loader>;
/**
* Match data of the one bracket the view renders, selected by the `idx` search param.
* The other brackets are represented by the layout's bracket state alone.
*/
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const user = getUser();
const { id: tournamentId } = parseParams({ params, schema: idObject });
const tournament = await tournamentSharedCached(tournamentId);
requireTournamentVisible({ ctx: tournament.ctx, user });
const bracketIdx = resolveBracketIdx(
tournament,
tournamentBracketsSearchParams.parse(request).idx,
);
const bracket = tournament.bracketByIdx(bracketIdx);
return {
bracketIdx,
bracket: bracket ? serializeBracket(bracket) : null,
// the layout does not ship these, standings derived in the view need them
participatedUserIds: tournament.participatedUserIds,
teamProgressStatus: tournament.teamMemberOfProgressStatus(user),
// the match cards' LIVE badges need these, also not shipped by the layout
streams: tournament.streams,
};
};
/**
* The bracket to show, always one of the brackets the view actually renders a tab for. Without
* a valid `idx` the first bracket, unless it is over and followed by a bracket the tournament
* actually continues in.
*/
function resolveBracketIdx(
tournament: Awaited<ReturnType<typeof tournamentSharedCached>>,
idx: number | null,
) {
const visibleBrackets = tournament.visibleBracketsMeta;
const isVisible = (idx: number) =>
visibleBrackets.some((bracket) => bracket.idx === idx);
if (idx !== null && isVisible(idx)) {
return idx;
}
const brackets = tournament.bracketsMeta;
const defaultIdx =
brackets.length <= 1 ||
brackets[1].isUnderground ||
!brackets[0].everyMatchOver
? 0
: 1;
return isVisible(defaultIdx) ? defaultIdx : (visibleBrackets[0]?.idx ?? 0);
}

View File

@@ -4,7 +4,11 @@ import * as TournamentRepository from "~/features/tournament/TournamentRepositor
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
import type { Unwrapped } from "../../../utils/types";
import { tournamentFromDB } from "../core/Tournament.server";
import {
requireTournamentVisible,
tournamentDataCached,
tournamentFromDB,
} from "../core/Tournament.server";
export const loader = async ({ params }: LoaderFunctionArgs) => {
const user = getUser();
@@ -13,6 +17,9 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
schema: idObject,
});
const { ctx } = await tournamentDataCached({ tournamentId });
requireTournamentVisible({ ctx, user });
const divisions = notFoundIfNullish(await divisionsCached(tournamentId));
return {

View File

@@ -13,7 +13,13 @@ import {
import * as React from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useTranslation } from "react-i18next";
import { Outlet, useLocation, useOutletContext } from "react-router";
import {
Outlet,
useLoaderData,
useLocation,
useNavigation,
useOutletContext,
} from "react-router";
import { Alert } from "~/components/Alert";
import { Divider } from "~/components/Divider";
import { LinkButton, SendouButton } from "~/components/elements/Button";
@@ -25,6 +31,7 @@ import {
SendouTabs,
} from "~/components/elements/Tabs";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { Placeholder } from "~/components/Placeholder";
import { useUser } from "~/features/auth/core/user";
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
@@ -35,6 +42,7 @@ import { useSearchParam } from "~/modules/search-params/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { SENDOU_INK_BASE_URL, tournamentJoinPage } from "~/utils/urls";
import {
TournamentOverrideProvider,
useBracketExpanded,
useTournament,
useTournamentPreparedMaps,
@@ -47,11 +55,15 @@ import { TournamentTeamActions } from "../components/TournamentTeamActions";
import * as AbDivisions from "../core/AbDivisions";
import type { Bracket as BracketType } from "../core/Bracket";
import * as PreparedMaps from "../core/PreparedMaps";
import type { Tournament } from "../core/Tournament";
import type { BracketMeta, Tournament } from "../core/Tournament";
import {
loader,
type TournamentBracketsLoaderData,
} from "../loaders/to.$id.brackets.server";
import { tournamentBracketsSearchParams } from "../tournament-bracket-search-params";
import { tournamentWebsocketRoom } from "../tournament-bracket-utils";
export { action };
export { action, loader };
export const handle: SendouRouteHandle = {
mainBreakout: true,
@@ -60,41 +72,45 @@ export const handle: SendouRouteHandle = {
import styles from "../tournament-bracket.module.css";
export default function TournamentBracketsPage() {
const data = useLoaderData<TournamentBracketsLoaderData>();
const layoutTournament = useTournament();
const tournament = React.useMemo(
() =>
data.bracket
? layoutTournament.withBrackets([data.bracket], {
participatedUsers: data.participatedUserIds,
streams: data.streams,
})
: layoutTournament,
[layoutTournament, data.bracket, data.participatedUserIds, data.streams],
);
return (
<TournamentOverrideProvider tournament={tournament}>
<TournamentBracketsView />
</TournamentOverrideProvider>
);
}
function TournamentBracketsView() {
const { t } = useTranslation(["common", "tournament"]);
const user = useUser();
const tournament = useTournament();
const data = useLoaderData<TournamentBracketsLoaderData>();
const ctx = useOutletContext();
const location = useLocation();
useScrollToMatchOnLoad();
const defaultBracketIdx = () => {
if (
tournament.brackets.length === 1 ||
tournament.brackets[1].isUnderground ||
!tournament.brackets[0].everyMatchOver
) {
return 0;
}
return 1;
};
const [bracketIdxParam, setBracketIdx] = useSearchParam(
tournamentBracketsSearchParams,
"idx",
);
const bracketIdx = bracketIdxParam ?? defaultBracketIdx();
const bracket = React.useMemo(
() => tournament.bracketByIdxOrDefault(bracketIdx),
[tournament, bracketIdx],
);
const bracket = tournament.bracketByIdx(data.bracketIdx);
useWebsocketRevalidation(
tournamentWebsocketRoom(tournament.ctx.id),
!tournament.ctx.isFinalized,
);
const teamProgressStatus = tournament.teamMemberOfProgressStatus(user);
const teamProgressStatus = data.teamProgressStatus;
const showAddSubsButton =
!tournament.canFinalize(user) &&
!tournament.everyBracketOver &&
@@ -137,20 +153,16 @@ export default function TournamentBracketsPage() {
});
};
const teamsSourceText = () => {
if (
tournament.brackets[0].type === "round_robin" &&
!bracket.isUnderground
) {
const teamsSourceText = (bracket: BracketType) => {
const firstBracket = tournament.bracketsMeta[0];
if (firstBracket.type === "round_robin" && !bracket.isUnderground) {
return `Teams that place in the top ${Math.max(
...(bracket.sources ?? []).flatMap((s) => s.placements),
)} of their group will advance to this stage`;
}
if (
tournament.brackets[0].type === "round_robin" &&
bracket.isUnderground
) {
if (firstBracket.type === "round_robin" && bracket.isUnderground) {
const placements = (
bracket.sources?.flatMap((s) => s.placements) ?? []
).sort((a, b) => a - b);
@@ -158,28 +170,22 @@ export default function TournamentBracketsPage() {
return `Teams that don't advance to the final stage can play in this bracket (placements: ${placements.join(", ")})`;
}
if (
tournament.brackets[0].type === "double_elimination" &&
bracket.isUnderground
) {
if (firstBracket.type === "double_elimination" && bracket.isUnderground) {
return `Teams that get eliminated in the first ${Math.abs(
Math.min(...(bracket.sources ?? []).flatMap((s) => s.placements)),
)} rounds of the losers bracket can play in this bracket`;
}
if (
tournament.brackets[0].type === "single_elimination" &&
bracket.isUnderground
) {
if (firstBracket.type === "single_elimination" && bracket.isUnderground) {
return `Teams that get eliminated in the first ${Math.abs(
Math.min(...(bracket.sources ?? []).flatMap((s) => s.placements)),
)} rounds can play in this bracket`;
}
const advanceThreshold = tournament.brackets[0].settings?.advanceThreshold;
const advanceThreshold = firstBracket.settings?.advanceThreshold;
if (
advanceThreshold &&
tournament.ctx.settings.bracketProgression[bracketIdx].sources?.[0]
tournament.ctx.settings.bracketProgression[bracket.idx].sources?.[0]
.placements.length === 0
) {
return `Teams that win at least ${advanceThreshold} sets in the Swiss bracket will advance to this stage`;
@@ -198,7 +204,9 @@ export default function TournamentBracketsPage() {
{showTeamActionsRow ? (
<div className="stack horizontal mb-4 sm justify-between items-center">
{/** TournamentTeamActions more confusing than helpful for leagues, for example might say "Waiting for match..." when previous match was rescheduled */}
{!tournament.isLeagueDivision ? <TournamentTeamActions /> : null}
{!tournament.isLeagueDivision ? (
<TournamentTeamActions status={teamProgressStatus} />
) : null}
{showAddSubsButton ? <AddSubsPopOver /> : null}
</div>
) : null}
@@ -206,7 +214,8 @@ export default function TournamentBracketsPage() {
<div className="stack horizontal sm mb-4">
{tournament.canFinalize(user) ? (
<LinkButton
to="finalize"
// keeps the selected bracket, which the loader reads from the search params
to={{ pathname: "finalize", search: location.search }}
testId="finalize-tournament-button"
icon={<Stamp />}
>
@@ -224,15 +233,15 @@ export default function TournamentBracketsPage() {
) : null}
</div>
) : null}
<BracketTabs bracketIdx={bracketIdx} setBracketIdx={setBracketIdx}>
{(currentBracket, currentBracketIdx) => (
<BracketTabs loadedBracketIdx={data.bracketIdx}>
{bracket ? (
<BracketTabContent
bracket={currentBracket}
bracketIdx={currentBracketIdx}
bracket={bracket}
bracketIdx={data.bracketIdx}
waitingForTeamsText={waitingForTeamsText}
teamsSourceText={teamsSourceText}
/>
)}
) : null}
</BracketTabs>
</div>
);
@@ -265,8 +274,7 @@ function useScrollToMatchOnLoad() {
function eligibleTeamCountForBracket(
tournament: Tournament,
bracket: BracketType,
bracketIdx: number,
bracket: BracketMeta,
) {
if (bracket.sources) {
return (
@@ -280,17 +288,13 @@ function eligibleTeamCountForBracket(
}
return tournament.ctx.teams.filter(
(team) => (team.startingBracketIdx ?? 0) === bracketIdx,
(team) => (team.startingBracketIdx ?? 0) === bracket.idx,
).length;
}
function bracketTabTeamCount(
tournament: Tournament,
bracket: BracketType,
bracketIdx: number,
) {
function bracketTabTeamCount(tournament: Tournament, bracket: BracketMeta) {
return bracket.preview
? eligibleTeamCountForBracket(tournament, bracket, bracketIdx)
? eligibleTeamCountForBracket(tournament, bracket)
: bracket.participantTournamentTeamIds.length;
}
@@ -450,11 +454,11 @@ function AddSubsPopOver() {
}
const subsAvailableToAdd =
tournament.maxMembersPerTeam - ownedTeam.members.length;
tournament.maxMembersPerTeam - ownedTeam.memberUserIds.length;
const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({
tournamentId: tournament.ctx.id,
inviteCode: ownedTeam.inviteCode,
inviteCode: ownedTeam.inviteCode!,
})}`;
return (
@@ -505,48 +509,61 @@ function SubsPopover({ children }: { children: React.ReactNode }) {
);
}
/**
* Bracket switcher. Only the bracket the loader shipped the match data of is rendered;
* switching navigates so that the newly selected bracket's data gets loaded.
*/
function BracketTabs({
bracketIdx,
setBracketIdx,
loadedBracketIdx,
children,
}: {
bracketIdx: number;
setBracketIdx: (bracketIdx: number) => void;
children: (bracket: BracketType, bracketIdx: number) => React.ReactNode;
loadedBracketIdx: number;
children: React.ReactNode;
}) {
const tournament = useTournament();
const visibleBrackets = tournament.ctx.settings.bracketProgression.filter(
(_, i) =>
!tournament.ctx.isFinalized ||
!tournament.bracketByIdxOrDefault(i).preview,
const [idxParam, setIdxParam] = useSearchParam(
tournamentBracketsSearchParams,
"idx",
);
const navigation = useNavigation();
const visibleBrackets = tournament.visibleBracketsMeta;
// while the newly selected bracket is being loaded its tab is already the selected one
const pendingIdx = navigation.location
? tournamentBracketsSearchParams.parse(
new URLSearchParams(navigation.location.search),
).idx
: null;
const requestedIdx = pendingIdx ?? idxParam ?? loadedBracketIdx;
// the search param can point to a bracket without a tab e.g. one hidden after finalization
const selectedIdx = visibleBrackets.some(
(bracket) => bracket.idx === requestedIdx,
)
? requestedIdx
: loadedBracketIdx;
const bracketNameForTab = (name: string) => name.replace("bracket", "");
return (
<SendouTabs
selectedKey={String(bracketIdx)}
onSelectionChange={(key) => setBracketIdx(Number(key))}
selectedKey={String(selectedIdx)}
onSelectionChange={(key) => setIdxParam(Number(key))}
>
<SendouTabList>
{visibleBrackets.map((bracket, i) => (
{visibleBrackets.map((bracket) => (
<SendouTab
key={bracket.name}
id={String(i)}
number={bracketTabTeamCount(
tournament,
tournament.bracketByIdxOrDefault(i),
i,
)}
id={String(bracket.idx)}
number={bracketTabTeamCount(tournament, bracket)}
>
{bracketNameForTab(bracket.name)}
</SendouTab>
))}
</SendouTabList>
{visibleBrackets.map((_, i) => (
<SendouTabPanel key={i} id={String(i)}>
{children(tournament.bracketByIdxOrDefault(i), i)}
{visibleBrackets.map((bracket) => (
<SendouTabPanel key={bracket.idx} id={String(bracket.idx)}>
{bracket.idx === loadedBracketIdx ? children : <Placeholder />}
</SendouTabPanel>
))}
</SendouTabs>
@@ -562,7 +579,7 @@ function BracketTabContent({
bracket: BracketType;
bracketIdx: number;
waitingForTeamsText: (bracket: BracketType, bracketIdx: number) => string;
teamsSourceText: () => string | null;
teamsSourceText: (bracket: BracketType) => string | null;
}) {
return (
<>
@@ -585,7 +602,7 @@ function BracketTabContent({
</div>
{bracket.sources ? (
<div className="text-center text-sm font-semi-bold text-lighter mt-2">
{teamsSourceText()}
{teamsSourceText(bracket)}
</div>
) : null}
{bracket.requiresCheckIn ? (
@@ -694,8 +711,7 @@ function StartBracketAlert({
const abDivisionsStartError = getAbDivisionsStartError(bracket, tournament);
const totalTeamsAvailableForTheBracket = eligibleTeamCountForBracket(
tournament,
bracket,
bracketIdx,
tournament.bracketsMeta[bracketIdx],
);
return (

View File

@@ -3,6 +3,6 @@ import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
export const tournamentBracketsSearchParams = SearchParams.define({
idx: SP.param(z.number().int().min(0).nullable(), { loader: false }),
idx: SP.param(z.number().int().min(0).nullable(), { loader: true }),
group: SP.param(z.number().int().nullable(), { loader: false }),
});

View File

@@ -39,8 +39,8 @@ export function tournamentTeamToActiveRosterUserIds(
}
// 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);
if (team.memberUserIds.length === teamMinMemberCount) {
return team.memberUserIds;
}
return null;
@@ -55,9 +55,9 @@ export function ensureOneStandingPerUser(standings: Standing[]) {
...standing,
team: {
...standing.team,
members: standing.team.members.filter((member) => {
if (userIds.has(member.userId)) return false;
userIds.add(member.userId);
memberUserIds: standing.team.memberUserIds.filter((userId) => {
if (userIds.has(userId)) return false;
userIds.add(userId);
return true;
}),
},

View File

@@ -6,6 +6,7 @@ import { requireNotBannedByOrganization } from "~/features/tournament/tournament
import {
clearTournamentDataCache,
tournamentFromDBCached,
tournamentTeamsFullCached,
} from "~/features/tournament-bracket/core/Tournament.server";
import { parseFormData } from "~/form/parse.server";
import { errorToastIfFalsy, parseParams } from "~/utils/remix.server";
@@ -65,7 +66,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const team = tournament.teamMemberOfByUser(user);
if (team) {
const member = team.members.find((m) => m.userId === user.id);
const teams = await tournamentTeamsFullCached({ tournamentId, user });
const member = teams
.find((t) => t.id === team.id)
?.members.find((m) => m.userId === user.id);
const canManageTeam =
member?.role === "OWNER" || member?.role === "MANAGER";
errorToastIfFalsy(
@@ -74,7 +78,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
);
errorToastIfFalsy(
team.members.length < tournament.maxMembersPerTeam,
team.memberUserIds.length < tournament.maxMembersPerTeam,
"Team is already at max capacity",
);
const pickup = await TournamentLFGRepository.startLooking(team.id);

View File

@@ -2,8 +2,13 @@ import type { LoaderFunctionArgs } from "react-router";
import * as R from "remeda";
import type { Pronouns } from "~/db/tables-json";
import { getUser } from "~/features/auth/core/user.server";
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
import {
requireTournamentVisible,
tournamentFromDBCached,
tournamentTeamsFullCached,
} from "~/features/tournament-bracket/core/Tournament.server";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type { SerializeFrom } from "~/utils/remix";
import { parseParams } from "~/utils/remix.server";
@@ -26,6 +31,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
tournamentId,
user,
});
requireTournamentVisible({ ctx: tournament.ctx, user });
if (!tournament.lfgEnabled) {
throw new Response(null, { status: 404 });
@@ -163,9 +169,17 @@ async function resolveOwnTeam({
user,
});
const team = tournament.teamMemberOfByUser(user);
const teamLite = tournament.teamMemberOfByUser(user);
if (!teamLite) return null;
const teamsFull = await tournamentTeamsFullCached({ tournamentId, user });
const team = teamsFull.find((t) => t.id === teamLite.id);
if (!team) return null;
const plusTiers = await UserRepository.findPlusTiersByUserIds(
team.members.map((m) => m.userId),
);
const members: LFGGroupMember[] = team.members.map((m) => ({
id: m.userId,
username: m.username,
@@ -179,7 +193,7 @@ async function resolveOwnTeam({
role: m.role,
isStayAsSub: false,
weapons: null,
plusTier: m.plusTier,
plusTier: plusTiers.get(m.userId) ?? null,
}));
return {

View File

@@ -10,7 +10,6 @@ import { executeBracketOperation } from "~/features/tournament-bracket/core/exec
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import {
clearTournamentDataCache,
type TournamentDataTeam,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import {
@@ -86,6 +85,9 @@ export const action: ActionFunction = async ({ params, request }) => {
let emitMatchUpdate = false;
let emitTournamentUpdate = false;
// true when nothing outside match data (scores, pick/ban events) changed, letting
// broadcast receivers skip revalidating the tournament layout and root loaders
let onlyMatchResultsChanged = false;
let setIsOver = false;
let endedDroppedMatchIds: number[] = [];
let followingMatchIds: number[] = [];
@@ -110,6 +112,9 @@ export const action: ActionFunction = async ({ params, request }) => {
emitMatchUpdate = true;
emitTournamentUpdate = true;
// a set ending (or dropped teams' matches ending) changes bracket state
// the layout ships (bracketsMeta), so only mid-set reports are scoped
onlyMatchResultsChanged = !setIsOver && endedDroppedMatchIds.length === 0;
break;
}
@@ -127,9 +132,7 @@ export const action: ActionFunction = async ({ params, request }) => {
const team = tournament.teamById(data.teamId)!;
errorToastIfFalsy(
data.roster.every((userId) =>
team.members.some((m) => m.userId === userId),
),
data.roster.every((userId) => team.memberUserIds.includes(userId)),
"Invalid roster",
);
@@ -239,10 +242,10 @@ export const action: ActionFunction = async ({ params, request }) => {
const teamTwo = tournament.teamById(match.opponentTwo!.id!)!;
errorToastIfFalsy(
data.rosters[0].every((userId) =>
teamOne.members.some((m) => m.userId === userId),
teamOne.memberUserIds.includes(userId),
) &&
data.rosters[1].every((userId) =>
teamTwo.members.some((m) => m.userId === userId),
teamTwo.memberUserIds.includes(userId),
),
"Invalid roster",
);
@@ -299,18 +302,27 @@ export const action: ActionFunction = async ({ params, request }) => {
const results =
await TournamentMatchRepository.findResultsByMatchId(matchId);
const teamOne = match.opponentOne?.id
? tournament.teamById(match.opponentOne.id)
: undefined;
const teamTwo = match.opponentTwo?.id
? tournament.teamById(match.opponentTwo.id)
: undefined;
invariant(teamOne && teamTwo, "Teams are missing");
invariant(
match.roundMaps && match.opponentOne?.id && match.opponentTwo?.id,
"Missing fields to pick/ban",
match.opponentOne?.id && match.opponentTwo?.id,
"Teams are missing",
);
const mapPools = await TournamentTeamRepository.findMapPoolsByTeamIds([
match.opponentOne.id,
match.opponentTwo.id,
]);
const teamOneCtx = tournament.teamById(match.opponentOne.id);
const teamTwoCtx = tournament.teamById(match.opponentTwo.id);
invariant(teamOneCtx && teamTwoCtx, "Teams are missing");
const teamOne = {
...teamOneCtx,
mapPool: mapPools.get(match.opponentOne.id) ?? [],
};
const teamTwo = {
...teamTwoCtx,
mapPool: mapPools.get(match.opponentTwo.id) ?? [],
};
invariant(match.roundMaps, "Missing fields to pick/ban");
const currentPickBanEvents =
await TournamentRepository.findPickBanEventsByMatchId(match.id);
@@ -349,7 +361,7 @@ export const action: ActionFunction = async ({ params, request }) => {
: [],
mapList,
tieBreakerMapPool: tournament.ctx.tieBreakerMapPool,
teams: [teamOne, teamTwo] as [TournamentDataTeam, TournamentDataTeam],
teams: [teamOne, teamTwo] as [PickBan.MapPoolTeam, PickBan.MapPoolTeam],
pickerTeamId,
pickBanEvents: currentPickBanEvents,
};
@@ -435,6 +447,7 @@ export const action: ActionFunction = async ({ params, request }) => {
}
emitMatchUpdate = true;
onlyMatchResultsChanged = true;
break;
}
@@ -668,6 +681,10 @@ export const action: ActionFunction = async ({ params, request }) => {
.map((followingMatch) => followingMatch.id);
}
const revalidateScope = onlyMatchResultsChanged
? ("MATCH_RESULTS" as const)
: undefined;
if (emitMatchUpdate) {
const otherMatchIdsToRevalidate = Array.from(
new Set([...endedDroppedMatchIds, ...followingMatchIds]),
@@ -678,11 +695,13 @@ export const action: ActionFunction = async ({ params, request }) => {
room: tournamentMatchWebsocketRoom(matchId),
type: "TOURNAMENT_MATCH_UPDATED",
revalidateOnly: true,
revalidateScope,
},
...otherMatchIdsToRevalidate.map((id) => ({
room: tournamentMatchWebsocketRoom(id),
type: "TOURNAMENT_MATCH_UPDATED" as const,
revalidateOnly: true as const,
revalidateScope,
})),
]);
}
@@ -692,6 +711,7 @@ export const action: ActionFunction = async ({ params, request }) => {
room: tournamentWebsocketRoom(tournament.ctx.id),
type: "TOURNAMENT_UPDATED",
revalidateOnly: true,
revalidateScope,
},
]);
}

View File

@@ -6,11 +6,10 @@ import {
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
import { useMatch } from "../match-page-context";
import { type MatchPageTeam, useMatch } from "../match-page-context";
import { UndoReportButton } from "./TournamentMatchActionTab";
type FromIndicator = NonNullable<PickBanMapOption["picker"]>;
@@ -21,7 +20,7 @@ export function TournamentMatchActionPickBanTab({
turnOfResult,
}: {
data: TournamentMatchLoaderData;
teams: [TournamentDataTeam, TournamentDataTeam];
teams: [MatchPageTeam, MatchPageTeam];
turnOfResult: PickBan.TurnOfResult;
}) {
const user = useUser();

View File

@@ -14,7 +14,7 @@ import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-brack
import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates";
import type { CommonUser } from "~/utils/kysely.server";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
import { useMatch } from "../match-page-context";
import { type MatchPageTeam, useMatch } from "../match-page-context";
export function TournamentMatchActionTab({
data,
@@ -55,9 +55,7 @@ export function TournamentMatchActionTab({
if (!teamOne || !teamTwo) return null;
const withKo = tournament.bracketByIdxOrDefault(
tournament.matchIdToBracketIdx(data.match.id) ?? 0,
).collectsKos;
const withKo = data.bracketContext.collectsKos;
const count = data.match.roundMaps.count;
const countType = data.match.roundMaps.type;
@@ -86,7 +84,6 @@ export function TournamentMatchActionTab({
setEndingTeamIds.length > 0
? {
...buildSetEndingData({
tournament,
teams: [teamOne, teamTwo],
scores,
results: data.results,
@@ -103,12 +100,12 @@ export function TournamentMatchActionTab({
{
id: teamOne.id,
name: teamOne.name,
avatar: tournament.tournamentTeamLogoSrc(teamOne) ?? undefined,
avatar: teamOne.logoUrl ?? undefined,
},
{
id: teamTwo.id,
name: teamTwo.name,
avatar: tournament.tournamentTeamLogoSrc(teamTwo) ?? undefined,
avatar: teamTwo.logoUrl ?? undefined,
},
]}
ownTeamId={ownTeamId}
@@ -220,24 +217,19 @@ function useTournamentWeaponReport({
const activeRoster =
tournamentTeamToActiveRosterUserIds(team, tournament.minMembersPerTeam) ??
team.members.map((m) => m.userId);
team.memberUserIds;
return activeRoster.includes(viewerUserId);
}
}
function buildSetEndingData({
tournament,
teams,
scores,
results,
opponentOneId,
}: {
tournament: ReturnType<typeof useTournament>;
teams: [
NonNullable<ReturnType<ReturnType<typeof useTournament>["teamById"]>>,
NonNullable<ReturnType<ReturnType<typeof useTournament>["teamById"]>>,
];
teams: [MatchPageTeam, MatchPageTeam];
scores: [number, number];
results: TournamentMatchLoaderData["results"];
opponentOneId: number;
@@ -299,9 +291,7 @@ function buildSetEndingData({
};
});
const activeRosterUsers = (
team: NonNullable<ReturnType<ReturnType<typeof useTournament>["teamById"]>>,
): CommonUser[] => {
const activeRosterUsers = (team: MatchPageTeam): CommonUser[] => {
const activeIds = team.activeRosterUserIds;
const members = activeIds
? team.members.filter((m) => activeIds.includes(m.userId))
@@ -313,11 +303,11 @@ function buildSetEndingData({
teams: {
alpha: {
name: teamOne.name,
avatar: tournament.tournamentTeamLogoSrc(teamOne) ?? undefined,
avatar: teamOne.logoUrl ?? undefined,
},
bravo: {
name: teamTwo.name,
avatar: tournament.tournamentTeamLogoSrc(teamTwo) ?? undefined,
avatar: teamTwo.logoUrl ?? undefined,
},
},
score: { alpha: scores[0], bravo: scores[1] },

View File

@@ -17,9 +17,8 @@ import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import type { MatchStatus } from "~/features/tournament-bracket/core/engine";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
import { useMatch } from "../match-page-context";
import { type MatchPageTeam, useMatch } from "../match-page-context";
import { OrganizerMatchMapListDialog } from "./OrganizerMatchMapListDialog";
import styles from "./TournamentMatchAdminTab.module.css";
@@ -38,9 +37,7 @@ export function TournamentMatchAdminTab({
const isOrganizer = tournament.isOrganizer(user);
const canReopen =
isOrganizer &&
data.matchIsOver &&
tournament.matchCanBeReopened(data.match.id);
isOrganizer && data.matchIsOver && data.bracketContext.canBeReopened;
const canEndSet =
isOrganizer && !data.matchIsOver && data.match.startedAt !== null;
@@ -256,11 +253,7 @@ function ReopenMatchButton() {
);
}
function EndSetPopover({
teams,
}: {
teams: [TournamentDataTeam, TournamentDataTeam];
}) {
function EndSetPopover({ teams }: { teams: [MatchPageTeam, MatchPageTeam] }) {
const { t } = useTranslation(["tournament"]);
const [selectedWinner, setSelectedWinner] = React.useState<
number | null | undefined
@@ -344,14 +337,11 @@ function EditReportedScoresSection({
teams,
}: {
data: TournamentMatchLoaderData;
teams: [TournamentDataTeam, TournamentDataTeam];
teams: [MatchPageTeam, MatchPageTeam];
}) {
const { t } = useTranslation(["tournament"]);
const tournament = useTournament();
const withKo = tournament.bracketByIdxOrDefault(
tournament.matchIdToBracketIdx(data.match.id) ?? 0,
).collectsKos;
const withKo = data.bracketContext.collectsKos;
return (
<div className={styles.editSection}>
@@ -379,7 +369,7 @@ function EditReportedScoreRow({
}: {
index: number;
result: TournamentMatchLoaderData["results"][number];
teams: [TournamentDataTeam, TournamentDataTeam];
teams: [MatchPageTeam, MatchPageTeam];
withKo: boolean;
}) {
const { t } = useTranslation(["common", "game-misc", "tournament"]);
@@ -452,7 +442,7 @@ function EditReportedScoreForm({
}: {
fetcher: ReturnType<typeof useFetcher>;
result: TournamentMatchLoaderData["results"][number];
teams: [TournamentDataTeam, TournamentDataTeam];
teams: [MatchPageTeam, MatchPageTeam];
withKo: boolean;
minMembersPerTeam: number;
onCancel: () => void;

View File

@@ -25,10 +25,6 @@ import { MatchBannerTimer } from "~/components/match-page/MatchBannerTimer";
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
import type { TournamentRoundMaps } from "~/db/tables-json";
import { useTournament } from "~/features/tournament/routes/to.$id";
import {
isLeagueRoundLocked,
resolveLeagueRoundStartDate,
} from "~/features/tournament/tournament-utils";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useAutoRerender } from "~/hooks/useAutoRerender";
@@ -67,7 +63,7 @@ export function TournamentMatchBanner({
const host = hostingTeam
? {
name: hostingTeam.name,
avatarUrl: tournament.tournamentTeamLogoSrc(hostingTeam) ?? undefined,
avatarUrl: hostingTeam.logoUrl ?? undefined,
}
: null;
@@ -80,9 +76,9 @@ export function TournamentMatchBanner({
tournament,
});
const leagueRoundLocked = isLeagueRoundLocked(tournament, data.match.roundId);
const leagueRoundStartDate = leagueRoundLocked
? resolveLeagueRoundStartDate(tournament, data.match.roundId)
const { leagueRoundLocked } = data.bracketContext;
const leagueRoundStartDate = data.bracketContext.leagueRoundStartDate
? databaseTimestampToDate(data.bracketContext.leagueRoundStartDate)
: null;
const pickBanBanner = resolvePickBanBanner(data, tournament, t);
@@ -94,7 +90,7 @@ export function TournamentMatchBanner({
: undefined;
const activeRosterByTeamId = (tournamentTeamId: number) => {
const team = tournament.teamById(tournamentTeamId);
const team = teams.find((t) => t?.id === tournamentTeamId);
if (!team) return null;
const activeRosterUserIds = team.activeRosterUserIds;
@@ -326,7 +322,7 @@ function CurrentMapPickInfo({
{teams.map((team) => (
<Avatar
key={team.id}
url={tournament.tournamentTeamLogoSrc(team)}
url={team.logoUrl}
identiconInput={team.name}
size="xxs"
/>

View File

@@ -13,9 +13,7 @@ export function TournamentMatchHeader({
}) {
const tournament = useTournament();
const { bracketName, roundName } = tournament.matchContextNamesById(
data.match.id,
);
const { bracketName, roundName } = data.bracketContext.names;
return (
<MatchPageHeader
@@ -24,7 +22,7 @@ export function TournamentMatchHeader({
<LinkButton
to={tournamentBracketsPage({
tournamentId: tournament.ctx.id,
bracketIdx: tournament.matchIdToBracketIdx(data.match.id),
bracketIdx: data.bracketContext.bracketIdx,
groupId: data.match.groupId,
})}
state={{ scrollToMatchId: data.match.id } satisfies BracketsPageState}

View File

@@ -115,15 +115,11 @@ function resolveTimelineTeams(
return {
alpha: {
name: teamOne?.name ?? "?",
avatar: teamOne
? (tournament.tournamentTeamLogoSrc(teamOne) ?? undefined)
: undefined,
avatar: teamOne ? (teamOne.logoUrl ?? undefined) : undefined,
},
bravo: {
name: teamTwo?.name ?? "?",
avatar: teamTwo
? (tournament.tournamentTeamLogoSrc(teamTwo) ?? undefined)
: undefined,
avatar: teamTwo ? (teamTwo.logoUrl ?? undefined) : undefined,
},
};
}
@@ -333,16 +329,14 @@ function TournamentMatchRosterTab({
/>
);
function rosterTeamData(
team: NonNullable<ReturnType<typeof tournament.teamById>>,
) {
function rosterTeamData(team: MatchPageTeam) {
const subbedOut =
!data.matchIsOver &&
team.activeRosterUserIds &&
team.members.length > tournament.minMembersPerTeam
? team.members
.filter((m) => !team.activeRosterUserIds!.includes(m.userId))
.map((m) => m.userId)
team.memberUserIds.length > tournament.minMembersPerTeam
? team.memberUserIds.filter(
(userId) => !team.activeRosterUserIds!.includes(userId),
)
: undefined;
return {
@@ -353,7 +347,7 @@ function TournamentMatchRosterTab({
tournamentId: tournament.ctx.id,
tournamentTeamId: team.id,
}),
avatar: tournament.tournamentTeamLogoSrc(team) ?? undefined,
avatar: team.logoUrl ?? undefined,
},
members: team.members.map((m) => ({
id: m.userId,
@@ -369,19 +363,15 @@ function TournamentMatchRosterTab({
};
}
function canEditSubbedOutForTeam(
team: NonNullable<ReturnType<typeof tournament.teamById>>,
) {
function canEditSubbedOutForTeam(team: MatchPageTeam) {
if (data.matchIsOver) return false;
if (team.members.length <= tournament.minMembersPerTeam) return false;
if (team.memberUserIds.length <= tournament.minMembersPerTeam) return false;
const isMemberOfTeam = team.members.some((m) => m.userId === user?.id);
const isMemberOfTeam = user ? team.memberUserIds.includes(user.id) : false;
return isMemberOfTeam || tournament.isOrganizer(user);
}
function needsActiveRosterSelection(
team: NonNullable<ReturnType<typeof tournament.teamById>>,
) {
function needsActiveRosterSelection(team: MatchPageTeam) {
if (!canEditSubbedOutForTeam(team)) return false;
return !tournamentTeamToActiveRosterUserIds(
team,
@@ -390,12 +380,12 @@ function TournamentMatchRosterTab({
}
function handleSubbedOutChange(teamId: number, subbedOut: number[]) {
const team = tournament.teamById(teamId);
const team = [teamOne, teamTwo].find((t) => t?.id === teamId);
if (!team) return;
const activeRoster = team.members
.filter((m) => !subbedOut.includes(m.userId))
.map((m) => m.userId);
const activeRoster = team.memberUserIds.filter(
(userId) => !subbedOut.includes(userId),
);
fetcher.submit(
{

View File

@@ -1,7 +1,6 @@
import type { TournamentRoundMaps } from "~/db/tables-json";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import type { ModeWithStage } from "~/modules/in-game-lists/types";
import invariant from "~/utils/invariant";
import { seededRandom } from "~/utils/random";
@@ -24,7 +23,7 @@ export async function executeRoll({
>;
results: Awaited<ReturnType<typeof findResultsByMatchId>>;
tournamentId: number;
teams: [TournamentDataTeam, TournamentDataTeam];
teams: [PickBan.MapPoolTeam, PickBan.MapPoolTeam];
tieBreakerMapPool: ModeWithStage[];
}): Promise<boolean> {
const customFlow = maps.customFlow;

View File

@@ -123,11 +123,16 @@ export async function resolveMatchMapList({
})
: undefined;
const mapPools =
match.mapPickingStyle !== "TO"
? await TournamentTeamRepository.findMapPoolsByTeamIds(teams)
: new Map<number, Array<{ mode: ModeShort; stageId: StageId }>>();
return resolveMapList({
tournamentId: match.tournamentId,
matchId: match.id,
teams,
mapPoolByTeamId: (teamId) => tournament.teamById(teamId)?.mapPool ?? [],
mapPoolByTeamId: (teamId) => mapPools.get(teamId) ?? [],
mapPickingStyle: match.mapPickingStyle,
maps: match.roundMaps,
tieBreakerMapPool: tournament.ctx.tieBreakerMapPool,

View File

@@ -6,15 +6,23 @@ import { chatAccessible } from "~/features/chat/chat-utils";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { isLeagueRoundLocked } from "~/features/tournament/tournament-utils";
import {
isLeagueRoundLocked,
resolveLeagueRoundStartDate,
} from "~/features/tournament/tournament-utils";
import { matchEndedEarly } from "~/features/tournament-bracket/core/engine";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
import {
requireTournamentVisible,
tournamentFromDBCached,
tournamentTeamsFullCached,
} 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 { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import { logger } from "~/utils/logger";
import type { SerializeFrom } from "~/utils/remix";
@@ -36,6 +44,11 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
tournamentId,
user: undefined,
});
requireTournamentVisible({ ctx: tournament.ctx, user });
const teamsFull = await tournamentTeamsFullCached({ tournamentId, user });
const teamFullById = (tournamentTeamId: number) =>
teamsFull.find((team) => team.id === tournamentTeamId);
const match = notFoundIfNullish(
await TournamentMatchRepository.findMatchById(matchId),
@@ -75,8 +88,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
resultsCount: results.length,
});
if (currentStep?.action === "ROLL") {
const teamOne = tournament.teamById(match.opponentOne.id);
const teamTwo = tournament.teamById(match.opponentTwo.id);
const teamOne = teamFullById(match.opponentOne.id);
const teamTwo = teamFullById(match.opponentTwo.id);
if (teamOne && teamTwo) {
const rollExecuted = await executeRoll({
matchId,
@@ -121,8 +134,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
tournamentId,
matchId,
teams: [match.opponentOne.id, match.opponentTwo.id],
mapPoolByTeamId: (teamId) =>
tournament.teamById(teamId)?.mapPool ?? [],
mapPoolByTeamId: (teamId) => teamFullById(teamId)?.mapPool ?? [],
mapPickingStyle: match.mapPickingStyle,
maps: match.roundMaps,
tieBreakerMapPool: tournament.ctx.tieBreakerMapPool,
@@ -165,13 +177,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
tournamentTeamToActiveRosterUserIds(
teamAlpha,
tournament.minMembersPerTeam,
) ?? teamAlpha.members.map((m) => m.userId);
) ?? teamAlpha.memberUserIds;
const teamBravo = tournament.teamById(match.opponentTwo.id)!;
const teamBravoActiveRoster =
tournamentTeamToActiveRosterUserIds(
teamBravo,
tournament.minMembersPerTeam,
) ?? teamBravo.members.map((m) => m.userId);
) ?? teamBravo.memberUserIds;
const playerIds = [...teamAlphaActiveRoster, ...teamBravoActiveRoster];
@@ -206,12 +218,24 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
hasPermsToSeeChat && !chatCodeExpired ? match.chatCode : undefined;
const isParticipant = match.players.some((p) => p.id === user?.id);
const leagueRoundLocked = isLeagueRoundLocked(tournament, match.roundId);
const canJoin =
!matchIsOver &&
match.opponentOne?.id != null &&
match.opponentTwo?.id != null &&
(isParticipant || tournament.isOrganizerOrStreamer(user)) &&
!isLeagueRoundLocked(tournament, match.roundId);
!leagueRoundLocked;
const bracketIdx = tournament.matchIdToBracketIdx(matchId);
const bracket =
typeof bracketIdx === "number" ? tournament.bracketByIdx(bracketIdx) : null;
const leagueRoundStartDate = leagueRoundLocked
? resolveLeagueRoundStartDate(
tournament,
bracket ?? undefined,
match.roundId,
)
: null;
return {
...(await UserCardRepository.findAllByUserIds({
@@ -228,11 +252,35 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
results,
reportedWeapons,
mapList,
teams: [match.opponentOne?.id, match.opponentTwo?.id].flatMap(
(tournamentTeamId) => {
const team = tournamentTeamId ? teamFullById(tournamentTeamId) : null;
return team ? [team] : [];
},
),
matchIsOver,
endedEarly,
noScreen,
chatCode: visibleChatCode,
canJoin,
// the views can't derive these themselves, the layout ships no bracket match data
bracketContext: {
bracketIdx,
bracketType: bracket?.type ?? null,
collectsKos: bracket?.collectsKos ?? false,
groupNumber:
bracket?.data.group.find((group) => group.id === match.groupId)
?.number ?? null,
hasRoundRobin: tournament.bracketsMeta.some(
(meta) => meta.type === "round_robin",
),
names: tournament.matchContextNamesById(matchId),
canBeReopened: tournament.matchCanBeReopened(matchId),
leagueRoundLocked,
leagueRoundStartDate: leagueRoundStartDate
? dateToDatabaseTimestamp(leagueRoundStartDate)
: null,
},
pickBanEventCount: pickBanEvents.length,
pickBanEvents: pickBanEvents.map((e) => ({
type: e.type,

View File

@@ -3,7 +3,6 @@ import { TAB_KEYS } from "~/components/match-page/MatchTabs";
import { resolveRoomPass } from "~/components/match-page/utils";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { isLeagueRoundLocked } from "~/features/tournament/tournament-utils";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import {
@@ -13,7 +12,12 @@ import {
import type { TournamentMatchLoaderData } from "./loaders/to.$id.matches.$mid.server";
import { matchIsLocked, resolveHostingTeam } from "./tournament-match-utils";
export type MatchPageTeam = NonNullable<ReturnType<Tournament["teamById"]>>;
/**
* One of the match's two teams: the tournament wide lite team (with its resolved seed)
* plus the roster and map pool that the match loader ships for these two teams only.
*/
export type MatchPageTeam = NonNullable<ReturnType<Tournament["teamById"]>> &
Pick<TournamentMatchLoaderData["teams"][number], "members" | "mapPool">;
export type MatchTabKey = (typeof TAB_KEYS)[keyof typeof TAB_KEYS];
@@ -54,9 +58,23 @@ export function MatchPageProvider({
const opponentOneId = data.match.opponentOne?.id;
const opponentTwoId = data.match.opponentTwo?.id;
const teamById = (tournamentTeamId: number | null | undefined) => {
if (!tournamentTeamId) return null;
const team = tournament.teamById(tournamentTeamId);
const withRoster = data.teams.find((t) => t.id === tournamentTeamId);
if (!team || !withRoster) return null;
return {
...team,
members: withRoster.members,
mapPool: withRoster.mapPool,
};
};
const teams: [MatchPageTeam | null, MatchPageTeam | null] = [
(opponentOneId ? tournament.teamById(opponentOneId) : null) ?? null,
(opponentTwoId ? tournament.teamById(opponentTwoId) : null) ?? null,
teamById(opponentOneId),
teamById(opponentTwoId),
];
const [teamOne, teamTwo] = teams;
@@ -122,7 +140,7 @@ export function MatchPageProvider({
isPickBanStep,
isAdminEligible:
tournament.isOrganizerOrStreamer(user) && !tournament.ctx.isFinalized,
leagueRoundLocked: isLeagueRoundLocked(tournament, data.match.roundId),
leagueRoundLocked: data.bracketContext.leagueRoundLocked,
lockedForCast,
waitingForPreviousMatch,
});
@@ -252,24 +270,17 @@ function resolveJoinInfo({
const hostingTeam = resolveHostingTeam([teamOne, teamTwo]);
const hasRoundRobin = tournament.brackets.some(
(b) => b.type === "round_robin",
);
const bracketIdx = tournament.brackets.findIndex((b) =>
b.data.match.some((m) => m.id === data.match.id),
);
const bracket = tournament.brackets[bracketIdx];
const bracketMatch = bracket?.data.match.find((m) => m.id === data.match.id);
const group = bracket?.data.group.find((g) => g.id === bracketMatch?.groupId);
const { bracketIdx, bracketType, groupNumber, hasRoundRobin } =
data.bracketContext;
const poolCode = tournament.resolvePoolCode({
hostingTeamId: hostingTeam.id,
groupLetters:
group && bracket?.type === "round_robin"
? groupNumberToLetters(group.number)
typeof groupNumber === "number" && bracketType === "round_robin"
? groupNumberToLetters(groupNumber)
: undefined,
bracketNumber:
hasRoundRobin && bracket?.type !== "round_robin"
hasRoundRobin && bracketType !== "round_robin" && bracketIdx !== null
? bracketIdx + 1
: undefined,
});

View File

@@ -2,6 +2,7 @@ import { sub } from "date-fns";
import { type Insertable, type NotNull, sql, type Transaction } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { ordinal } from "openskill";
import * as R from "remeda";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import type {
@@ -33,13 +34,6 @@ import { updatedCastedMatchesInfo } from "./tournament-utils";
export type FindById = NonNullable<Unwrapped<typeof findById>>;
export async function findById(id: number) {
const isSetAsRanked = await db
.selectFrom("Tournament")
.select("settings")
.where("id", "=", id)
.executeTakeFirst()
.then((row) => row?.settings.isRanked ?? false);
const result = await db
.selectFrom("Tournament")
.innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
@@ -161,9 +155,15 @@ export async function findById(id: number) {
eb
.selectFrom("TournamentTeam")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage as PickupAvatar",
"TournamentTeam.avatarImgId",
"UserSubmittedImage.id",
"PickupAvatar.id",
)
.leftJoin("AllTeam", "AllTeam.id", "TournamentTeam.teamId")
.leftJoin(
"UserSubmittedImage as TeamAvatar",
"AllTeam.avatarImgId",
"TeamAvatar.id",
)
.select(({ eb: innerEb }) => [
"TournamentTeam.id",
@@ -171,51 +171,54 @@ export async function findById(id: number) {
"TournamentTeam.seed",
"TournamentTeam.prefersNotToHost",
"TournamentTeam.droppedOut",
"TournamentTeam.inviteCode",
"TournamentTeam.createdAt",
"TournamentTeam.inviteCode",
"TournamentTeam.activeRosterUserIds",
"TournamentTeam.startingBracketIdx",
"TournamentTeam.abDivision",
"TournamentTeam.avatarImgId",
concatUserSubmittedImagePrefix(
innerEb.ref("UserSubmittedImage.url"),
).as("pickupAvatarUrl"),
concatUserSubmittedImagePrefix(innerEb.ref("TeamAvatar.url")).as(
"teamLogoUrl",
),
concatUserSubmittedImagePrefix(innerEb.ref("PickupAvatar.url")).as(
"pickupAvatarUrl",
),
sql<boolean> /*sql*/`exists(
select 1 from "MapPoolMap"
where "MapPoolMap"."tournamentTeamId" = "TournamentTeam"."id"
)`.as("hasMapPool"),
innerEb
.selectFrom("TournamentTeamMember")
.innerJoin("SeedingSkill", (join) =>
join
.onRef(
"SeedingSkill.userId",
"=",
"TournamentTeamMember.userId",
)
.on(
"SeedingSkill.type",
"=",
sql<
Tables["SeedingSkill"]["type"]
> /*sql*/`case when json_extract("Tournament"."settings", '$.isRanked') = 1 then 'RANKED' else 'UNRANKED' end`,
),
)
.select(({ fn }) =>
fn.avg<number>("SeedingSkill.ordinal").as("v"),
)
.whereRef(
"TournamentTeamMember.tournamentTeamId",
"=",
"TournamentTeam.id",
)
.as("avgSeedingSkillOrdinal"),
jsonArrayFrom(
innerEb
.selectFrom("TournamentTeamMember")
.innerJoin("User", "TournamentTeamMember.userId", "User.id")
.leftJoin("SeedingSkill", (join) =>
join
.onRef("User.id", "=", "SeedingSkill.userId")
.on(
"SeedingSkill.type",
"=",
isSetAsRanked ? "RANKED" : "UNRANKED",
),
)
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.leftJoin("LiveStream", "LiveStream.userId", "User.id")
.select((eb) => [
"User.id as userId",
"User.username",
"User.discordId",
"User.discordAvatar",
"User.customUrl",
"User.country",
"User.twitch",
"SeedingSkill.ordinal",
"PlusTier.tier as plusTier",
.select([
"TournamentTeamMember.userId",
"TournamentTeamMember.role",
"TournamentTeamMember.createdAt",
"TournamentTeamMember.isSub",
sql<string | null> /*sql*/`coalesce(
"TournamentTeamMember"."inGameName",
"User"."inGameName"
)`.as("inGameName"),
"LiveStream.twitch as streamTwitch",
"LiveStream.viewerCount as streamViewerCount",
"LiveStream.thumbnailUrl as streamThumbnailUrl",
customAvatarUrl(eb).as("customAvatarUrl"),
])
.whereRef(
"TournamentTeamMember.tournamentTeamId",
@@ -239,34 +242,6 @@ export async function findById(id: number) {
"TournamentTeam.id",
),
).as("checkIns"),
jsonArrayFrom(
innerEb
.selectFrom("MapPoolMap")
.whereRef(
"MapPoolMap.tournamentTeamId",
"=",
"TournamentTeam.id",
)
.select(["MapPoolMap.stageId", "MapPoolMap.mode"]),
).as("mapPool"),
jsonObjectFrom(
innerEb
.selectFrom("AllTeam")
.leftJoin(
"UserSubmittedImage",
"AllTeam.avatarImgId",
"UserSubmittedImage.id",
)
.whereRef("AllTeam.id", "=", "TournamentTeam.teamId")
.select((eb) => [
"AllTeam.id",
"AllTeam.customUrl",
concatUserSubmittedImagePrefix(
eb.ref("UserSubmittedImage.url"),
).as("logoUrl"),
"AllTeam.deletedAt",
]),
).as("team"),
])
.where("TournamentTeam.tournamentId", "=", id)
.where("TournamentTeam.isPlaceholder", "=", 0)
@@ -290,40 +265,6 @@ export async function findById(id: number) {
.select(["MapPoolMap.mode", "MapPoolMap.stageId"])
.whereRef("MapPoolMap.calendarEventId", "=", "CalendarEvent.id"),
).as("toSetMapPool"),
jsonArrayFrom(
eb
.selectFrom("TournamentStage")
.innerJoin(
"TournamentMatch",
"TournamentMatch.stageId",
"TournamentStage.id",
)
.innerJoin(
"TournamentMatchGameResult",
"TournamentMatch.id",
"TournamentMatchGameResult.matchId",
)
.innerJoin(
"TournamentMatchGameResultParticipant",
"TournamentMatchGameResult.id",
"TournamentMatchGameResultParticipant.matchGameResultId",
)
.select("TournamentMatchGameResultParticipant.userId")
.groupBy("TournamentMatchGameResultParticipant.userId")
.where("TournamentStage.tournamentId", "=", id),
).as("participatedUsers"),
jsonArrayFrom(
eb
.selectFrom("LiveStream")
.select([
"LiveStream.twitch",
"LiveStream.viewerCount",
"LiveStream.thumbnailUrl",
])
.where(
sql<boolean>`"LiveStream"."twitch" IN (SELECT value FROM json_each("Tournament"."castTwitchAccounts"))`,
),
).as("castStreams"),
])
.where("Tournament.id", "=", id)
.$narrowType<{ author: NotNull }>()
@@ -333,19 +274,306 @@ export async function findById(id: number) {
return {
...result,
teams: result.teams.map((team) => ({
teams: result.teams.map(({ members, ...team }) => ({
...team,
members: team.members.map(({ ordinal, ...member }) => member),
avgSeedingSkillOrdinal: nullifyingAvg(
team.members
.map((member) => member.ordinal)
.filter((ordinal) => typeof ordinal === "number"),
),
avgSeedingSkillOrdinal:
typeof team.avgSeedingSkillOrdinal === "number"
? Math.round(team.avgSeedingSkillOrdinal * 100) / 100
: null,
memberUserIds: members.map((member) => member.userId),
ownerUserId:
members.find((member) => member.role === "OWNER")?.userId ?? null,
})),
participatedUsers: result.participatedUsers.map((user) => user.userId),
latestTeamIdByDuplicatedUserId: latestTeamIdByDuplicatedUserId(
result.teams,
),
};
}
/**
* User ids of everyone on multiple teams' rosters mapped to the team they joined
* most recently. Nearly always empty, allowing the teams to drop per member join
* timestamps that only this tiebreak needed.
*/
function latestTeamIdByDuplicatedUserId(
teams: Array<{
id: number;
members: Array<{ userId: number; createdAt: number }>;
}>,
) {
const latestByUserId = new Map<
number,
{ teamId: number; joinedAt: number }
>();
const duplicatedUserIds = new Set<number>();
for (const team of teams) {
for (const member of team.members) {
const existing = latestByUserId.get(member.userId);
if (existing) {
duplicatedUserIds.add(member.userId);
}
if (!existing || member.createdAt > existing.joinedAt) {
latestByUserId.set(member.userId, {
teamId: team.id,
joinedAt: member.createdAt,
});
}
}
}
const result: Record<number, number> = {};
for (const userId of duplicatedUserIds) {
result[userId] = latestByUserId.get(userId)!.teamId;
}
return result;
}
/**
* Live streams of the tournament: streams of checked-in participants and the streams
* of the tournament's cast Twitch accounts. Kept out of {@link findById} so the
* frequently changing stream data does not live in the cached tournament context.
*/
export async function findStreamsByTournamentId(tournamentId: number) {
const [participantStreams, castStreams] = await Promise.all([
db
.selectFrom("LiveStream")
.innerJoin(
"TournamentTeamMember",
"TournamentTeamMember.userId",
"LiveStream.userId",
)
.innerJoin(
"TournamentTeam",
"TournamentTeam.id",
"TournamentTeamMember.tournamentTeamId",
)
.innerJoin("User", "User.id", "LiveStream.userId")
.select((eb) => [
"LiveStream.userId",
"LiveStream.twitch",
"LiveStream.viewerCount",
"LiveStream.thumbnailUrl",
"TournamentTeam.name as teamName",
...commonUserSelect(eb),
])
.where("TournamentTeam.tournamentId", "=", tournamentId)
.where("TournamentTeam.isPlaceholder", "=", 0)
.where("LiveStream.twitch", "is not", null)
.where(({ exists, selectFrom }) =>
exists(
selectFrom("TournamentTeamCheckIn")
.select("TournamentTeamCheckIn.tournamentTeamId")
.whereRef(
"TournamentTeamCheckIn.tournamentTeamId",
"=",
"TournamentTeam.id",
),
),
)
.groupBy("LiveStream.userId")
.$narrowType<{ userId: NotNull; twitch: NotNull }>()
.execute(),
db
.selectFrom("LiveStream")
.select([
"LiveStream.twitch",
"LiveStream.viewerCount",
"LiveStream.thumbnailUrl",
])
.where(
sql<boolean>`"LiveStream"."twitch" IN (SELECT value FROM json_each((SELECT "castTwitchAccounts" FROM "Tournament" WHERE "Tournament"."id" = ${tournamentId})))`,
)
.execute(),
]);
return { participantStreams, castStreams };
}
/** User ids of everyone who played at least one map of the tournament. */
export async function findParticipatedUserIdsById(tournamentId: number) {
const rows = await db
.selectFrom("TournamentStage")
.innerJoin(
"TournamentMatch",
"TournamentMatch.stageId",
"TournamentStage.id",
)
.innerJoin(
"TournamentMatchGameResult",
"TournamentMatch.id",
"TournamentMatchGameResult.matchId",
)
.innerJoin(
"TournamentMatchGameResultParticipant",
"TournamentMatchGameResult.id",
"TournamentMatchGameResultParticipant.matchGameResultId",
)
.select("TournamentMatchGameResultParticipant.userId")
.groupBy("TournamentMatchGameResultParticipant.userId")
.where("TournamentStage.tournamentId", "=", tournamentId)
.execute();
return rows.map((row) => row.userId);
}
export type TeamFull = Unwrapped<typeof findTeamsFullByTournamentId>;
/**
* Full rosters of a tournament's teams: per member profile data, map pools and
* invite codes. Kept out of {@link findById} because the tournament layout ships
* the lite team shape only — views that render rosters load these separately.
*/
export async function findTeamsFullByTournamentId(tournamentId: number) {
const teams = await db
.selectFrom("TournamentTeam")
.innerJoin("Tournament", "Tournament.id", "TournamentTeam.tournamentId")
.leftJoin(
"UserSubmittedImage as PickupAvatar",
"TournamentTeam.avatarImgId",
"PickupAvatar.id",
)
.select((eb) => [
"TournamentTeam.id",
"TournamentTeam.name",
"TournamentTeam.seed",
"TournamentTeam.prefersNotToHost",
"TournamentTeam.droppedOut",
"TournamentTeam.inviteCode",
"TournamentTeam.createdAt",
"TournamentTeam.activeRosterUserIds",
"TournamentTeam.startingBracketIdx",
"TournamentTeam.abDivision",
"TournamentTeam.avatarImgId",
concatUserSubmittedImagePrefix(eb.ref("PickupAvatar.url")).as(
"pickupAvatarUrl",
),
jsonArrayFrom(
eb
.selectFrom("TournamentTeamMember")
.innerJoin("User", "TournamentTeamMember.userId", "User.id")
.leftJoin("SeedingSkill", (join) =>
join
.onRef("User.id", "=", "SeedingSkill.userId")
.on(
"SeedingSkill.type",
"=",
sql<
Tables["SeedingSkill"]["type"]
> /*sql*/`case when json_extract("Tournament"."settings", '$.isRanked') = 1 then 'RANKED' else 'UNRANKED' end`,
),
)
.select((eb) => [
"User.id as userId",
"User.username",
"User.discordId",
"User.discordAvatar",
"User.customUrl",
"User.country",
"SeedingSkill.ordinal",
"TournamentTeamMember.role",
"TournamentTeamMember.createdAt",
"TournamentTeamMember.isSub",
sql<string | null> /*sql*/`coalesce(
"TournamentTeamMember"."inGameName",
"User"."inGameName"
)`.as("inGameName"),
customAvatarUrl(eb).as("customAvatarUrl"),
])
.whereRef(
"TournamentTeamMember.tournamentTeamId",
"=",
"TournamentTeam.id",
)
.orderBy(sql`"TournamentTeamMember"."role" = 'OWNER'`, "desc")
.orderBy("TournamentTeamMember.createdAt", "asc"),
).as("members"),
jsonArrayFrom(
eb
.selectFrom("TournamentTeamCheckIn")
.select([
"TournamentTeamCheckIn.bracketIdx",
"TournamentTeamCheckIn.checkedInAt",
"TournamentTeamCheckIn.isCheckOut",
])
.whereRef(
"TournamentTeamCheckIn.tournamentTeamId",
"=",
"TournamentTeam.id",
),
).as("checkIns"),
jsonArrayFrom(
eb
.selectFrom("MapPoolMap")
.whereRef("MapPoolMap.tournamentTeamId", "=", "TournamentTeam.id")
.select(["MapPoolMap.stageId", "MapPoolMap.mode"]),
).as("mapPool"),
jsonObjectFrom(
eb
.selectFrom("AllTeam")
.leftJoin(
"UserSubmittedImage",
"AllTeam.avatarImgId",
"UserSubmittedImage.id",
)
.whereRef("AllTeam.id", "=", "TournamentTeam.teamId")
.select((eb) => [
"AllTeam.id",
"AllTeam.customUrl",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"logoUrl",
),
"AllTeam.deletedAt",
]),
).as("team"),
])
.where("TournamentTeam.tournamentId", "=", tournamentId)
.where("TournamentTeam.isPlaceholder", "=", 0)
.orderBy("TournamentTeam.seed", "asc")
.orderBy("TournamentTeam.createdAt", "asc")
.orderBy("TournamentTeam.id", "asc")
.execute();
return teams.map((team) => ({
...team,
members: team.members.map(({ ordinal, ...member }) => member),
avgSeedingSkillOrdinal: nullifyingAvg(
team.members
.map((member) => member.ordinal)
.filter((ordinal) => typeof ordinal === "number"),
),
}));
}
/**
* Twitch accounts of the given tournaments' participants who have not dropped out.
* Kept out of {@link findById} since only the live stream sync routine needs them.
*/
export async function findParticipantTwitchAccounts(tournamentIds: number[]) {
if (tournamentIds.length === 0) return [];
return db
.selectFrom("TournamentTeamMember")
.innerJoin(
"TournamentTeam",
"TournamentTeam.id",
"TournamentTeamMember.tournamentTeamId",
)
.innerJoin("User", "User.id", "TournamentTeamMember.userId")
.select([
"TournamentTeam.tournamentId",
"TournamentTeamMember.userId",
"User.twitch",
])
.where("TournamentTeam.tournamentId", "in", tournamentIds)
.where("TournamentTeam.isPlaceholder", "=", 0)
.where("TournamentTeam.droppedOut", "=", 0)
.where("User.twitch", "is not", null)
.$narrowType<{ twitch: NotNull }>()
.execute();
}
/**
* Loads a tournament's rules markdown. Kept out of {@link findById} since it can
* be large and is only needed on the tournament's rules page.
@@ -448,6 +676,24 @@ export function findChildTournamentsForDivCalc(parentTournamentId: number) {
.execute();
}
/**
* Per-user results of a finalized tournament as persisted at finalization time.
* Empty for tournaments that have not been finalized.
*/
export function findResultsByTournamentId(tournamentId: number) {
return db
.selectFrom("TournamentResult")
.select([
"TournamentResult.tournamentTeamId",
"TournamentResult.userId",
"TournamentResult.placement",
"TournamentResult.div",
])
.where("TournamentResult.tournamentId", "=", tournamentId)
.orderBy("TournamentResult.placement", "asc")
.execute();
}
/**
* User ids eligible for a LUTI division placement in the given tournament: they have a result, were
* on a team that did not drop out, and played at least one match.
@@ -1356,14 +1602,9 @@ export async function searchByName({
export function updateTeamSeeds({
tournamentId,
teamIds,
teamsWithMembers,
}: {
tournamentId: number;
teamIds: number[];
teamsWithMembers: Array<{
teamId: number;
members: Array<{ userId: number; username: string }>;
}>;
}) {
return db.transaction().execute(async (trx) => {
await trx
@@ -1380,9 +1621,32 @@ export function updateTeamSeeds({
.execute();
}
const memberRows =
teamIds.length > 0
? await trx
.selectFrom("TournamentTeamMember")
.innerJoin("User", "User.id", "TournamentTeamMember.userId")
.select([
"TournamentTeamMember.tournamentTeamId",
"User.id as userId",
"User.username",
])
.where("TournamentTeamMember.tournamentTeamId", "in", teamIds)
.execute()
: [];
const membersByTeamId = R.groupBy(
memberRows,
(member) => member.tournamentTeamId,
);
const snapshot = JSON.stringify({
savedAt: databaseTimestampNow(),
teams: teamsWithMembers,
teams: teamIds.map((teamId) => ({
teamId,
members: (membersByTeamId[teamId] ?? []).map(
({ userId, username }) => ({ userId, username }),
),
})),
});
await trx
.updateTable("Tournament")

View File

@@ -1,4 +1,4 @@
import type { Transaction } from "kysely";
import type { NotNull, Transaction } from "kysely";
import { sql } from "kysely";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
@@ -930,6 +930,37 @@ export function findByInviteCode(inviteCode: string) {
.executeTakeFirst();
}
/** Map pools of the given tournament teams, keyed by tournament team id. */
export async function findMapPoolsByTeamIds(tournamentTeamIds: number[]) {
const rows = await db
.selectFrom("MapPoolMap")
.select([
"MapPoolMap.tournamentTeamId",
"MapPoolMap.stageId",
"MapPoolMap.mode",
])
.where("MapPoolMap.tournamentTeamId", "in", tournamentTeamIds)
.$narrowType<{ tournamentTeamId: NotNull }>()
.execute();
const result = new Map<
number,
Array<{ mode: ModeShort; stageId: StageId }>
>();
for (const row of rows) {
const existing = result.get(row.tournamentTeamId);
if (existing) {
existing.push({ mode: row.mode, stageId: row.stageId });
} else {
result.set(row.tournamentTeamId, [
{ mode: row.mode, stageId: row.stageId },
]);
}
}
return result;
}
export async function findRecentlyPlayedMapsByIds({
teamIds,
limit = 5,

View File

@@ -52,7 +52,7 @@ export const action: ActionFunction = async ({ params, url }) => {
(team) => team.id === leanTeam.id,
);
const previousTeam = tournament.ctx.teams.find((team) =>
team.members.some((member) => member.userId === user.id),
team.memberUserIds.includes(user.id),
);
errorToastIfFalsy(

View File

@@ -146,7 +146,7 @@ export const action: ActionFunction = async ({ request, params }) => {
case "DELETE_TEAM_MEMBER": {
errorToastIfFalsy(ownTeam, "You are not registered to this tournament");
errorToastIfFalsy(
ownTeam.members.some((member) => member.userId === data.userId),
ownTeam.memberUserIds.includes(data.userId),
"User is not in your team",
);
errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself");
@@ -155,7 +155,7 @@ export const action: ActionFunction = async ({ request, params }) => {
// and then having members kicked without it affecting the checking in status
errorToastIfFalsy(
!ownTeamCheckedIn ||
ownTeam.members.length > tournament.minMembersPerTeam,
ownTeam.memberUserIds.length > tournament.minMembersPerTeam,
"Can't kick a member after checking in",
);
@@ -249,8 +249,8 @@ export const action: ActionFunction = async ({ request, params }) => {
}
case "ADD_PLAYER": {
errorToastIfFalsy(
tournament.ctx.teams.every((team) =>
team.members.every((member) => member.userId !== data.userId),
tournament.ctx.teams.every(
(team) => !team.memberUserIds.includes(data.userId),
),
"User is already in a team",
);
@@ -329,11 +329,11 @@ export const action: ActionFunction = async ({ request, params }) => {
await TournamentTeamRepository.deleteById(ownTeam.id);
for (const member of ownTeam.members) {
for (const userId of ownTeam.memberUserIds) {
ShowcaseTournaments.removeFromCached({
tournamentId,
type: "participant",
userId: member.userId,
userId,
});
ShowcaseTournaments.updateCachedTournamentTeamCount({

View File

@@ -4,7 +4,7 @@ import { Avatar } from "~/components/Avatar";
import { ModeImage, StageImage } from "~/components/Image";
import type { Tables } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
import { userPage } from "~/utils/urls";
import { accountCreatedInTheLastSixMonths } from "~/utils/users";
import { useTournament, useTournamentFriendCodes } from "../routes/to.$id";
@@ -18,7 +18,7 @@ export function TeamWithRoster({
teamPageUrl,
activePlayers,
}: {
team: TournamentDataTeam;
team: TournamentTeamFull;
mapPool?: Array<Pick<Tables["MapPoolMap"], "stageId" | "mode">> | null;
seed?: number;
bracketLabel?: string;
@@ -29,14 +29,12 @@ export function TeamWithRoster({
const tournament = useTournament();
const friendCodes = useTournamentFriendCodes();
const teamLogoSrc = tournament.tournamentTeamLogoSrc(team);
return (
<div>
<div className={styles.teamWithRoster}>
<div className={styles.teamWithRosterName}>
<div className="stack horizontal sm justify-end items-end">
<Avatar size="xxs" url={teamLogoSrc} identiconInput={team.name} />
<Avatar size="xxs" url={team.logoUrl} identiconInput={team.name} />
{seed ? (
<div className={styles.teamWithRosterSeed}>
{bracketLabel ? `${bracketLabel} ` : null}#{seed}

View File

@@ -63,13 +63,19 @@ const PRIORITY_ORDER: NavItemKey[] = [
export function TournamentNav({
tournament,
streamsCount,
hasChildTournaments,
}: {
tournament: Tournament;
streamsCount: number;
hasChildTournaments: boolean;
}) {
const { t } = useTranslation(["tournament"]);
const navItems = useNavItems({ tournament, hasChildTournaments });
const navItems = useNavItems({
tournament,
streamsCount,
hasChildTournaments,
});
const { visibleCount, containerRef, measureRef } = useNavOverflow(
navItems.length,
);
@@ -145,9 +151,11 @@ export function TournamentNav({
function useNavItems({
tournament,
streamsCount,
hasChildTournaments,
}: {
tournament: Tournament;
streamsCount: number;
hasChildTournaments: boolean;
}): NavItem[] {
const { t } = useTranslation(["tournament"]);
@@ -212,7 +220,7 @@ function useNavItems({
items.streams = {
key: "streams",
label: t("tournament:nav.streams", {
count: tournament.streams.length,
count: streamsCount,
}),
to: "streams",
icon: <Tv />,

View File

@@ -15,10 +15,6 @@ export function TournamentStream({
withThumbnail?: boolean;
}) {
const tournament = useTournament();
const team = tournament.ctx.teams.find((team) =>
team.members.some((m) => m.userId === stream.userId),
);
const user = team?.members.find((m) => m.userId === stream.userId);
return (
<div
@@ -41,14 +37,14 @@ export function TournamentStream({
</a>
) : null}
<div className="stack md horizontal justify-between">
{user && team ? (
{stream.user ? (
<div className={styles.streamUserContainer}>
<Avatar size="xxs" user={user} /> {user.username}
<Avatar size="xxs" user={stream.user} /> {stream.user.username}
<span
className={clsx("text-theme-secondary", styles.streamTeamName)}
title={team.name}
title={stream.teamName ?? undefined}
>
{team.name}
{stream.teamName}
</span>
</div>
) : (

View File

@@ -154,6 +154,74 @@ export function matchesPlayed({
});
}
type PersistedResultRow = {
tournamentTeamId: number;
userId: number;
placement: number;
div: string | null;
};
/**
* Standings of a finalized tournament reconstructed from the per-user results persisted at
* finalization, instead of recomputing them from bracket match data. Returns null when the
* persisted rows cannot back the standings (no rows, a team no longer in the tournament
* context, or a division label the progression no longer produces) so the caller can fall
* back to {@link tournamentStandings}.
*/
export function standingsFromPersistedResults({
tournament,
results,
}: {
tournament: Tournament;
results: PersistedResultRow[];
}): TournamentStandingsResult | null {
if (results.length === 0) return null;
const standings: Array<Standing & { div: string | null }> = [];
for (const rows of Object.values(
R.groupBy(results, (row) => row.tournamentTeamId),
)) {
const team = tournament.teamById(rows[0].tournamentTeamId);
if (!team) return null;
standings.push({
team: { ...team, memberUserIds: rows.map((row) => row.userId) },
placement: rows[0].placement,
div: rows[0].div,
});
}
const sorted = R.sortBy(
standings,
(standing) => standing.placement,
(standing) => standing.team.seed ?? Number.POSITIVE_INFINITY,
);
if (sorted.every((standing) => standing.div === null)) {
return { type: "single", standings: sorted };
}
const progression = tournament.ctx.settings.bracketProgression;
const divs = Progression.hasAbDivisionsFinals(progression)
? ["A", "B"]
: Progression.startingBrackets(progression).map((bracketIdx) =>
getBracketProgressionLabel(bracketIdx, progression),
);
const hasUnknownDiv = sorted.some(
(standing) => standing.div === null || !divs.includes(standing.div),
);
if (hasUnknownDiv) return null;
return {
type: "multi",
standings: divs.map((div) => ({
div,
standings: sorted.filter((standing) => standing.div === div),
})),
};
}
/**
* Computes the standings for a given tournament by aggregating results from relevant brackets.
*

View File

@@ -0,0 +1,68 @@
import type { TournamentLoaderData } from "../loaders/to.$id.server";
/**
* Team keys that {@link serializeTournamentLoaderData} drops when null and
* {@link parseTournamentLoaderData} restores. Null for most teams, so dropping
* them trims the layout payload of big tournaments by a few gzipped kilobytes.
*/
const NULL_COMPACTED_TEAM_KEYS = [
"seed",
"inviteCode",
"logoUrl",
"activeRosterUserIds",
"startingBracketIdx",
"abDivision",
"avgSeedingSkillOrdinal",
"ownerUserId",
] as const;
type LayoutTeam = TournamentLoaderData["tournament"]["ctx"]["teams"][number];
/**
* Serializes the tournament layout loader data, omitting null-valued team keys.
* Counterpart of {@link parseTournamentLoaderData}, the only way the payload
* should be read.
*/
export function serializeTournamentLoaderData(
data: TournamentLoaderData,
): string {
// JSON.stringify so that we skip expensive rr7 data serialization (hot path loader)
return JSON.stringify({
...data,
tournament: {
...data.tournament,
ctx: {
...data.tournament.ctx,
teams: data.tournament.ctx.teams.map(compactTeamNulls),
},
},
});
}
/**
* Parses the tournament layout loader data serialized by
* {@link serializeTournamentLoaderData}, restoring the null team keys it omitted.
*/
export function parseTournamentLoaderData(raw: string): TournamentLoaderData {
const data = JSON.parse(raw) as TournamentLoaderData;
for (const team of data.tournament.ctx.teams) {
const teamRecord = team as Record<string, unknown>;
for (const key of NULL_COMPACTED_TEAM_KEYS) {
teamRecord[key] ??= null;
}
}
return data;
}
function compactTeamNulls(team: LayoutTeam) {
const compacted: Record<string, unknown> = { ...team };
for (const key of NULL_COMPACTED_TEAM_KEYS) {
if (compacted[key] === null) {
delete compacted[key];
}
}
return compacted as unknown as LayoutTeam;
}

View File

@@ -2,6 +2,10 @@ import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import {
requireTournamentVisible,
tournamentDataCached,
} from "~/features/tournament-bracket/core/Tournament.server";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
@@ -12,6 +16,9 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
schema: idObject,
});
const { ctx } = await tournamentDataCached({ tournamentId });
requireTournamentVisible({ ctx, user });
const description =
await TournamentRepository.findDescriptionById(tournamentId);

View File

@@ -3,7 +3,11 @@ import { getUser } from "~/features/auth/core/user.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server";
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
import {
requireTournamentVisible,
tournamentFromDBCached,
tournamentTeamsFullCached,
} from "~/features/tournament-bracket/core/Tournament.server";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
@@ -17,10 +21,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
});
const tournament = await tournamentFromDBCached({ tournamentId, user });
const ownTeam = tournament.ownedTeamByUser(user);
requireTournamentVisible({ ctx: tournament.ctx, user });
if (!ownTeam) {
const teamMemberOf = tournament.teamMemberOfByUser(user);
if (!teamMemberOf) {
return {
ownTeam: null,
mapPool: null,
friendPlayers: null,
teams: await TeamRepository.findAllMemberOfByUserId(user.id),
@@ -31,8 +38,14 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
};
}
const ownTeam =
(await tournamentTeamsFullCached({ tournamentId, user })).find(
(team) => team.id === teamMemberOf.id,
) ?? null;
return {
mapPool: ownTeam.mapPool,
ownTeam,
mapPool: ownTeam?.mapPool ?? null,
friendPlayers: await SQGroupRepository.findFriendsAndTeammates(user.id),
teams: await TeamRepository.findAllMemberOfByUserId(user.id),
isSaved: false,

View File

@@ -0,0 +1,76 @@
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import type { Standing } from "~/features/tournament-bracket/core/Bracket";
import {
requireTournamentVisible,
tournamentSharedCached,
tournamentTeamsFullCached,
} from "~/features/tournament-bracket/core/Tournament.server";
import type { SerializeFrom } from "~/utils/remix";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
import * as Standings from "../core/Standings";
export type TournamentResultsLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id: tournamentId } = parseParams({ params, schema: idObject });
const tournament = await tournamentSharedCached(tournamentId);
requireTournamentVisible({ ctx: tournament.ctx, user: getUser() });
const teams = await tournamentTeamsFullCached({ tournamentId });
const rosterByTeamId = new Map(
teams.map((team) => [
team.id,
team.members.map((member) => ({
userId: member.userId,
username: member.username,
country: member.country,
})),
]),
);
const toRow = (standings: Standing[]) => (standing: Standing) => ({
placement: standing.placement,
spr: Standings.calculateSPR({ standings, teamId: standing.team.id }),
team: {
id: standing.team.id,
name: standing.team.name,
seed: standing.team.seed,
logoUrl: standing.team.logoUrl,
},
roster: (rosterByTeamId.get(standing.team.id) ?? []).filter((member) =>
standing.team.memberUserIds.includes(member.userId),
),
matches: Standings.matchesPlayed({ tournament, teamId: standing.team.id }),
});
const persistedStandings = tournament.ctx.isFinalized
? Standings.standingsFromPersistedResults({
tournament,
results:
await TournamentRepository.findResultsByTournamentId(tournamentId),
})
: null;
const result =
persistedStandings ?? Standings.tournamentStandings(tournament);
return {
standings:
result.type === "single"
? {
type: "single" as const,
standings: result.standings.map(toRow(result.standings)),
}
: {
type: "multi" as const,
standings: result.standings.map(({ div, standings }) => ({
div,
standings: standings.map(toRow(standings)),
})),
},
};
};

View File

@@ -1,5 +1,10 @@
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import {
requireTournamentVisible,
tournamentDataCached,
} from "~/features/tournament-bracket/core/Tournament.server";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
@@ -9,6 +14,9 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
schema: idObject,
});
const { ctx } = await tournamentDataCached({ tournamentId });
requireTournamentVisible({ ctx, user: getUser() });
return {
rules: await TournamentRepository.findRulesById(tournamentId),
};

View File

@@ -6,15 +6,22 @@ import {
LEAGUES,
TOURNAMENT,
} from "~/features/tournament/tournament-constants";
import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server";
import { isTournamentOrganizer } from "~/features/tournament-bracket/core/Tournament";
import {
bracketsMetaCached,
requireTournamentVisible,
type TournamentLayoutData,
tournamentDataCached,
} from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentMatchVodRepository from "~/features/tournament-bracket/TournamentMatchVodRepository.server";
import { databaseTimestampToDate } from "~/utils/dates";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
import { serializeTournamentLoaderData } from "../core/layout-payload";
export type TournamentLoaderData = {
tournament: Awaited<ReturnType<typeof tournamentDataCached>>;
streamingParticipants: number[];
tournament: TournamentLayoutData;
/** Count for the streams tab badge; the streams view loads the actual streams itself. */
streamsCount: number;
hasChildTournaments: boolean;
friendCodes:
@@ -36,6 +43,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
});
const tournament = await tournamentDataCached({ tournamentId, user });
requireTournamentVisible({ ctx: tournament.ctx, user });
const friendCodeVisibilityDays = tournament.ctx.parentTournamentId ? 120 : 30;
const tournamentStartedRecently = isAfter(
@@ -51,18 +59,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
tournament.ctx.organization?.members.some(
(m) => m.userId === user?.id && m.role === "ADMIN",
);
const isTournamentOrganizer =
isTournamentAdmin ||
tournament.ctx.staff.some(
(s) => s.role === "ORGANIZER" && s.id === user?.id,
) ||
tournament.ctx.organization?.members.some(
(m) => m.userId === user?.id && m.role === "ORGANIZER",
);
if (tournament.ctx.settings.isDraft && !isTournamentOrganizer) {
throw new Response(null, { status: 404 });
}
const showFriendCodes = tournamentStartedRecently && isTournamentAdmin;
const isLeagueSignup = Object.values(LEAGUES)
@@ -79,15 +75,19 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
subDays(new Date(), TOURNAMENT.VOD_VISIBILITY_DAYS),
);
// skip expensive rr7 data serialization (hot path loader)
return JSON.stringify({
tournament,
return serializeTournamentLoaderData({
tournament: {
ctx: tournament.ctx,
bracketsMeta: await bracketsMetaCached(tournamentId),
},
streamsCount: tournament.streams.length,
hasChildTournaments,
friendCodes: showFriendCodes
? await TournamentRepository.findFriendCodesByTournamentId(tournamentId)
: undefined,
preparedMaps:
isTournamentOrganizer && !tournament.ctx.isFinalized
isTournamentOrganizer({ ctx: tournament.ctx, user }) &&
!tournament.ctx.isFinalized
? await TournamentRepository.findPreparedMapsById(tournamentId)
: undefined,
vods: showVods

View File

@@ -0,0 +1,21 @@
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import {
fetchTournamentStreams,
requireTournamentVisible,
tournamentSharedCached,
} from "~/features/tournament-bracket/core/Tournament.server";
import type { SerializeFrom } from "~/utils/remix";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
export type TournamentStreamsLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id: tournamentId } = parseParams({ params, schema: idObject });
const tournament = await tournamentSharedCached(tournamentId);
requireTournamentVisible({ ctx: tournament.ctx, user: getUser() });
return { streams: await fetchTournamentStreams(tournamentId) };
};

View File

@@ -1,23 +1,38 @@
import type { LoaderFunctionArgs } from "react-router";
import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server";
import { getUser } from "~/features/auth/core/user.server";
import {
requireTournamentVisible,
tournamentDataCached,
tournamentSharedCached,
tournamentTeamsFullCached,
} from "~/features/tournament-bracket/core/Tournament.server";
import { tournamentTeamPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import invariant from "~/utils/invariant";
import type { SerializeFrom } from "~/utils/remix";
import { parseParams } from "~/utils/remix.server";
import * as Standings from "../core/Standings";
import {
type AllRoundsItem,
tournamentTeamSets,
winCounts,
} from "../core/sets.server";
export type TournamentTeamLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id: tournamentId, tid: tournamentTeamId } = parseParams({
params,
schema: tournamentTeamPageParamsSchema,
});
const user = getUser();
const tournament = await tournamentDataCached({ tournamentId });
const team = tournament?.ctx.teams.find((t) => t.id === tournamentTeamId);
requireTournamentVisible({ ctx: tournament.ctx, user });
const team = (await tournamentTeamsFullCached({ tournamentId, user })).find(
(t) => t.id === tournamentTeamId,
);
const tournamentHasStarted = (tournament?.data.stage.length ?? 0) > 0;
if (!team || (tournamentHasStarted && team.checkIns.length === 0)) {
throw new Response(null, { status: 404 });
@@ -42,9 +57,47 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const sets = tournamentTeamSets({ sets: setHistory, allRounds });
const fullTournament = await tournamentSharedCached(tournamentId);
const standingsResult = Standings.tournamentStandings(fullTournament);
const overallStandings = Standings.flattenStandings(standingsResult);
const undergroundBracketIdx = fullTournament.bracketsMeta.find(
(bracket) => bracket.isUnderground,
)?.idx;
return {
tournamentTeamId,
sets,
team,
activePlayers:
sets.length > 0
? fullTournament.participatedPlayerUserIdsByTeamId(tournamentTeamId)
: undefined,
tournamentName: tournament.ctx.name,
sets: sets.map((set) => ({
...set,
// the layout ships no bracket match data, so the names can't be derived in the view
matchContextNames: fullTournament.matchContextNamesById(
set.tournamentMatchId,
),
})),
winCounts: winCounts(sets),
division:
standingsResult.type === "multi"
? (standingsResult.standings.find((div) =>
div.standings.some(
(standing) => standing.team.id === tournamentTeamId,
),
)?.div ?? null)
: null,
placement: overallStandings.find(
(standing) => standing.team.id === tournamentTeamId,
)?.placement,
undergroundPlacement:
typeof undergroundBracketIdx === "number"
? fullTournament
.bracketByIdx(undergroundBracketIdx)
?.standings.find(
(standing) => standing.team.id === tournamentTeamId,
)?.placement
: undefined,
};
};

View File

@@ -0,0 +1,43 @@
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import {
requireTournamentVisible,
tournamentSharedCached,
tournamentTeamsFullInSeedOrder,
} from "~/features/tournament-bracket/core/Tournament.server";
import type { SerializeFrom } from "~/utils/remix";
import { paginate, parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
import { tournamentTeamsSearchParams } from "../tournament-search-params";
export type TournamentTeamsLoaderData = SerializeFrom<typeof loader>;
/** How many rosters are rendered (and shipped) per page of the teams tab. */
const TEAMS_PAGE_SIZE = 50;
export const loader = async ({ request, params, url }: LoaderFunctionArgs) => {
const user = getUser();
const { id: tournamentId } = parseParams({ params, schema: idObject });
const { page } = tournamentTeamsSearchParams.parse(request);
const tournament = await tournamentSharedCached(tournamentId);
requireTournamentVisible({ ctx: tournament.ctx, user });
const teams = await tournamentTeamsFullInSeedOrder({ tournament, user });
const { currentPage, pagesCount } = paginate({
url,
page,
pageSize: TEAMS_PAGE_SIZE,
totalCount: teams.length,
});
return {
teams: teams.slice(
(currentPage - 1) * TEAMS_PAGE_SIZE,
currentPage * TEAMS_PAGE_SIZE,
),
currentPage,
pagesCount,
};
};

View File

@@ -1,5 +1,9 @@
import { type LoaderFunctionArgs, redirect } from "react-router";
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
import { getUser } from "~/features/auth/core/user.server";
import {
requireTournamentVisible,
tournamentFromDBCached,
} from "~/features/tournament-bracket/core/Tournament.server";
import { parseParams } from "~/utils/remix.server";
import {
tournamentBracketsPage,
@@ -18,6 +22,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
tournamentId,
user: undefined,
});
requireTournamentVisible({ ctx: tournament.ctx, user: getUser() });
if (!tournament.hasStarted) {
return redirect(tournamentInfoPage(tournamentId));

View File

@@ -7,7 +7,6 @@ import { containerClassName } from "~/components/Main";
import { Markdown } from "~/components/Markdown";
import { TierPill } from "~/components/TierPill";
import * as Seasons from "~/features/mmr/core/Seasons";
import type { TournamentData } from "~/features/tournament-bracket/core/Tournament.server";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { removeMarkdown } from "~/utils/strings";
@@ -19,6 +18,7 @@ import {
TournamentHeader,
TournamentHeaderActions,
} from "../components/TournamentHeader";
import { parseTournamentLoaderData } from "../core/layout-payload";
import { loader } from "../loaders/to.$id.info.server";
import { bracketProgressionLabel } from "../tournament-utils";
import { useTournament } from "./to.$id";
@@ -27,8 +27,10 @@ import styles from "./to.$id.info.module.css";
export { action, loader };
export const meta: MetaFunction<typeof loader> = (args) => {
const tournamentData = JSON.parse(args.matches[1].loaderData as any)
?.tournament as TournamentData | undefined;
const rawLayoutData = args.matches[1].loaderData as string | undefined;
const tournamentData = rawLayoutData
? parseTournamentLoaderData(rawLayoutData).tournament
: undefined;
if (!tournamentData) return [];
return metaTags({

View File

@@ -17,7 +17,7 @@ import { Config } from "~/config";
import { useUser } from "~/features/auth/core/user";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { ModeMapPoolPicker } from "~/features/settings/components/ModeMapPoolPicker";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
import { FormField } from "~/form/FormField";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
@@ -159,7 +159,9 @@ function RegistrationForms({ readOnly = false }: { readOnly?: boolean }) {
return <ReadOnlyRegistrationForms />;
}
const ownTeam = tournament.ownedTeamByUser(user);
const ownTeam = tournament.ownedTeamByUser(user)
? (data?.ownTeam ?? null)
: null;
const ownTeamCheckedIn = Boolean(ownTeam && ownTeam.checkIns.length > 0);
const hasFriendCodeSet = Boolean(user?.friendCode);
@@ -219,10 +221,10 @@ function RegistrationForms({ readOnly = false }: { readOnly?: boolean }) {
}
function ReadOnlyRegistrationForms() {
const user = useUser();
const data = useLoaderData<TournamentRegisterPageLoader>();
const tournament = useTournament();
const team = tournament.teamMemberOfByUser(user);
const team = data?.ownTeam;
if (!team) return null;
const checkedIn = team.checkIns.length > 0;
@@ -453,7 +455,7 @@ function TeamInfo({
canUnregister,
readOnly = false,
}: {
ownTeam?: TournamentDataTeam | null;
ownTeam?: TournamentTeamFull | null;
canUnregister: boolean;
readOnly?: boolean;
}) {
@@ -638,7 +640,7 @@ function FillRoster({
ownTeamCheckedIn,
readOnly = false,
}: {
ownTeam: TournamentDataTeam;
ownTeam: TournamentTeamFull;
ownTeamCheckedIn: boolean;
readOnly?: boolean;
}) {
@@ -673,8 +675,8 @@ function FillRoster({
const playersAvailableToDirectlyAdd = (() => {
if (readOnly) return [];
return (data?.friendPlayers?.friends ?? []).filter((user) => {
const isNotInTeam = tournament.ctx.teams.every((team) =>
team.members.every((member) => member.userId !== user.id),
const isNotInTeam = tournament.ctx.teams.every(
(team) => !team.memberUserIds.includes(user.id),
);
const hasInGameNameIfNeeded =
@@ -855,7 +857,7 @@ function DirectlyAddPlayerSelect({
);
}
function DeleteMember({ members }: { members: TournamentDataTeam["members"] }) {
function DeleteMember({ members }: { members: TournamentTeamFull["members"] }) {
const { t } = useTranslation(["tournament", "common"]);
const id = React.useId();
const fetcher = useFetcher();
@@ -903,7 +905,7 @@ function CounterPickMapPoolPicker({
mapPool,
}: {
readOnly?: boolean;
mapPool?: NonNullable<TournamentDataTeam["mapPool"]>;
mapPool?: NonNullable<TournamentTeamFull["mapPool"]>;
}) {
const { t } = useTranslation(["common", "game-misc", "tournament"]);
const tournament = useTournament();

View File

@@ -3,7 +3,7 @@ import { differenceInDays } from "date-fns";
import { ShieldMinus } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Link, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import {
@@ -16,20 +16,30 @@ import { Flag } from "~/components/Flag";
import { InfoPopover } from "~/components/InfoPopover";
import { Placement } from "~/components/Placement";
import { Table } from "~/components/Table";
import type { Standing } from "~/features/tournament-bracket/core/Bracket";
import { useSpoilerFree } from "~/hooks/useSpoilerFree";
import {
SPR_INFO_URL,
tournamentMatchPage,
tournamentTeamPage,
} from "~/utils/urls";
import * as Standings from "../core/Standings";
import type { TournamentResultsLoaderData } from "../loaders/to.$id.results.server";
import styles from "../tournament.module.css";
import { TOURNAMENT } from "../tournament-constants";
import { useTournament } from "./to.$id";
export { loader } from "../loaders/to.$id.results.server";
type ResultsStanding =
TournamentResultsLoaderData["standings"]["standings"] extends Array<infer T>
? T extends { standings: Array<infer U> }
? U
: T
: never;
export default function TournamentResultsPage() {
const { t } = useTranslation(["common"]);
const { standings: standingsResult } =
useLoaderData<TournamentResultsLoaderData>();
const tournament = useTournament();
const { isCensored, reveal } = useSpoilerFree();
@@ -53,8 +63,6 @@ export default function TournamentResultsPage() {
);
}
const standingsResult = Standings.tournamentStandings(tournament);
if (standingsResult.type === "single") {
if (standingsResult.standings.length === 0) {
return (
@@ -95,7 +103,7 @@ export default function TournamentResultsPage() {
);
}
function ResultsTable({ standings }: { standings: Standing[] }) {
function ResultsTable({ standings }: { standings: ResultsStanding[] }) {
const tournament = useTournament();
let lastRenderedPlacement = 0;
@@ -141,12 +149,7 @@ function ResultsTable({ standings }: { standings: Standing[] }) {
rowDarkerBg = !rowDarkerBg;
}
const teamLogoSrc = tournament.tournamentTeamLogoSrc(standing.team);
const spr = Standings.calculateSPR({
standings,
teamId: standing.team.id,
});
const teamLogoSrc = standing.team.logoUrl;
return (
<tr
@@ -179,7 +182,7 @@ function ResultsTable({ standings }: { standings: Standing[] }) {
</Link>
</td>
<td>
{standing.team.members.map((player) => (
{standing.roster.map((player) => (
<div
key={player.userId}
className="stack xxs horizontal items-center"
@@ -194,12 +197,12 @@ function ResultsTable({ standings }: { standings: Standing[] }) {
<td className="text-sm">{standing.team.seed}</td>
{tournament.ctx.isFinalized ? (
<td className="text-sm">
{spr > 0 ? "+" : ""}
{spr}
{standing.spr > 0 ? "+" : ""}
{standing.spr}
</td>
) : null}
<td>
<MatchHistoryRow teamId={standing.team.id} />
<MatchHistoryRow matches={standing.matches} />
</td>
</tr>
);
@@ -209,14 +212,11 @@ function ResultsTable({ standings }: { standings: Standing[] }) {
);
}
function MatchHistoryRow({ teamId }: { teamId: number }) {
const tournament = useTournament();
const teamMatches = Standings.matchesPlayed({
tournament,
teamId,
});
function MatchHistoryRow({
matches: teamMatches,
}: {
matches: ResultsStanding["matches"];
}) {
return (
<div className="stack horizontal xs">
{teamMatches.map((match, i) => {

Some files were not shown because too many files have changed in this diff Show More