diff --git a/app/components/match-page/WeaponReporter.tsx b/app/components/match-page/WeaponReporter.tsx index 3cec646c4..3f7b37e02 100644 --- a/app/components/match-page/WeaponReporter.tsx +++ b/app/components/match-page/WeaponReporter.tsx @@ -15,12 +15,16 @@ import { WeaponSelect } from "../WeaponSelect"; import { SecondaryAction } from "./SecondaryAction"; import styles from "./WeaponReporter.module.css"; -interface WeaponReporterMap { +export interface WeaponReporterMap { + /** Index of the map in the match's map list, which is what a weapon is reported for. */ + mapIndex: number; stageId: StageId; mode: ModeShort; } export interface WeaponReporterProps { + /** Only the maps the viewer took part in, so someone who was subbed out is + * never asked for a weapon of a map they did not play. */ maps: WeaponReporterMap[]; pastReported: MainWeaponId[]; nextMapIndex: number; @@ -51,7 +55,7 @@ export function WeaponReporter({ null, ); - const inputTargetMap = nextMapIndex >= 0 ? maps[nextMapIndex] : undefined; + const inputTargetMap = maps.find((map) => map.mapIndex === nextMapIndex); const unreportedCount = inputTargetMap ? maps.length - pastReported.length - 1 : maps.length - pastReported.length; diff --git a/app/components/match-page/useMatchWeaponReport.ts b/app/components/match-page/useMatchWeaponReport.ts index 3fa7a76a0..8d5f48c53 100644 --- a/app/components/match-page/useMatchWeaponReport.ts +++ b/app/components/match-page/useMatchWeaponReport.ts @@ -1,26 +1,23 @@ import { useFetcher } from "react-router"; import { useRecentlyReportedWeapons } from "~/hooks/useRecentlyReportedWeapons"; -import type { - MainWeaponId, - ModeShort, - StageId, -} from "~/modules/in-game-lists/types"; -import type { WeaponReporterProps } from "./WeaponReporter"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import type { WeaponReporterMap, WeaponReporterProps } from "./WeaponReporter"; /** * Wires the `` component to the standard * `REPORT_WEAPON` / `UNDO_WEAPON_REPORT` fetcher actions and to the * locally persisted recently-reported weapons list. * - * `maps` is the play order of maps the viewer can report a weapon for and - * `pastReported` is the weapons the viewer has already reported, paired - * with the `mapIndex` they were reported for. + * `maps` is the maps the viewer can report a weapon for, in play order, each + * carrying its `mapIndex` in the match's map list — a viewer who sat out a map + * simply has no entry for it. `pastReported` is the weapons the viewer has + * already reported, paired with the `mapIndex` they were reported for. */ export function useMatchWeaponReport({ maps, pastReported, }: { - maps: { stageId: StageId; mode: ModeShort }[]; + maps: WeaponReporterMap[]; pastReported: { mapIndex: number; weaponSplId: MainWeaponId }[]; }): WeaponReporterProps { const weaponFetcher = useFetcher(); @@ -28,12 +25,8 @@ export function useMatchWeaponReport({ useRecentlyReportedWeapons(); const reportedMapIndexes = new Set(pastReported.map((w) => w.mapIndex)); - const nextMapIndex = (() => { - for (let i = 0; i < maps.length; i++) { - if (!reportedMapIndexes.has(i)) return i; - } - return -1; - })(); + const nextMapIndex = + maps.find((map) => !reportedMapIndexes.has(map.mapIndex))?.mapIndex ?? -1; const undoMapIndex = pastReported.reduce( (max, w) => Math.max(max, w.mapIndex), -1, diff --git a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx index 479b146b5..9085d5723 100644 --- a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx +++ b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx @@ -276,7 +276,11 @@ function WeaponReportSection({ : []; const weaponReport = useMatchWeaponReport({ - maps: completedMaps.map((m) => ({ stageId: m.stageId, mode: m.mode })), + maps: completedMaps.map((m, mapIndex) => ({ + mapIndex, + stageId: m.stageId, + mode: m.mode, + })), pastReported, }); @@ -402,7 +406,7 @@ function InProgressTab({ const weaponReport = useMatchWeaponReport({ maps: data.match.mapList .slice(0, reportedCount + 1) - .map((m) => ({ stageId: m.stageId, mode: m.mode })), + .map((m, mapIndex) => ({ mapIndex, stageId: m.stageId, mode: m.mode })), pastReported: data.reportedWeapons ? data.reportedWeapons .filter((w) => w.userId === user.id) diff --git a/app/features/tournament-match/components/TournamentMatchActionTab.tsx b/app/features/tournament-match/components/TournamentMatchActionTab.tsx index bc5602414..7ece46f95 100644 --- a/app/features/tournament-match/components/TournamentMatchActionTab.tsx +++ b/app/features/tournament-match/components/TournamentMatchActionTab.tsx @@ -10,6 +10,7 @@ import { WeaponReporter } from "~/components/match-page/WeaponReporter"; import { useUser } from "~/features/auth/core/user"; import { useTournament } from "~/features/tournament/routes/to.$id"; import { isSetOverByScore } from "~/features/tournament-bracket/core/engine"; +import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils"; import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates"; import type { CommonUser } from "~/utils/kysely.server"; import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; @@ -171,13 +172,16 @@ function useTournamentWeaponReport({ viewerUserId: number | undefined; weaponReportingOpen: boolean; }) { + const tournament = useTournament(); + const playOrderMaps = (data.mapList ?? []).filter( (m) => !m.bannedByTournamentTeamId, ); const reportedCount = data.results.length; const weaponReportMaps = playOrderMaps .slice(0, reportedCount + 1) - .map((m) => ({ stageId: m.stageId, mode: m.mode })); + .map((m, mapIndex) => ({ mapIndex, stageId: m.stageId, mode: m.mode })) + .filter(({ mapIndex }) => viewerPlayedMap(mapIndex)); const pastReported = data.reportedWeapons && viewerUserId !== undefined @@ -200,6 +204,26 @@ function useTournamentWeaponReport({ if (weaponReportMaps.length === 0) return null; return weaponReport; + + function viewerPlayedMap(mapIndex: number) { + if (viewerUserId === undefined) return false; + + // a played map remembers the roster it was played with, the map still to be + // played goes by the roster as it stands now + const result = data.results[mapIndex]; + if (result) { + return result.participants.some((p) => p.userId === viewerUserId); + } + + const team = tournament.teamMemberOfByUser({ id: viewerUserId }); + if (!team) return false; + + const activeRoster = + tournamentTeamToActiveRosterUserIds(team, tournament.minMembersPerTeam) ?? + team.members.map((m) => m.userId); + + return activeRoster.includes(viewerUserId); + } } function buildSetEndingData({ diff --git a/e2e/pages/tournament/tournament-match-page.ts b/e2e/pages/tournament/tournament-match-page.ts index 10f769545..6e7019066 100644 --- a/e2e/pages/tournament/tournament-match-page.ts +++ b/e2e/pages/tournament/tournament-match-page.ts @@ -3,6 +3,7 @@ import { tournamentMatchPage } from "~/utils/urls"; import { expect, navigate, + selectWeapon, submit, waitForPOSTResponse, } from "../../helpers/playwright"; @@ -50,6 +51,16 @@ export class TournamentMatchPage { banAMapText: page.getByText(/Ban a map/), lastBanText: page.getByText(/Ban a map \(2\/2\)/), pickAMapText: page.getByText(/Pick a map/), + actionPanel: page.getByRole("tabpanel", { name: TAB_LABELS.action }), + rostersPanel: page.getByRole("tabpanel", { name: TAB_LABELS.rosters }), + reportWeaponsButton: page.getByRole("button", { + name: "Report used weapons", + }), + // the weapon reporter's own submit, which has no test id of its own + submitWeaponButton: page + .getByRole("button", { name: "Submit", exact: true }) + .last(), + undoWeaponButton: page.getByRole("button", { name: "Undo weapon" }), }; } @@ -155,6 +166,28 @@ export class TournamentMatchPage { }); } + /** The weapon reporter's input for the `mapNumber`th map of the set (1-indexed). */ + weaponPrompt(mapNumber: number) { + return this.page.getByText(`Your weapon #${mapNumber}`); + } + + /** The weapon reporter sits collapsed behind a button unless the viewer's + * preference has it open — expanding an already open one is a no-op. */ + async expandWeaponReporter() { + await expect(this.locators.actionPanel).toBeVisible(); + if (await this.locators.reportWeaponsButton.isVisible()) { + await this.locators.reportWeaponsButton.click(); + } + } + + async reportWeapon(name: string) { + await this.expandWeaponReporter(); + await selectWeapon({ page: this.page, name }); + await waitForPOSTResponse(this.page, async () => { + await this.locators.submitWeaponButton.click(); + }); + } + playerCheckbox(side: RosterSide, nth: number) { return this.page.getByTestId(`player-checkbox-${side}-${nth}`); } @@ -167,6 +200,34 @@ export class TournamentMatchPage { return this.page.getByTestId(`edit-active-roster-button-${side}`); } + /** Puts exactly the members at `memberIndexes` on the team's active roster, + * from the rosters tab. Starts an edit first when a roster was already set. */ + async setActiveRoster(side: RosterSide, memberIndexes: number[]) { + await expect(this.locators.rostersPanel).toBeVisible(); + + const editButton = this.editActiveRosterButton(side); + const checkboxes = this.page.locator( + `[data-testid^="player-checkbox-${side}-"]`, + ); + // A roster that was never set renders in editing mode already, one that was + // has to be put back into it. + await expect(editButton.or(checkboxes.first())).toBeVisible(); + if (await editButton.isVisible()) { + await editButton.click(); + await expect(checkboxes.first()).toBeVisible(); + } + + const memberCount = await checkboxes.count(); + for (let i = 0; i < memberCount; i++) { + const checkbox = this.playerCheckbox(side, i); + if ((await checkbox.isChecked()) !== memberIndexes.includes(i)) { + await checkbox.click(); + } + } + + await this.saveActiveRoster(side); + } + reopen() { return submit(this.page, "reopen-match-button"); } @@ -224,9 +285,7 @@ export class TournamentMatchPage { // Editing inputs only render on the rosters tab — switch there. await this.page.getByRole("tab", { name: TAB_LABELS.rosters }).click(); // Wait for the rosters panel to be ready before probing for editing UI. - await expect( - this.page.getByRole("tabpanel", { name: TAB_LABELS.rosters }), - ).toBeVisible(); + await expect(this.locators.rostersPanel).toBeVisible(); for (const side of sides) { const submitButton = this.page.getByTestId( diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index b3896b291..ebc8b903e 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -53,6 +53,73 @@ test.describe("Tournament bracket", () => { await expect(matchPage.editActiveRosterButton("bravo")).toBeVisible(); }); + test("only asks for weapons of the maps played", async ({ + page, + factories, + }) => { + const tournament = await factories.TournamentFactory.create({ + authorId: ADMIN_ID, + startTimes: startedTournamentTimes(), + }); + // the team with subs is created second, making it the bravo side of the match + const [, teamWithSub] = await createTeams(factories, tournament.id, [ + {}, + { rosterSize: 5 }, + ]); + const [match] = await factories.TournamentFactory.startBracket( + tournament.id, + ); + + const ROSTER_WITH_VIEWER = [0, 1, 2, 4]; + const ROSTER_WITHOUT_VIEWER = [0, 1, 2, 3]; + const viewerUserId = teamWithSub.memberUserIds[4]; + + await impersonate(page, viewerUserId); + + const matchPage = new TournamentMatchPage(page); + await matchPage.goto({ tournamentId: tournament.id, matchId: match.id }); + + // Map 1: subbed in, reports their weapon like any other player + await matchPage.openTab("rosters"); + await matchPage.setActiveRoster("bravo", ROSTER_WITH_VIEWER); + + await matchPage.openTab("action"); + await matchPage.reportWeapon("Splattershot"); + await expect(matchPage.locators.undoWeaponButton).toBeVisible(); + await matchPage.reportResult({ + mapsToReport: 1, + winner: 1, + setEnds: false, + }); + + // Map 2: subbed out, so there is no weapon of theirs to report + await matchPage.openTab("rosters"); + await matchPage.setActiveRoster("bravo", ROSTER_WITHOUT_VIEWER); + + await matchPage.openTab("action"); + await matchPage.expandWeaponReporter(); + await isNotVisible(matchPage.weaponPrompt(2)); + + await matchPage.reportResult({ + mapsToReport: 1, + winner: 2, + setEnds: false, + }); + + // Map 3: subbed back in, asked for the map they are about to play + await matchPage.openTab("rosters"); + await matchPage.setActiveRoster("bravo", ROSTER_WITH_VIEWER); + + await matchPage.openTab("action"); + await matchPage.expandWeaponReporter(); + await expect(matchPage.weaponPrompt(3)).toBeVisible(); + + // Undoing map 2 puts it back up for grabs, now with them on the roster + await matchPage.undoLastReport(); + await matchPage.expandWeaponReporter(); + await expect(matchPage.weaponPrompt(2)).toBeVisible(); + }); + test("adds a sub mid tournament", async ({ page, factories }) => { const tournament = await factories.TournamentFactory.create({ authorId: ADMIN_ID,