mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 12:16:12 -05:00
Fix sub can report weapons for maps they did not play in
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user