diff --git a/app/db/seed/factories/TournamentTeamFactory.ts b/app/db/seed/factories/TournamentTeamFactory.ts index e05229ef6..f82f32b30 100644 --- a/app/db/seed/factories/TournamentTeamFactory.ts +++ b/app/db/seed/factories/TournamentTeamFactory.ts @@ -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, + }); + } }, }); diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts index 152780040..09aa10ddb 100644 --- a/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.test.ts @@ -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)); diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.ts index 5c8232579..5a90d18cf 100644 --- a/app/features/tournament-lfg/TournamentLFGRepository.server.ts +++ b/app/features/tournament-lfg/TournamentLFGRepository.server.ts @@ -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 { 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 diff --git a/app/features/tournament-lfg/actions/to.$id.looking.server.test.ts b/app/features/tournament-lfg/actions/to.$id.looking.server.test.ts new file mode 100644 index 000000000..544d47ac6 --- /dev/null +++ b/app/features/tournament-lfg/actions/to.$id.looking.server.test.ts @@ -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({ action: lookingAction }); + +const users = UserFactory.pool(); + +const inAWeek = () => dateToDatabaseTimestamp(addDays(new Date(), 7)); + +const createOpenTournament = ( + args: Partial[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([]); + }); +}); diff --git a/app/features/tournament-lfg/actions/to.$id.looking.server.ts b/app/features/tournament-lfg/actions/to.$id.looking.server.ts index 81fefe4e7..191cad830 100644 --- a/app/features/tournament-lfg/actions/to.$id.looking.server.ts +++ b/app/features/tournament-lfg/actions/to.$id.looking.server.ts @@ -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`, + ); + } +}