Fix sub can report weapons for maps they did not play in

This commit is contained in:
Kalle
2026-08-01 11:40:40 +03:00
parent 34857887ad
commit 7ddab95631
6 changed files with 175 additions and 24 deletions

View File

@@ -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;

View File

@@ -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 `<WeaponReporter />` 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,

View File

@@ -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)

View File

@@ -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({

View File

@@ -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(

View File

@@ -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,