Block tournament LFG roster changes after registration closes

This commit is contained in:
Kalle
2026-10-23 18:44:03 +03:00
parent 8bcbbf416d
commit 428efeea5b
5 changed files with 378 additions and 1 deletions

View File

@@ -28,6 +28,8 @@ type Options = {
isCheckedIn?: boolean;
/** Is the team looking for more players on the tournament's LFG page? */
isLooking?: boolean;
/** Teams this team has liked in the LFG matchmaking view. */
likedTeamIds?: number[];
/** The division (starting bracket) the organizer placed the team in, as the seeds page does. */
startingBracketIdx?: number;
};
@@ -88,7 +90,7 @@ export const { create } = defineFactory({
},
applyOptions: async (
team,
{ isCheckedIn, isLooking, startingBracketIdx }: Options,
{ isCheckedIn, isLooking, likedTeamIds, startingBracketIdx }: Options,
) => {
if (typeof startingBracketIdx === "number") {
await TournamentTeamRepository.updateStartingBrackets([
@@ -108,5 +110,12 @@ export const { create } = defineFactory({
chatRoomExpiresAt: addDays(new Date(), 7),
});
}
for (const targetTeamId of likedTeamIds ?? []) {
await TournamentLFGRepository.insertLike({
likerTeamId: team.id,
targetTeamId,
});
}
},
});

View File

@@ -36,8 +36,10 @@ const mergeTeams = (args: {
survivingTeamId: number;
otherTeamId: number;
maxGroupSize: number;
requireInGameNames?: boolean;
}) =>
TournamentLFGRepository.mergeTeams({
requireInGameNames: false,
...args,
chatRoomExpiresAt: addDays(new Date(), 7),
});
@@ -395,6 +397,34 @@ describe("mergeTeams", () => {
);
});
test("fills in the in-game names of merged members when required", async () => {
const tournament = await createTournament();
const playerOne = await UserFactory.create({
profile: { inGameName: "Player#1111" },
});
const playerTwo = await UserFactory.create({
profile: { inGameName: "Player#2222" },
});
const team1 = await createPlaceholder(tournament.id, playerOne.id);
const team2 = await createPlaceholder(tournament.id, playerTwo.id);
await mergeTeams({
survivingTeamId: team1.id,
otherTeamId: team2.id,
maxGroupSize: 4,
requireInGameNames: true,
});
const members = await db
.selectFrom("TournamentTeamMember")
.select("inGameName")
.where("tournamentTeamId", "=", team1.id)
.execute();
expect(new Set(members.map((member) => member.inGameName))).toEqual(
new Set(["Player#1111", "Player#2222"]),
);
});
test("deletes the other team's chat room", async () => {
const tournament = await createTournament();
const team1 = await createPlaceholder(tournament.id, users.id(1));

View File

@@ -128,11 +128,14 @@ export function mergeTeams({
survivingTeamId,
otherTeamId,
maxGroupSize,
requireInGameNames,
chatRoomExpiresAt,
}: {
survivingTeamId: number;
otherTeamId: number;
maxGroupSize: number;
/** Fills the in-game name of members who don't have one yet, as registering does. */
requireInGameNames: boolean;
chatRoomExpiresAt: Date;
}): Promise<number[]> {
return db.transaction().execute(async (trx) => {
@@ -156,6 +159,20 @@ export function mergeTeams({
.execute();
}
if (requireInGameNames) {
await trx
.updateTable("TournamentTeamMember")
.set((eb) => ({
inGameName: eb
.selectFrom("User")
.select("User.inGameName")
.whereRef("User.id", "=", "TournamentTeamMember.userId"),
}))
.where("TournamentTeamMember.tournamentTeamId", "=", survivingTeamId)
.where("TournamentTeamMember.inGameName", "is", null)
.execute();
}
await deleteLikesByTeamId(survivingTeamId, trx);
await trx

View File

@@ -0,0 +1,273 @@
import { addDays, subDays } from "date-fns";
import { beforeEach, describe, expect, test, vi } from "vitest";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentLFGTeamFactory from "~/db/seed/factories/TournamentLFGTeamFactory";
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { assertResponseErrored, wrappedAction } from "~/utils/Test";
import * as TournamentLFGRepository from "../TournamentLFGRepository.server";
import type { lookingSchema } from "../tournament-lfg-schemas";
import { action as lookingAction } from "./to.$id.looking.server";
// likes and accepts notify the other group; not under test here
vi.mock("~/features/notifications/core/notify.server", () => ({
notify: () => Promise.resolve(),
}));
const looking = wrappedAction<typeof lookingSchema>({ action: lookingAction });
const users = UserFactory.pool();
const inAWeek = () => dateToDatabaseTimestamp(addDays(new Date(), 7));
const createOpenTournament = (
args: Partial<Parameters<typeof TournamentFactory.create>[0]> = {},
) =>
TournamentFactory.create({
authorId: users.id(1),
startTimes: [inAWeek()],
...args,
});
/** Two registered teams (users 1-2 and 3-4) and a started bracket, registration closed as the start time has passed. */
const createStartedTournament = async () => {
const tournament = await TournamentFactory.create({ authorId: users.id(1) });
await TournamentTeamFactory.create(
{ tournamentId: tournament.id, memberUserIds: [users.id(1), users.id(2)] },
{ isCheckedIn: true },
);
await TournamentTeamFactory.create(
{ tournamentId: tournament.id, memberUserIds: [users.id(3), users.id(4)] },
{ isCheckedIn: true },
);
await TournamentFactory.startBracket(tournament.id);
return tournament;
};
/** A placeholder of `likerUserId` that has liked the placeholder of `likedUserId`. */
const createLikedPair = async ({
tournamentId,
likedUserId,
likerUserId,
}: {
tournamentId: number;
likedUserId: number;
likerUserId: number;
}) => {
const liked = await TournamentLFGTeamFactory.create({
tournamentId,
userId: likedUserId,
});
const liker = await TournamentLFGTeamFactory.create(
{ tournamentId, userId: likerUserId },
{ likedTeamIds: [liked.id] },
);
return { liked, liker };
};
const memberCountsOfGroups = async (tournamentId: number) =>
(await TournamentLFGRepository.findLookingTeamsByTournamentId(tournamentId))
.map((group) => group.members.length)
.sort((a, b) => a - b);
const accept = ({
tournamentId,
userId,
targetTeamId,
}: {
tournamentId: number;
userId: number;
targetTeamId: number;
}) =>
looking(
{ _action: "ACCEPT", targetTeamId },
{ user: userId, params: { id: String(tournamentId) } },
);
describe("Tournament LFG LIKE", () => {
beforeEach(async () => {
await users.create(6);
});
test("errors after the tournament has started", async () => {
const tournament = await createStartedTournament();
const own = await TournamentLFGTeamFactory.create({
tournamentId: tournament.id,
userId: users.id(5),
});
const target = await TournamentLFGTeamFactory.create({
tournamentId: tournament.id,
userId: users.id(6),
});
assertResponseErrored(
await looking(
{ _action: "LIKE", targetTeamId: target.id },
{ user: users.id(5), params: { id: String(tournament.id) } },
),
);
expect(
(await TournamentLFGRepository.findAllLikesByTeamId(own.id)).given,
).toHaveLength(0);
});
});
describe("Tournament LFG ACCEPT", () => {
beforeEach(async () => {
await users.create(6);
});
test("merges two groups while registration is open", async () => {
const tournament = await createOpenTournament();
const { liker } = await createLikedPair({
tournamentId: tournament.id,
likedUserId: users.id(1),
likerUserId: users.id(2),
});
await accept({
tournamentId: tournament.id,
userId: users.id(1),
targetTeamId: liker.id,
});
expect(await memberCountsOfGroups(tournament.id)).toEqual([2]);
});
test("errors after the tournament has started and leaves the groups as they were", async () => {
const tournament = await createStartedTournament();
const { liker } = await createLikedPair({
tournamentId: tournament.id,
likedUserId: users.id(5),
likerUserId: users.id(6),
});
assertResponseErrored(
await accept({
tournamentId: tournament.id,
userId: users.id(5),
targetTeamId: liker.id,
}),
);
expect(await memberCountsOfGroups(tournament.id)).toEqual([1, 1]);
});
test("errors after registration has closed", async () => {
const tournament = await createOpenTournament({
regClosesAt: dateToDatabaseTimestamp(subDays(new Date(), 1)),
});
const { liker } = await createLikedPair({
tournamentId: tournament.id,
likedUserId: users.id(1),
likerUserId: users.id(2),
});
assertResponseErrored(
await accept({
tournamentId: tournament.id,
userId: users.id(1),
targetTeamId: liker.id,
}),
);
expect(await memberCountsOfGroups(tournament.id)).toEqual([1, 1]);
});
test("does not merge away a team that has checked in", async () => {
const tournament = await createOpenTournament();
const ownTeam = await TournamentTeamFactory.create(
{
tournamentId: tournament.id,
memberUserIds: [users.id(1), users.id(2)],
},
{ isLooking: true },
);
const checkedInTeam = await TournamentTeamFactory.create(
{
tournamentId: tournament.id,
memberUserIds: [users.id(3), users.id(4)],
},
{ isLooking: true, isCheckedIn: true, likedTeamIds: [ownTeam.id] },
);
assertResponseErrored(
await accept({
tournamentId: tournament.id,
userId: users.id(1),
targetTeamId: checkedInTeam.id,
}),
"checked in",
);
expect(await memberCountsOfGroups(tournament.id)).toEqual([2, 2]);
});
test("errors when a member has no friend code", async () => {
const tournament = await createOpenTournament();
const withoutFriendCode = await UserFactory.create({ friendCode: null });
const { liker } = await createLikedPair({
tournamentId: tournament.id,
likedUserId: users.id(1),
likerUserId: withoutFriendCode.id,
});
assertResponseErrored(
await accept({
tournamentId: tournament.id,
userId: users.id(1),
targetTeamId: liker.id,
}),
"no friend code",
);
expect(await memberCountsOfGroups(tournament.id)).toEqual([1, 1]);
});
test("errors when a member has no in-game name and the tournament requires one", async () => {
const tournament = await createOpenTournament({ requireInGameNames: true });
const withInGameName = await UserFactory.create({
profile: { inGameName: "Player#1234" },
});
const withoutInGameName = await UserFactory.create({ profile: null });
const { liker } = await createLikedPair({
tournamentId: tournament.id,
likedUserId: withInGameName.id,
likerUserId: withoutInGameName.id,
});
assertResponseErrored(
await accept({
tournamentId: tournament.id,
userId: withInGameName.id,
targetTeamId: liker.id,
}),
"no in-game name",
);
expect(await memberCountsOfGroups(tournament.id)).toEqual([1, 1]);
});
});
describe("Tournament LFG JOIN_QUEUE", () => {
beforeEach(async () => {
await users.create(4);
});
test("errors for a registered team after the tournament has started", async () => {
const tournament = await createStartedTournament();
assertResponseErrored(
await looking(
{ _action: "JOIN_QUEUE" },
{ user: users.id(1), params: { id: String(tournament.id) } },
),
);
expect(await memberCountsOfGroups(tournament.id)).toEqual([]);
});
});

View File

@@ -3,12 +3,14 @@ import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournam
import { notify } from "~/features/notifications/core/notify.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import { requireNotBannedByOrganization } from "~/features/tournament/tournament-utils.server";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import {
clearTournamentDataCache,
requireTournamentOrganizer,
tournamentFromParams,
tournamentTeamsFullCached,
} from "~/features/tournament-bracket/core/Tournament.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { defineAction } from "~/form/define-action.server";
import { errorToastIfFalsy } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
@@ -50,6 +52,10 @@ export const action = defineAction(
const existingGroup = await findOwnGroup();
if (existingGroup) return null;
errorToastIfFalsy(
tournament.registrationOpen,
"Registration is closed",
);
await requireNotBannedByOrganization({ tournament, user });
errorToastIfFalsy(
tournament.canAddNewSubPost,
@@ -93,6 +99,11 @@ export const action = defineAction(
break;
}
case "LIKE": {
errorToastIfFalsy(
tournament.registrationOpen,
"Registration is closed",
);
const groups =
await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournamentId,
@@ -137,6 +148,11 @@ export const action = defineAction(
break;
}
case "ACCEPT": {
errorToastIfFalsy(
tournament.registrationOpen,
"Registration is closed",
);
const groups =
await TournamentLFGRepository.findLookingTeamsByTournamentId(
tournamentId,
@@ -179,10 +195,22 @@ export const action = defineAction(
const otherGroup = surviving === ownGroup.id ? theirGroup : ownGroup;
errorToastIfFalsy(
!tournament.teamById(otherGroup.id)?.checkIns.length,
"A team that has checked in can't be merged into another team",
);
await requireMembersCanJoinTeam({
tournament,
members: [...ownGroup.members, ...theirGroup.members],
});
const roomsChangedUserIds = await TournamentLFGRepository.mergeTeams({
survivingTeamId: surviving,
otherTeamId: otherGroup.id,
maxGroupSize: tournament.maxMembersPerTeam,
requireInGameNames: Boolean(
tournament.ctx.settings.requireInGameNames,
),
chatRoomExpiresAt: pickupChatRoomExpiresAt(tournament.ctx.startsAt),
});
ChatSystemMessage.notifyRoomsChanged(roomsChangedUserIds);
@@ -374,3 +402,23 @@ export const action = defineAction(
return null;
},
);
async function requireMembersCanJoinTeam({
tournament,
members,
}: {
tournament: Tournament;
members: Array<{ id: number; username: string }>;
}) {
for (const member of members) {
const memberUser = await UserRepository.findLeanById(member.id);
errorToastIfFalsy(
memberUser?.friendCode,
`${member.username} has no friend code set`,
);
errorToastIfFalsy(
!tournament.ctx.settings.requireInGameNames || memberUser.inGameName,
`${member.username} has no in-game name set`,
);
}
}