diff --git a/app/features/tournament-bracket/components/MatchActions.tsx b/app/features/tournament-bracket/components/MatchActions.tsx index d347a5d872..9cea07513d 100644 --- a/app/features/tournament-bracket/components/MatchActions.tsx +++ b/app/features/tournament-bracket/components/MatchActions.tsx @@ -41,12 +41,26 @@ export function MatchActions({ >(() => { if (result) { return [ - result.participantIds.filter((id) => - teams[0].members.some((member) => member.userId === id), - ), - result.participantIds.filter((id) => - teams[1].members.some((member) => member.userId === id), - ), + result.participants + .filter((participant) => + teams[0].members.some( + (member) => + member.userId === participant.userId && + (!participant.tournamentTeamId || + teams[0].id === participant.tournamentTeamId), + ), + ) + .map((p) => p.userId), + result.participants + .filter((participant) => + teams[1].members.some( + (member) => + member.userId === participant.userId && + (!participant.tournamentTeamId || + teams[1].id === participant.tournamentTeamId), + ), + ) + .map((p) => p.userId), ]; } @@ -340,7 +354,7 @@ function EditScoreForm({ return ( `${p.userId}-${p.tournamentTeamId}`) + .join(","), result?.opponentOnePoints, result?.opponentTwoPoints, ].join("-"); diff --git a/app/features/tournament-bracket/components/TeamRosterInputs.tsx b/app/features/tournament-bracket/components/TeamRosterInputs.tsx index 46df13c1eb..8554b90c31 100644 --- a/app/features/tournament-bracket/components/TeamRosterInputs.tsx +++ b/app/features/tournament-bracket/components/TeamRosterInputs.tsx @@ -174,8 +174,14 @@ function _TeamRoster({ ); const checkedInputPlayerIds = () => { - if (result?.participantIds && !revising) { - return result.participantIds; + if (result?.participants && !revising) { + return result.participants + .filter( + (participant) => + !participant.tournamentTeamId || + participant.tournamentTeamId === team.id, + ) + .map((participant) => participant.userId); } if (editingRoster) return checkedPlayers.split(",").map(Number); @@ -216,12 +222,12 @@ function _TeamRoster({ teamId={team.id} checkedPlayers={checkedInputPlayerIds()} presentational={!revising && (presentational || !editingRoster)} - handlePlayerClick={(playerId: number) => { + handlePlayerClick={(playerId) => { if (!setCheckedPlayers) return; setCheckedPlayers((oldPlayers) => { const newPlayers = clone(oldPlayers); - if (oldPlayers.flat().includes(playerId)) { + if (oldPlayers[idx].includes(playerId)) { newPlayers[idx] = newPlayers[idx].filter((id) => id !== playerId); } else { newPlayers[idx].push(playerId); @@ -452,7 +458,7 @@ function TeamRosterInputsCheckboxes({ name="playerName" disabled={mode() === "DISABLED" || mode() === "PRESENTATIONAL"} value={member.id} - checked={checkedPlayers.flat().includes(member.id)} + checked={checkedPlayers.includes(member.id)} onChange={() => handlePlayerClick(member.id)} data-testid={`player-checkbox-${i}`} />{" "} diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index d7db204744..4acb15a522 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -1041,9 +1041,22 @@ export class Tournament { teamMemberOfByUser(user: OptionalIdObject) { if (!user) return null; - return this.ctx.teams.find((team) => + const teams = this.ctx.teams.filter((team) => team.members.some((member) => member.userId === user.id), ); + + 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; } teamMemberOfProgressStatus(user: OptionalIdObject) { diff --git a/app/features/tournament-bracket/core/summarizer.server.ts b/app/features/tournament-bracket/core/summarizer.server.ts index 8e6b86e6d8..c204eb0c1f 100644 --- a/app/features/tournament-bracket/core/summarizer.server.ts +++ b/app/features/tournament-bracket/core/summarizer.server.ts @@ -16,6 +16,7 @@ import { removeDuplicates } from "~/utils/arrays"; import invariant from "~/utils/invariant"; import type { Tables } from "../../../db/tables"; import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.server"; +import { ensureOneStandingPerUser } from "../tournament-bracket-utils"; import type { Standing } from "./Bracket"; export interface TournamentSummary { @@ -57,13 +58,10 @@ export function tournamentSummary({ seedingSkillCountsFor: Tables["SeedingSkill"]["type"] | null; calculateSeasonalStats?: boolean; }): TournamentSummary { - const userIdsToTeamId = userIdsToTeamIdRecord(teams); - return { skills: calculateSeasonalStats ? skills({ results, - userIdsToTeamId, queryCurrentTeamRating, queryCurrentUserRating, queryTeamPlayerRatingAverage, @@ -73,22 +71,19 @@ export function tournamentSummary({ ? calculateIndividualPlayerSkills({ queryCurrentUserRating: queryCurrentSeedingRating, results, - userIdsToTeamId, }).map((skill) => ({ ...skill, type: seedingSkillCountsFor, ordinal: ordinal(skill), })) : [], - mapResultDeltas: calculateSeasonalStats - ? mapResultDeltas({ results, userIdsToTeamId }) - : [], + mapResultDeltas: calculateSeasonalStats ? mapResultDeltas(results) : [], playerResultDeltas: calculateSeasonalStats - ? playerResultDeltas({ results, userIdsToTeamId }) + ? playerResultDeltas(results) : [], tournamentResults: tournamentResults({ participantCount: teams.length, - finalStandings, + finalStandings: ensureOneStandingPerUser(finalStandings), }), }; } @@ -107,7 +102,6 @@ export function userIdsToTeamIdRecord(teams: TeamsArg) { function skills(args: { results: AllMatchResult[]; - userIdsToTeamId: UserIdToTeamId; queryCurrentTeamRating: (identifier: string) => Rating; queryTeamPlayerRatingAverage: (identifier: string) => Rating; queryCurrentUserRating: (userId: number) => Rating; @@ -122,11 +116,9 @@ function skills(args: { export function calculateIndividualPlayerSkills({ results, - userIdsToTeamId, queryCurrentUserRating, }: { results: AllMatchResult[]; - userIdsToTeamId: UserIdToTeamId; queryCurrentUserRating: (userId: number) => Rating; }) { const userRatings = new Map(); @@ -144,12 +136,16 @@ export function calculateIndividualPlayerSkills({ ? match.opponentOne.id : match.opponentTwo.id; - const allUserIds = removeDuplicates(match.maps.flatMap((m) => m.userIds)); - const loserUserIds = allUserIds.filter( - (userId) => userIdsToTeamId[userId] !== winnerTeamId, + const participants = match.maps.flatMap((m) => m.participants); + const winnerUserIds = removeDuplicates( + participants + .filter((p) => p.tournamentTeamId === winnerTeamId) + .map((p) => p.userId), ); - const winnerUserIds = allUserIds.filter( - (userId) => userIdsToTeamId[userId] === winnerTeamId, + const loserUserIds = removeDuplicates( + participants + .filter((p) => p.tournamentTeamId !== winnerTeamId) + .map((p) => p.userId), ); const [ratedWinners, ratedLosers] = rate([ @@ -190,12 +186,10 @@ export function calculateIndividualPlayerSkills({ function calculateTeamSkills({ results, - userIdsToTeamId, queryCurrentTeamRating, queryTeamPlayerRatingAverage, }: { results: AllMatchResult[]; - userIdsToTeamId: UserIdToTeamId; queryCurrentTeamRating: (identifier: string) => Rating; queryTeamPlayerRatingAverage: (identifier: string) => Rating; }) { @@ -215,18 +209,18 @@ function calculateTeamSkills({ : match.opponentTwo.id; const winnerTeamIdentifiers = match.maps.flatMap((m) => { - const winnerUserIds = m.userIds.filter( - (userId) => userIdsToTeamId[userId] === winnerTeamId, - ); + const winnerUserIds = m.participants + .filter((p) => p.tournamentTeamId === winnerTeamId) + .map((p) => p.userId); return userIdsToIdentifier(winnerUserIds); }); const winnerTeamIdentifier = selectMostPopular(winnerTeamIdentifiers); const loserTeamIdentifiers = match.maps.flatMap((m) => { - const loserUserIds = m.userIds.filter( - (userId) => userIdsToTeamId[userId] !== winnerTeamId, - ); + const loserUserIds = m.participants + .filter((p) => p.tournamentTeamId !== winnerTeamId) + .map((p) => p.userId); return userIdsToIdentifier(loserUserIds); }); @@ -294,13 +288,9 @@ function selectMostPopular(items: T[]): T { return shuffle(mostPopularItems)[0][0]; } -function mapResultDeltas({ - results, - userIdsToTeamId, -}: { - results: AllMatchResult[]; - userIdsToTeamId: UserIdToTeamId; -}): TournamentSummary["mapResultDeltas"] { +function mapResultDeltas( + results: AllMatchResult[], +): TournamentSummary["mapResultDeltas"] { const result: TournamentSummary["mapResultDeltas"] = []; const addMapResult = ( @@ -330,18 +320,13 @@ function mapResultDeltas({ for (const match of results) { for (const map of match.maps) { - for (const userId of map.userIds) { - const tournamentTeamId = userIdsToTeamId[userId]; - invariant( - tournamentTeamId, - `Couldn't resolve tournament team id for user id ${userId}`, - ); - + for (const participant of map.participants) { addMapResult({ mode: map.mode, stageId: map.stageId, - type: tournamentTeamId === map.winnerTeamId ? "win" : "loss", - userId, + type: + participant.tournamentTeamId === map.winnerTeamId ? "win" : "loss", + userId: participant.userId, }); } } @@ -350,13 +335,9 @@ function mapResultDeltas({ return result; } -function playerResultDeltas({ - results, - userIdsToTeamId, -}: { - results: AllMatchResult[]; - userIdsToTeamId: UserIdToTeamId; -}): TournamentSummary["playerResultDeltas"] { +function playerResultDeltas( + results: AllMatchResult[], +): TournamentSummary["playerResultDeltas"] { const result: TournamentSummary["playerResultDeltas"] = []; const addPlayerResult = ( @@ -381,48 +362,46 @@ function playerResultDeltas({ for (const match of results) { for (const map of match.maps) { - for (const ownerUserId of map.userIds) { - for (const otherUserId of map.userIds) { - if (ownerUserId === otherUserId) continue; - - const ownTournamentTeamId = userIdsToTeamId[ownerUserId]; - invariant( - ownTournamentTeamId, - `Couldn't resolve tournament team id for user id ${ownerUserId}`, - ); - const otherTournamentTeamId = userIdsToTeamId[otherUserId]; - invariant( - otherTournamentTeamId, - `Couldn't resolve tournament team id for user id ${otherUserId}`, - ); - - const won = ownTournamentTeamId === map.winnerTeamId; + for (const ownerParticipant of map.participants) { + for (const otherParticipant of map.participants) { + if (ownerParticipant.userId === otherParticipant.userId) continue; + + const won = ownerParticipant.tournamentTeamId === map.winnerTeamId; addPlayerResult({ - ownerUserId, - otherUserId, + ownerUserId: ownerParticipant.userId, + otherUserId: otherParticipant.userId, mapLosses: won ? 0 : 1, mapWins: won ? 1 : 0, setLosses: 0, setWins: 0, type: - ownTournamentTeamId === otherTournamentTeamId ? "MATE" : "ENEMY", + ownerParticipant.tournamentTeamId === + otherParticipant.tournamentTeamId + ? "MATE" + : "ENEMY", }); } } } - const mostPopularUserIds = (() => { + const mostPopularParticipants = (() => { const alphaIdentifiers: string[] = []; const bravoIdentifiers: string[] = []; for (const map of match.maps) { - const alphaUserIds = map.userIds.filter( - (userId) => userIdsToTeamId[userId] === match.opponentOne.id, - ); - const bravoUserIds = map.userIds.filter( - (userId) => userIdsToTeamId[userId] === match.opponentTwo.id, - ); + const alphaUserIds = map.participants + .filter( + (participant) => + participant.tournamentTeamId === match.opponentOne.id, + ) + .map((p) => p.userId); + const bravoUserIds = map.participants + .filter( + (participant) => + participant.tournamentTeamId === match.opponentTwo.id, + ) + .map((p) => p.userId); alphaIdentifiers.push(userIdsToIdentifier(alphaUserIds)); bravoIdentifiers.push(userIdsToIdentifier(bravoUserIds)); @@ -432,41 +411,39 @@ function playerResultDeltas({ const bravoIdentifier = selectMostPopular(bravoIdentifiers); return [ - ...identifierToUserIds(alphaIdentifier), - ...identifierToUserIds(bravoIdentifier), + ...identifierToUserIds(alphaIdentifier).map((id) => ({ + userId: id, + tournamentTeamId: match.opponentOne.id, + })), + ...identifierToUserIds(bravoIdentifier).map((id) => ({ + userId: id, + tournamentTeamId: match.opponentTwo.id, + })), ]; })(); - for (const ownerUserId of mostPopularUserIds) { - for (const otherUserId of mostPopularUserIds) { - if (ownerUserId === otherUserId) continue; - - const ownTournamentTeamId = userIdsToTeamId[ownerUserId]; - invariant( - ownTournamentTeamId, - `Couldn't resolve tournament team id for user id ${ownerUserId}`, - ); - const otherTournamentTeamId = userIdsToTeamId[otherUserId]; - invariant( - otherTournamentTeamId, - `Couldn't resolve tournament team id for user id ${otherUserId}`, - ); + for (const ownerParticipant of mostPopularParticipants) { + for (const otherParticipant of mostPopularParticipants) { + if (ownerParticipant.userId === otherParticipant.userId) continue; const result = - match.opponentOne.id === ownTournamentTeamId + match.opponentOne.id === ownerParticipant.tournamentTeamId ? match.opponentOne.result : match.opponentTwo.result; const won = result === "win"; addPlayerResult({ - ownerUserId, - otherUserId, + ownerUserId: ownerParticipant.userId, + otherUserId: otherParticipant.userId, mapLosses: 0, mapWins: 0, setLosses: won ? 0 : 1, setWins: won ? 1 : 0, type: - ownTournamentTeamId === otherTournamentTeamId ? "MATE" : "ENEMY", + ownerParticipant.tournamentTeamId === + otherParticipant.tournamentTeamId + ? "MATE" + : "ENEMY", }); } } diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index 3158f2f918..66e6fdfa92 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -44,15 +44,20 @@ describe("tournamentSummary()", () => { function summarize({ results, seedingSkillCountsFor, + withMemberInTwoTeams = false, }: { results?: AllMatchResult[]; seedingSkillCountsFor?: Tables["SeedingSkill"]["type"]; + withMemberInTwoTeams?: boolean; } = {}) { return tournamentSummary({ finalStandings: [ { placement: 1, - team: createTeam(1, [1, 2, 3, 4]), + team: createTeam( + 1, + withMemberInTwoTeams ? [1, 2, 3, 4, 5] : [1, 2, 3, 4], + ), }, { placement: 2, @@ -73,13 +78,31 @@ describe("tournamentSummary()", () => { { mode: "SZ", stageId: 1, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 2 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 1, }, { mode: "TC", stageId: 2, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 2 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 1, }, ], @@ -142,6 +165,16 @@ describe("tournamentSummary()", () => { expect(summary.tournamentResults.length).toBe(4 * 4); }); + test("calculates final standings, handling a player in two teams", () => { + const summary = summarize({ withMemberInTwoTeams: true }); + expect( + summary.tournamentResults.some( + (result) => result.tournamentTeamId === 1 && result.userId === 5, + ), + ).toBeTruthy(); + expect(summary.tournamentResults.length).toBe(4 * 4); + }); + test("winners skill should go up, losers skill should go down", () => { const summary = summarize(); const winnerSkill = summary.skills.find((s) => s.userId === 1); @@ -183,13 +216,31 @@ describe("tournamentSummary()", () => { { mode: "SZ", stageId: 1, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 2 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 1, }, { mode: "TC", stageId: 2, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 2 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 1, }, ], @@ -209,13 +260,31 @@ describe("tournamentSummary()", () => { { mode: "SZ", stageId: 1, - userIds: [1, 20, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 20 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 1, }, { mode: "TC", stageId: 2, - userIds: [1, 20, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 20 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 1, }, ], @@ -266,19 +335,46 @@ describe("tournamentSummary()", () => { { mode: "SZ", stageId: 1, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 2 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 1, }, { mode: "TC", stageId: 2, - userIds: [1, 2, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 2 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 2, }, { mode: "TC", stageId: 2, - userIds: [1, 20, 3, 4, 5, 6, 7, 8], + participants: [ + { tournamentTeamId: 1, userId: 1 }, + { tournamentTeamId: 1, userId: 20 }, + { tournamentTeamId: 1, userId: 3 }, + { tournamentTeamId: 1, userId: 4 }, + { tournamentTeamId: 2, userId: 5 }, + { tournamentTeamId: 2, userId: 6 }, + { tournamentTeamId: 2, userId: 7 }, + { tournamentTeamId: 2, userId: 8 }, + ], winnerTeamId: 1, }, ], diff --git a/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts b/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts index b88ab050e5..a4d773cde5 100644 --- a/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts +++ b/app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server.ts @@ -1,12 +1,18 @@ import { sql } from "~/db/sql"; import type { ModeShort, StageId } from "~/modules/in-game-lists"; +import invariant from "~/utils/invariant"; import { parseDBArray, parseDBJsonArray } from "~/utils/sql"; const stm = sql.prepare(/* sql */ ` with "q1" as ( select "TournamentMatchGameResult".*, - json_group_array("TournamentMatchGameResultParticipant"."userId") as "userIds" + json_group_array( + json_object( + 'tournamentTeamId', "TournamentMatchGameResultParticipant"."tournamentTeamId", + 'userId', "TournamentMatchGameResultParticipant"."userId" + ) + ) as "participants" from "TournamentMatchGameResult" left join "TournamentMatchGameResultParticipant" on "TournamentMatchGameResultParticipant"."matchGameResultId" = "TournamentMatchGameResult"."id" group by "TournamentMatchGameResult"."id" @@ -26,8 +32,8 @@ const stm = sql.prepare(/* sql */ ` "q1"."mode", 'winnerTeamId', "q1"."winnerTeamId", - 'userIds', - "q1"."userIds" + 'participants', + "q1"."participants" ) ) as "maps" from @@ -54,7 +60,11 @@ export interface AllMatchResult { stageId: StageId; mode: ModeShort; winnerTeamId: number; - userIds: number[]; + participants: Array<{ + // in the DB this can actually also be null, but for new tournaments it should always be a number + tournamentTeamId: number; + userId: number; + }>; }>; } @@ -75,10 +85,21 @@ export function allMatchResultsByTournamentId( score: row.opponentTwoScore, result: row.opponentTwoResult, }, - maps: parseDBJsonArray(row.maps).map((map: any) => ({ - ...map, - userIds: parseDBArray(map.userIds), - })), + maps: parseDBJsonArray(row.maps).map((map: any) => { + const participants = parseDBArray(map.participants); + invariant(participants.length > 0, "No participants found"); + invariant( + participants.every( + (p: any) => typeof p.tournamentTeamId === "number", + ), + "Some participants have no team id", + ); + + return { + ...map, + participants, + }; + }), }; }); } diff --git a/app/features/tournament-bracket/queries/findResultsByMatchId.server.ts b/app/features/tournament-bracket/queries/findResultsByMatchId.server.ts index b2d15d78f1..087f09ff9e 100644 --- a/app/features/tournament-bracket/queries/findResultsByMatchId.server.ts +++ b/app/features/tournament-bracket/queries/findResultsByMatchId.server.ts @@ -1,6 +1,6 @@ import { sql } from "~/db/sql"; import type { Tables } from "~/db/tables"; -import type { TournamentMatchGameResult, User } from "~/db/types"; +import type { TournamentMatchGameResult } from "~/db/types"; import { parseDBArray } from "~/utils/sql"; const stm = sql.prepare(/* sql */ ` @@ -12,7 +12,12 @@ const stm = sql.prepare(/* sql */ ` "TournamentMatchGameResult"."createdAt", "TournamentMatchGameResult"."opponentOnePoints", "TournamentMatchGameResult"."opponentTwoPoints", - json_group_array("TournamentMatchGameResultParticipant"."userId") as "participantIds" + json_group_array( + json_object( + 'tournamentTeamId', "TournamentMatchGameResultParticipant"."tournamentTeamId", + 'userId', "TournamentMatchGameResultParticipant"."userId" + ) + ) as "participants" from "TournamentMatchGameResult" left join "TournamentMatchGameResultParticipant" on "TournamentMatchGameResultParticipant"."matchGameResultId" = "TournamentMatchGameResult"."id" @@ -26,7 +31,12 @@ interface FindResultsByMatchIdResult { winnerTeamId: TournamentMatchGameResult["winnerTeamId"]; stageId: TournamentMatchGameResult["stageId"]; mode: TournamentMatchGameResult["mode"]; - participantIds: Array; + participants: Array< + Pick< + Tables["TournamentMatchGameResultParticipant"], + "tournamentTeamId" | "userId" + > + >; createdAt: TournamentMatchGameResult["createdAt"]; opponentOnePoints: Tables["TournamentMatchGameResult"]["opponentOnePoints"]; opponentTwoPoints: Tables["TournamentMatchGameResult"]["opponentTwoPoints"]; @@ -39,6 +49,6 @@ export function findResultsByMatchId( return rows.map((row) => ({ ...row, - participantIds: parseDBArray(row.participantIds), + participants: parseDBArray(row.participants), })); } diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx index 3432ed07a2..b82b604698 100644 --- a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx +++ b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx @@ -186,6 +186,12 @@ export const action: ActionFunction = async ({ params, request }) => { validate(teamOneRoster, "Team one has no active roster"); validate(teamTwoRoster, "Team two has no active roster"); + validate( + new Set([...teamOneRoster, ...teamTwoRoster]).size === + tournament.minMembersPerTeam * 2, + "Duplicate user in rosters", + ); + sql.transaction(() => { manager.update.match({ id: match.id, diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts index 1538e3a2ba..1c9e884d40 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.ts @@ -12,6 +12,7 @@ import { removeDuplicates } from "~/utils/arrays"; import { sumArray } from "~/utils/number"; import type { FindMatchById } from "../tournament-bracket/queries/findMatchById.server"; import type { TournamentLoaderData } from "../tournament/routes/to.$id"; +import type { Standing } from "./core/Bracket"; import type { Tournament } from "./core/Tournament"; import type { TournamentDataTeam } from "./core/Tournament.server"; @@ -270,3 +271,22 @@ export function tournamentTeamToActiveRosterUserIds( return null; } + +// deal with user getting added to multiple teams by the TO +export function ensureOneStandingPerUser(standings: Standing[]) { + const userIds = new Set(); + + return standings.map((standing) => { + return { + ...standing, + team: { + ...standing.team, + members: standing.team.members.filter((member) => { + if (userIds.has(member.userId)) return false; + userIds.add(member.userId); + return true; + }), + }, + }; + }); +} diff --git a/app/features/tournament/actions/to.$id.admin.server.ts b/app/features/tournament/actions/to.$id.admin.server.ts index 8afb027b65..1aaf81f889 100644 --- a/app/features/tournament/actions/to.$id.admin.server.ts +++ b/app/features/tournament/actions/to.$id.admin.server.ts @@ -195,14 +195,10 @@ export const action: ActionFunction = async ({ request, params }) => { const previousTeam = tournament.teamMemberOfByUser({ id: data.userId }); - if (tournament.hasStarted) { - validate( - !previousTeam || previousTeam.checkIns.length === 0, - "User is already on a checked in team", - ); - } else { - validate(!previousTeam, "User is already on a team"); - } + validate( + tournament.hasStarted || !previousTeam, + "User is already in a team", + ); validate( !userIsBanned(data.userId), @@ -213,8 +209,13 @@ export const action: ActionFunction = async ({ request, params }) => { userId: data.userId, newTeamId: team.id, previousTeamId: previousTeam?.id, - // this team is not checked in so we can simply delete it - whatToDoWithPreviousTeam: previousTeam ? "DELETE" : undefined, + // this team is not checked in & tournament started, so we can simply delete it + whatToDoWithPreviousTeam: + previousTeam && + previousTeam.checkIns.length === 0 && + tournament.hasStarted + ? "DELETE" + : undefined, tournamentId, inGameName: await inGameNameIfNeeded({ tournament, diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index a478e9985b..a6127b54ef 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -26,6 +26,7 @@ import { tournamentEditPage, tournamentPage, } from "~/utils/urls"; +import { Alert } from "../../../components/Alert"; import { Dialog } from "../../../components/Dialog"; import { BracketProgressionSelector } from "../../calendar/components/BracketProgressionSelector"; import { useTournament } from "./to.$id"; @@ -201,6 +202,7 @@ function TeamActions() { ? actions.find((a) => a.when.length === 0)! : actions[0], ); + const [selectedUserId, setSelectedUserId] = React.useState(); const selectedTeam = tournament.teamById(selectedTeamId); @@ -251,113 +253,137 @@ function TeamActions() { return true; }); + const showAlreadyInTeamAlert = () => { + if (selectedAction.type !== "ADD_MEMBER") return false; + if ( + !selectedUserId || + !tournament.teamMemberOfByUser({ id: selectedUserId }) + ) { + return false; + } + + return true; + }; + return ( - -
- - -
- {selectedAction.inputs.includes("REGISTERED_TEAM") ? ( +
+
- + -
- ) : null} - {selectedAction.inputs.includes("TEAM_NAME") ? ( -
- - -
- ) : null} - {selectedTeam && selectedAction.inputs.includes("ROSTER_MEMBER") ? ( -
- - -
- ) : null} - {selectedAction.inputs.includes("USER") ? ( -
- - -
- ) : null} - {selectedAction.inputs.includes("BRACKET") ? ( -
- -
- ) : null} - {selectedTeam && selectedAction.inputs.includes("IN_GAME_NAME") ? ( -
- -
- -
#
- + + +
+ ) : null} + {selectedAction.inputs.includes("TEAM_NAME") ? ( +
+ + +
+ ) : null} + {selectedTeam && selectedAction.inputs.includes("ROSTER_MEMBER") ? ( +
+ + +
+ ) : null} + {selectedAction.inputs.includes("USER") ? ( +
+ + setSelectedUserId(newUser.id)} />
-
+ ) : null} + {selectedAction.inputs.includes("BRACKET") ? ( +
+ + +
+ ) : null} + {selectedTeam && selectedAction.inputs.includes("IN_GAME_NAME") ? ( +
+ +
+ +
#
+ +
+
+ ) : null} + + Go + +
+ {showAlreadyInTeamAlert() ? ( + This player is already in a team ) : null} - - Go - - +
); } diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index 21fdf10b0e..88333e03c0 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -6,6 +6,7 @@ import { isNotVisible, navigate, seed, + selectUser, submit, } from "~/utils/playwright"; import { @@ -131,13 +132,6 @@ const backToBracket = async (page: Page) => { const expectScore = (page: Page, score: [number, number]) => expect(page.getByText(score.join("-"))).toBeVisible(); -// 1) Report winner of N-ZAP's first match -// 2) Report winner of the adjacent match by using admin powers -// 3) Report one match on the only losers side match available -// 4) Try to reopen N-ZAP's first match and fail -// 5) Undo score of first losers match -// 6) Try to reopen N-ZAP's first match and succeed -// 7) As N-ZAP, undo all scores and switch to different team sweeping test.describe("Tournament bracket", () => { test("sets active roster as regular member", async ({ page }) => { const tournamentId = 1; @@ -179,6 +173,13 @@ test.describe("Tournament bracket", () => { ).not.toBeChecked(); }); + // 1) Report winner of N-ZAP's first match + // 2) Report winner of the adjacent match by using admin powers + // 3) Report one match on the only losers side match available + // 4) Try to reopen N-ZAP's first match and fail + // 5) Undo score of first losers match + // 6) Try to reopen N-ZAP's first match and succeed + // 7) As N-ZAP, undo all scores and switch to different team sweeping test("reports score and sees bracket update", async ({ page }) => { const tournamentId = 2; await startBracket(page); @@ -447,7 +448,9 @@ test.describe("Tournament bracket", () => { ).toHaveCount(3); }); - test("changes SOS format and progresses with it", async ({ page }) => { + test("changes SOS format and progresses with it & adds a member to another team", async ({ + page, + }) => { const tournamentId = 4; await seed(page, "SMALL_SOS"); @@ -490,6 +493,22 @@ test.describe("Tournament bracket", () => { await page.locator('[data-match-id="7"]').click(); await expect(page.getByTestId("back-to-bracket-button")).toBeVisible(); + + await page.getByTestId("admin-tab").click(); + await page.getByLabel("Action").selectOption("ADD_MEMBER"); + await page.getByLabel("Team", { exact: true }).selectOption("303"); // a team in the Mako bracket + await selectUser({ + labelName: "User", + userName: "Sendou", + page, + }); + await submit(page); + + await page.getByTestId("teams-tab").click(); + + await expect( + page.getByTestId("team-member-name").getByText("Sendou"), + ).toHaveCount(2); }); test("conducts a tournament with many starting brackets", async ({ diff --git a/scripts/calc-seeding-skills.ts b/scripts/calc-seeding-skills.ts index feb61ead03..a2efd2afd5 100644 --- a/scripts/calc-seeding-skills.ts +++ b/scripts/calc-seeding-skills.ts @@ -3,10 +3,7 @@ import { type Rating, ordinal, rating } from "openskill"; import { db } from "../app/db/sql"; import type { Tables } from "../app/db/tables"; import { tournamentFromDB } from "../app/features/tournament-bracket/core/Tournament.server"; -import { - calculateIndividualPlayerSkills, - userIdsToTeamIdRecord, -} from "../app/features/tournament-bracket/core/summarizer.server"; +import { calculateIndividualPlayerSkills } from "../app/features/tournament-bracket/core/summarizer.server"; import { allMatchResultsByTournamentId } from "../app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server"; import invariant from "../app/utils/invariant"; import { logger } from "../app/utils/logger"; @@ -29,7 +26,6 @@ async function main() { return ratings.get(userId) ?? rating(); }, results, - userIdsToTeamId: userIdsToTeamIdRecord(tournament.ctx.teams), }); for (const { userId, mu, sigma } of skills) {