}
+ onPress={() => handleToggle(false)}
+ className={styles.collapseButton}
+ aria-label={t("q:match.actions.reportWeapons")}
+ />
+ )}
{pastReported.length > 0 ? (
{pastReported.map((weaponId, i) => (
diff --git a/app/db/tables.ts b/app/db/tables.ts
index 2679d14e1..b00787aa3 100644
--- a/app/db/tables.ts
+++ b/app/db/tables.ts
@@ -453,8 +453,14 @@ export interface PlusVotingResult {
passedVoting: DBBoolean;
}
+// xxx: unify SendouQ side to also key on (matchId, mapIndex). Replace
+// groupMatchMapId with groupMatchId, make mapIndex non-null, and have a
+// single CHECK that exactly one of groupMatchId / tournamentMatchId is set.
+// Backfill (groupMatchId, mapIndex) from GroupMatchMap.matchId/index.
export interface ReportedWeapon {
groupMatchMapId: number | null;
+ tournamentMatchId: number | null;
+ mapIndex: number | null;
userId: number;
weaponSplId: MainWeaponId;
}
diff --git a/app/features/sendouq-match/ReportedWeaponRepository.server.ts b/app/features/sendouq-match/ReportedWeaponRepository.server.ts
index 4423274c3..df1db2221 100644
--- a/app/features/sendouq-match/ReportedWeaponRepository.server.ts
+++ b/app/features/sendouq-match/ReportedWeaponRepository.server.ts
@@ -1,6 +1,9 @@
import type { NotNull, Transaction } from "kysely";
import { db } from "~/db/sql";
import type { DB, TablesInsertable } from "~/db/tables";
+import * as Seasons from "~/features/mmr/core/Seasons";
+import type { MainWeaponId } from "~/modules/in-game-lists/types";
+import { dateToDatabaseTimestamp } from "~/utils/dates";
export function createMany(
weapons: TablesInsertable["ReportedWeapon"][],
@@ -106,3 +109,143 @@ export async function findByMatchId(matchId: number) {
return rows;
}
+
+export async function upsertOneTournament({
+ tournamentMatchId,
+ mapIndex,
+ userId,
+ weaponSplId,
+}: TablesInsertable["ReportedWeapon"] & {
+ tournamentMatchId: number;
+ mapIndex: number;
+}) {
+ await db
+ .deleteFrom("ReportedWeapon")
+ .where("tournamentMatchId", "=", tournamentMatchId)
+ .where("mapIndex", "=", mapIndex)
+ .where("userId", "=", userId)
+ .execute();
+
+ await db
+ .insertInto("ReportedWeapon")
+ .values({ tournamentMatchId, mapIndex, userId, weaponSplId })
+ .execute();
+}
+
+export async function deleteByUserMapIndexTournament({
+ tournamentMatchId,
+ userId,
+ mapIndex,
+}: {
+ tournamentMatchId: number;
+ userId: number;
+ mapIndex: number;
+}) {
+ await db
+ .deleteFrom("ReportedWeapon")
+ .where("tournamentMatchId", "=", tournamentMatchId)
+ .where("mapIndex", "=", mapIndex)
+ .where("userId", "=", userId)
+ .execute();
+}
+
+export async function findByTournamentMatchId(matchId: number) {
+ const rows = await db
+ .selectFrom("ReportedWeapon")
+ .select([
+ "ReportedWeapon.tournamentMatchId",
+ "ReportedWeapon.mapIndex",
+ "ReportedWeapon.weaponSplId",
+ "ReportedWeapon.userId",
+ ])
+ .where("ReportedWeapon.tournamentMatchId", "=", matchId)
+ .orderBy("ReportedWeapon.mapIndex", "asc")
+ .orderBy("ReportedWeapon.userId", "asc")
+ .$narrowType<{ tournamentMatchId: NotNull; mapIndex: NotNull }>()
+ .execute();
+
+ if (rows.length === 0) return null;
+
+ return rows;
+}
+
+/**
+ * Aggregates a user's reported weapons across both SendouQ matches and
+ * finalized tournaments that fall within the given season's date range.
+ */
+export async function seasonReportedWeaponsByUserId({
+ userId,
+ season,
+}: {
+ userId: number;
+ season: number;
+}): Promise
> {
+ const { starts, ends } = Seasons.nthToDateRange(season);
+ const startsTs = dateToDatabaseTimestamp(starts);
+ const endsTs = dateToDatabaseTimestamp(ends);
+
+ const sendouqWeapons = db
+ .selectFrom("ReportedWeapon")
+ .innerJoin(
+ "GroupMatchMap",
+ "GroupMatchMap.id",
+ "ReportedWeapon.groupMatchMapId",
+ )
+ .innerJoin("GroupMatch", "GroupMatch.id", "GroupMatchMap.matchId")
+ .select(({ fn }) => [
+ "ReportedWeapon.weaponSplId",
+ fn.countAll().as("count"),
+ ])
+ .where("ReportedWeapon.userId", "=", userId)
+ .where("GroupMatch.createdAt", ">=", startsTs)
+ .where("GroupMatch.createdAt", "<=", endsTs)
+ .groupBy("ReportedWeapon.weaponSplId");
+
+ const tournamentWeapons = db
+ .selectFrom("ReportedWeapon")
+ .innerJoin(
+ "TournamentMatch",
+ "TournamentMatch.id",
+ "ReportedWeapon.tournamentMatchId",
+ )
+ .innerJoin(
+ "TournamentStage",
+ "TournamentStage.id",
+ "TournamentMatch.stageId",
+ )
+ .innerJoin("Tournament", "Tournament.id", "TournamentStage.tournamentId")
+ .innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id")
+ .innerJoin(
+ (eb) =>
+ eb
+ .selectFrom("CalendarEventDate")
+ .select(({ fn }) => [
+ "CalendarEventDate.eventId",
+ fn.min("CalendarEventDate.startTime").as("startTime"),
+ ])
+ .groupBy("CalendarEventDate.eventId")
+ .as("EventStartTime"),
+ (join) => join.onRef("EventStartTime.eventId", "=", "CalendarEvent.id"),
+ )
+ .select(({ fn }) => [
+ "ReportedWeapon.weaponSplId",
+ fn.countAll().as("count"),
+ ])
+ .where("ReportedWeapon.userId", "=", userId)
+ .where("Tournament.isFinalized", "=", 1)
+ .where("EventStartTime.startTime", ">=", startsTs)
+ .where("EventStartTime.startTime", "<=", endsTs)
+ .groupBy("ReportedWeapon.weaponSplId");
+
+ const rows = await db
+ .selectFrom(sendouqWeapons.unionAll(tournamentWeapons).as("merged"))
+ .select(({ fn }) => [
+ "merged.weaponSplId",
+ fn.sum("merged.count").as("count"),
+ ])
+ .groupBy("merged.weaponSplId")
+ .orderBy("count", "desc")
+ .execute();
+
+ return rows;
+}
diff --git a/app/features/sendouq/queries/seasonReportedWeaponsByUserId.server.ts b/app/features/sendouq/queries/seasonReportedWeaponsByUserId.server.ts
deleted file mode 100644
index 89642ac27..000000000
--- a/app/features/sendouq/queries/seasonReportedWeaponsByUserId.server.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { sql } from "~/db/sql";
-import * as Seasons from "~/features/mmr/core/Seasons";
-import type { MainWeaponId } from "~/modules/in-game-lists/types";
-import { dateToDatabaseTimestamp } from "~/utils/dates";
-
-const stm = sql.prepare(/* sql */ `
- select
- "ReportedWeapon"."weaponSplId",
- count(*) as "count"
- from
- "ReportedWeapon"
- left join "GroupMatchMap" on "GroupMatchMap"."id" = "ReportedWeapon"."groupMatchMapId"
- left join "GroupMatch" on "GroupMatch"."id" = "GroupMatchMap"."matchId"
- where
- "ReportedWeapon"."userId" = @userId
- and "GroupMatch"."createdAt" between @starts and @ends
- group by "ReportedWeapon"."weaponSplId"
- order by "count" desc
-`);
-
-export function seasonReportedWeaponsByUserId({
- userId,
- season,
-}: {
- userId: number;
- season: number;
-}) {
- const { starts, ends } = Seasons.nthToDateRange(season);
-
- return stm.all({
- userId,
- starts: dateToDatabaseTimestamp(starts),
- ends: dateToDatabaseTimestamp(ends),
- }) as Array<{ weaponSplId: MainWeaponId; count: number }>;
-}
diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts
index 6bb6f7c78..f9ca57d89 100644
--- a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts
+++ b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts
@@ -9,6 +9,7 @@ import {
numericEnum,
safeJSONParse,
stageId,
+ weaponSplId,
} from "~/utils/zod";
import { TOURNAMENT } from "../tournament/tournament-constants";
import * as PickBan from "./core/PickBan";
@@ -102,6 +103,15 @@ export const matchSchema = z.union([
_action: _action("END_SET"),
winnerTeamId: z.preprocess(nullLiteraltoNull, id.nullable()),
}),
+ z.object({
+ _action: _action("REPORT_WEAPON"),
+ weaponSplId,
+ mapIndex: z.coerce.number().int().nonnegative(),
+ }),
+ z.object({
+ _action: _action("UNDO_WEAPON_REPORT"),
+ mapIndex: z.coerce.number().int().nonnegative(),
+ }),
]);
export const bracketIdx = z.coerce.number().int().min(0).max(100);
diff --git a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts
index c0df3b420..6d31a4853 100644
--- a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts
+++ b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts
@@ -3,6 +3,7 @@ import { sql } from "~/db/sql";
import { TournamentMatchStatus } from "~/db/tables";
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
+import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { endDroppedTeamMatches } from "~/features/tournament/tournament-utils.server";
@@ -736,6 +737,37 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
+ case "REPORT_WEAPON": {
+ const isMemberOfATeamInTheMatch = match.players.some(
+ (p) => p.id === user.id,
+ );
+ errorToastIfFalsy(isMemberOfATeamInTheMatch, "Unauthorized");
+ errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized");
+
+ await ReportedWeaponRepository.upsertOneTournament({
+ tournamentMatchId: matchId,
+ mapIndex: data.mapIndex,
+ userId: user.id,
+ weaponSplId: data.weaponSplId,
+ });
+
+ break;
+ }
+ case "UNDO_WEAPON_REPORT": {
+ const isMemberOfATeamInTheMatch = match.players.some(
+ (p) => p.id === user.id,
+ );
+ errorToastIfFalsy(isMemberOfATeamInTheMatch, "Unauthorized");
+ errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized");
+
+ await ReportedWeaponRepository.deleteByUserMapIndexTournament({
+ tournamentMatchId: matchId,
+ userId: user.id,
+ mapIndex: data.mapIndex,
+ });
+
+ break;
+ }
default: {
assertUnreachable(data);
}
diff --git a/app/features/tournament-match/components/TournamentMatchActionTab.tsx b/app/features/tournament-match/components/TournamentMatchActionTab.tsx
index d7f3ff947..64b86d4c7 100644
--- a/app/features/tournament-match/components/TournamentMatchActionTab.tsx
+++ b/app/features/tournament-match/components/TournamentMatchActionTab.tsx
@@ -3,9 +3,21 @@ import { Undo2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import { SendouButton } from "~/components/elements/Button";
+import { SendouTabPanel } from "~/components/elements/Tabs";
import { MatchActionTab } from "~/components/match-page/MatchActionTab";
+import { TAB_KEYS } from "~/components/match-page/MatchTabs";
+import {
+ WeaponReporter,
+ type WeaponReporterProps,
+} from "~/components/match-page/WeaponReporter";
+import { useUser } from "~/features/auth/core/user";
+import { useRecentlyReportedWeapons } from "~/features/sendouq/q-hooks";
import { useTournament } from "~/features/tournament/routes/to.$id";
-import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
+import type {
+ MainWeaponId,
+ ModeShort,
+ StageId,
+} from "~/modules/in-game-lists/types";
import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates";
import type { CommonUser } from "~/utils/kysely.server";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
@@ -17,14 +29,28 @@ export function TournamentMatchActionTab({
ownTeamId,
}: {
data: TournamentMatchLoaderData;
- currentMap: { stageId: StageId; mode: ModeShort };
+ currentMap?: { stageId: StageId; mode: ModeShort };
ownTeamId: number;
}) {
const { t } = useTranslation(["q"]);
const tournament = useTournament();
+ const user = useUser();
const reportFetcher = useFetcher();
const undoFetcher = useFetcher();
+ const weaponReport = useTournamentWeaponReport({
+ data,
+ viewerUserId: user?.id,
+ });
+
+ if (!currentMap) {
+ return (
+
+ {weaponReport ? : null}
+
+ );
+ }
+
const opponentOneId = data.match.opponentOne!.id!;
const opponentTwoId = data.match.opponentTwo!.id!;
@@ -129,10 +155,74 @@ export function TournamentMatchActionTab({
{t("q:match.undoReport")}
}
+ weaponReport={weaponReport ?? undefined}
/>
);
}
+function useTournamentWeaponReport({
+ data,
+ viewerUserId,
+}: {
+ data: TournamentMatchLoaderData;
+ viewerUserId: number | undefined;
+}): WeaponReporterProps | null {
+ const weaponFetcher = useFetcher();
+ const { recentlyReportedWeapons, addRecentlyReportedWeapon } =
+ useRecentlyReportedWeapons();
+
+ if (viewerUserId === undefined) return null;
+
+ const isParticipant = data.match.players.some((p) => p.id === viewerUserId);
+ if (!isParticipant) return null;
+
+ 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 }));
+
+ if (weaponReportMaps.length === 0) return null;
+
+ const pastReported: MainWeaponId[] = data.reportedWeapons
+ ? data.reportedWeapons
+ .filter((w) => w.userId === viewerUserId)
+ .map((w) => w.weaponSplId)
+ : [];
+
+ return {
+ maps: weaponReportMaps,
+ pastReported,
+ quickSelectWeaponIds: recentlyReportedWeapons,
+ isSubmitting: weaponFetcher.state !== "idle",
+ onSubmit: (weaponSplId) => {
+ addRecentlyReportedWeapon(weaponSplId);
+ const mapIndex = pastReported.length;
+ weaponFetcher.submit(
+ {
+ _action: "REPORT_WEAPON",
+ weaponSplId: String(weaponSplId),
+ mapIndex: String(mapIndex),
+ },
+ { method: "post" },
+ );
+ },
+ onUndo: () => {
+ const mapIndex = pastReported.length - 1;
+ if (mapIndex < 0) return;
+ weaponFetcher.submit(
+ {
+ _action: "UNDO_WEAPON_REPORT",
+ mapIndex: String(mapIndex),
+ },
+ { method: "post" },
+ );
+ },
+ };
+}
+
function buildSetEndingData({
teams,
scores,
diff --git a/app/features/tournament-match/components/TournamentMatchTabs.tsx b/app/features/tournament-match/components/TournamentMatchTabs.tsx
index 83edbeb07..82f26d9f8 100644
--- a/app/features/tournament-match/components/TournamentMatchTabs.tsx
+++ b/app/features/tournament-match/components/TournamentMatchTabs.tsx
@@ -86,9 +86,14 @@ export function TournamentMatchTabs({
const hasReportedMaps = data.results.length > 0;
const hasPickBanEvents = data.pickBanEventCount > 0;
+ const isParticipant = data.match.players.some((p) => p.id === user?.id);
+ const canReportWeapons =
+ isParticipant && !tournament.ctx.isFinalized && hasReportedMaps;
+
const tabs = resolveVisibleTabs({
matchIsOver: data.matchIsOver,
canReportScore,
+ canReportWeapons,
canJoin: data.canJoin,
hasCurrentMap: Boolean(currentMap),
hasMissingActiveRoster,
@@ -136,13 +141,13 @@ export function TournamentMatchTabs({
teams={pickBanTeams}
turnOfResult={turnOfResult}
/>
- ) : currentMap ? (
+ ) : (
- ) : null
+ )
) : null}
{tabs.includes("admin") ? : null}
@@ -192,10 +197,24 @@ function resolveTimelineMaps(
customUrl: u.customUrl,
}));
- return data.results.map((result) => {
+ return data.results.map((result, mapIndex) => {
const hasPoints =
result.opponentOnePoints !== null && result.opponentTwoPoints !== null;
+ const alphaRoster = resolveRoster(result.participants, opponentOneId);
+ const bravoRoster = resolveRoster(result.participants, opponentTwoId);
+
+ const weaponFor = (userId: number) =>
+ data.reportedWeapons?.find(
+ (w) => w.mapIndex === mapIndex && w.userId === userId,
+ )?.weaponSplId ?? null;
+
+ const alphaWeapons = alphaRoster.map((u) => weaponFor(u.id));
+ const bravoWeapons = bravoRoster.map((u) => weaponFor(u.id));
+ const hasAnyWeapon =
+ alphaWeapons.some((w) => w !== null) ||
+ bravoWeapons.some((w) => w !== null);
+
return {
stageId: result.stageId,
mode: result.mode,
@@ -205,9 +224,12 @@ function resolveTimelineMaps(
? ("ALPHA" as const)
: ("BRAVO" as const),
rosters: {
- alpha: resolveRoster(result.participants, opponentOneId),
- bravo: resolveRoster(result.participants, opponentTwoId),
+ alpha: alphaRoster,
+ bravo: bravoRoster,
},
+ weapons: hasAnyWeapon
+ ? { alpha: alphaWeapons, bravo: bravoWeapons }
+ : undefined,
points: hasPoints
? ([result.opponentOnePoints, result.opponentTwoPoints] as [
number,
@@ -485,6 +507,7 @@ function TournamentMatchRosterTab({
function resolveVisibleTabs({
matchIsOver,
canReportScore,
+ canReportWeapons,
canJoin,
hasCurrentMap,
hasMissingActiveRoster,
@@ -496,6 +519,7 @@ function resolveVisibleTabs({
}: {
matchIsOver: boolean;
canReportScore: boolean;
+ canReportWeapons: boolean;
canJoin: boolean;
hasCurrentMap: boolean;
hasMissingActiveRoster: boolean;
@@ -517,7 +541,8 @@ function resolveVisibleTabs({
if (
!leagueRoundLocked &&
(isPickBanStep ||
- (canReportScore && hasCurrentMap && !hasMissingActiveRoster))
+ (canReportScore && hasCurrentMap && !hasMissingActiveRoster) ||
+ canReportWeapons)
) {
tabs.push("action");
}
diff --git a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts
index 4fa7c2388..b93b192dc 100644
--- a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts
+++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts
@@ -4,6 +4,7 @@ import { getUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { chatAccessible } from "~/features/chat/chat-utils";
import * as RoomLinkRepository from "~/features/chat/RoomLinkRepository.server";
+import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { isLeagueRoundLocked } from "~/features/tournament/tournament-utils";
@@ -53,6 +54,9 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const results = findResultsByMatchId(matchId);
+ const reportedWeapons =
+ await ReportedWeaponRepository.findByTournamentMatchId(matchId);
+
const matchIsOver =
match.opponentOne?.result === "win" || match.opponentTwo?.result === "win";
@@ -219,6 +223,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
return {
match: hasPermsToSeeChat ? match : { ...match, chatCode: undefined },
results,
+ reportedWeapons,
mapList,
matchIsOver,
endedEarly,
diff --git a/app/features/user-page/loaders/u.$identifier.seasons.server.ts b/app/features/user-page/loaders/u.$identifier.seasons.server.ts
index bf92700b1..1262dae74 100644
--- a/app/features/user-page/loaders/u.$identifier.seasons.server.ts
+++ b/app/features/user-page/loaders/u.$identifier.seasons.server.ts
@@ -4,10 +4,10 @@ import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepos
import * as SkillRepository from "~/features/mmr/SkillRepository.server";
import { userSkills as _userSkills } from "~/features/mmr/tiered.server";
import { seasonMapWinrateByUserId } from "~/features/sendouq/queries/seasonMapWinrateByUserId.server";
-import { seasonReportedWeaponsByUserId } from "~/features/sendouq/queries/seasonReportedWeaponsByUserId.server";
import { seasonSetWinrateByUserId } from "~/features/sendouq/queries/seasonSetWinrateByUserId.server";
import { seasonStagesByUserId } from "~/features/sendouq/queries/seasonStagesByUserId.server";
import { seasonsMatesEnemiesByUserId } from "~/features/sendouq/queries/seasonsMatesEnemiesByUserId.server";
+import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import type { SerializeFrom } from "~/utils/remix";
@@ -91,7 +91,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
: null,
weapons:
info === "weapons"
- ? seasonReportedWeaponsByUserId({ season, userId: user.id })
+ ? await ReportedWeaponRepository.seasonReportedWeaponsByUserId({ season, userId: user.id })
: null,
players:
info === "enemies" || info === "mates"
diff --git a/db-test.sqlite3 b/db-test.sqlite3
index 54cd492f131fae504afd64db5156576664bcd526..f12b1b373a6628e5bc8bb83ae4dafe09b84c0cab 100644
GIT binary patch
delta 6072
zcmeHLZEO=|9KUwAja}KEc5iLhuDx}%TgMLW+I540GBid}UVUL~Y|PRg<6!HJ_QuN;
z1~ZPt54_dnNiYh|L@^PVf-Hm$F-Cz8it#0Y(N83R5rZF85(4Pcjt%aAJN)9u-R?ku?ejx`auN1O?a;Ok`cu~Vc7eOhZ$RASq?0cN1rU&_2
zfm;*dq*zqktmF6YBIwikA}2`koOFsqv$$|Xkv584Tuvyum39|b&QiJJpG{LjkPp?4
zR_2l3^cEKfRKn$TH0342q`aG?L_#Nsv&C$UO3WLgDL)Ylj9L@*1RYif$p4d8I4w6>
zD*?|(Ru`aLw8DzeHb3C0x0M6RI-3(vk~X+ZKDWVz_`9tX1nW-+Tf_fHu#ekesW;oF
z0S7*`!_a=P!_W*Ru9>QAv*k494)VboZev8JLW@i6y($@Z8BLWEnENmN~t!hy$tJThG`V
zrO?>PQWw~1a?^DZ0-Pd~SMJc$HPz*BQcVG$X;hNu)<~Pl`O&2#>0#1o;b%XBC%MZ7
zJsok`fv4GNdweYy33rIGXjt#CGPz6YRGwC5($vf#&)0C0(!;F|N85FGlomHU2m0OY
zGL^PzMw+T1+Jr}H@*2{snoq52Ns+?pBki?Y?wz;X1~QCfWRO9Vkx51t8QEk^AtPt#
zmYZSrcuikYQ;k&=`T{+hH_V)4l1zQh-YM6!vgz!MuZ&fre?z`O<^U-PWxR#YpJJ5M
zuUrXkd;R*nivuym{|ZQ_fM+cN`((
z@G;BM8)%@RfzStdexQNxA2jt3PVFDeJu#S)0$n-Yu|i|!(-o);+^TFz-Kq!#14OzLirmR5
zYsF4c#|UcDgtwpx0R)GYCIot8{GOKdT@$JT*~@8003YE{vnB?q8Sy~y{bp1SdT%+^
zdkX^3b;aeF+#S>D_3^!C1n?H-S0aF%@yjbwB`~l)ZGbfQ>DwrR7j<$rvJ!y~9ba3C
zYCr^)Et*-<+M0gFAY8m{B58Yg~jMc`HmSFAz+?cwHC
zs2ZqGu0j>HstH}uLQ`CjpDl2SNL=dVV)7=Pl>MRAnNh&DC}FC!by)A4yPoS
zn|6l!#B8hIZ`@Um-6Kqy(GbK>j4(bN9bvr1EO|T{HP~>(Ts(z<;03A6y7U*mbjIMq
z4W~^x`0CFl2R?Y5Veq9pmLhy*gn0(fIBL$vx!-3phDG?sUztzguDcc*|8&Qahrj(T
zYdYTjQjQs)JZhebUp>w6xS|aay<1(
z?B9QMOpdG5@#uzx`}NiAxcD;I3r~OJJfm(ftOv*)&DsONTmzn1{xe*s&Dv`YX0
delta 5357
zcmeHL*>4*~7~dqDq*=$AIHw&S>s(HpWSZIak)u#0>H`8wNf656Ty+yCY!18Fo1;Of
zwJKbR2ijt!m5MkNBn}Coa5bvh2oQpj2ZV$`QU3ub52!pKMMO{-Z={m_HXB}ew6)`h
zPxIX~-}n3G!kbTA`26G*-YhZ?emUD8w%sW;`?2VE3u3nz=oW+NGD{tx^jd6yGGpNZ
zC2gti(#UD)XBnT5bP_B_!V7pI91YJz;zwr_A`yd_X!fS>S!#hiXIT@V^y?@u=qNYJ
z+PXE8TOVhcfRA+eaUv3#lA<#>F(=K>8<@eDL)TB0w*oU#<#mEaMcpGTQ%_Y8d?qn>
z&Oq{=awm|JS0MmOZv_UF=PKNQ@@@sRa9g*Bt%O`cr8l6l*>jv_T74ua;OR*$MW-Sw
z26kVrgf?weIzWhzsUZ$@;_##_Ek$rVmXHlI%~v&dYE1EVv5ep&f#6j<#Kg1*z@c|9c6j|Hdn1U^t9F>AEjx_ku8ynCVFhyf9S%@v
zD%CUhG?D7t4j)2Fs$4T(Cv_kvGSp&{GP7Mp`I%H4!B^R`G(`5se7OMJ45Ko5Z
zBhjg_QL{4fHMF1XYnjdYEYt2Iw5Lh(Dn1jQnlY>>qde>bDc;?$k=BsQGEG#PaHyol
z9Q9U{%&nGWDXeX27kBteJA4jZxpd{x#nP2eR{>pxbRD6qXk&-xxGRkm50vJ%Fz8G4
zSjjDOMe&ct51YQ?zTwhbchS`&cMA&H!o06HJnP&Wro6@EQ%dr8i>U10Rz=#ipaSNDiw(&qH>{i7-P|>|dySN?kv{dGto4x8x+%3@oLV|Y*B2gL7PtP1YUMPJ-2q17J
zIiyqb=@6;~FDU0SFDL@V`;&_aIktf1XCey`10`r!=iZ~k2uy2=Jd6MiR^HN+z8^*{
zAbLe11ZE*65YqV|g%AOZZ--Dlz}c}3&K3lM&&FfX*kaTG*DLo!2+URr89`u5R!)zg
zW}skBRX~Bf`tn3)LxY%Yjv#=>m7hnDA9&C_syiiPqdGsY>34lMiok5F{5h)2Qg94)
z0FA3-x?P`)>5TYg41pIUrD+_2$xaE4qc$KuJ&u}!ngiW5$}-qTx&)kB@I&+!DR0UDm3Kt}=T@&p1eY0B*hXxf^)XRW74ofGK5koi6$Z0RMIly0!Z^4DiJ
qI;j+Ks|?ygFQclGYb9RuJ0{6g!>xX7={wC`THUp5tnQYj8vg+||Ilgx
diff --git a/migrations/134-match-page.js b/migrations/134-match-page.js
index d77e663ef..9d6b64f96 100644
--- a/migrations/134-match-page.js
+++ b/migrations/134-match-page.js
@@ -135,6 +135,53 @@ export function up(db) {
/* sql */ `create index group_match_continue_vote_group_id on "GroupMatchContinueVote"("groupId")`,
).run();
+ db.prepare(
+ /* sql */ `
+ create table "ReportedWeapon_new" (
+ "groupMatchMapId" integer,
+ "tournamentMatchId" integer,
+ "mapIndex" integer,
+ "weaponSplId" integer not null,
+ "userId" integer not null,
+ foreign key ("groupMatchMapId") references "GroupMatchMap"("id") on delete restrict,
+ foreign key ("tournamentMatchId") references "TournamentMatch"("id") on delete cascade,
+ foreign key ("userId") references "User"("id") on delete restrict,
+ unique("groupMatchMapId", "userId") on conflict rollback,
+ unique("tournamentMatchId", "mapIndex", "userId") on conflict rollback,
+ check (
+ ("groupMatchMapId" is not null and "tournamentMatchId" is null and "mapIndex" is null)
+ or
+ ("groupMatchMapId" is null and "tournamentMatchId" is not null and "mapIndex" is not null)
+ )
+ ) strict
+ `,
+ ).run();
+
+ db.prepare(
+ /* sql */ `
+ insert into "ReportedWeapon_new" (
+ "groupMatchMapId", "tournamentMatchId", "mapIndex", "weaponSplId", "userId"
+ )
+ select "groupMatchMapId", null, null, "weaponSplId", "userId"
+ from "ReportedWeapon"
+ `,
+ ).run();
+
+ db.prepare(/* sql */ `drop table "ReportedWeapon"`).run();
+ db.prepare(
+ /* sql */ `alter table "ReportedWeapon_new" rename to "ReportedWeapon"`,
+ ).run();
+
+ db.prepare(
+ /* sql */ `create index reported_weapon_group_match_map_id on "ReportedWeapon"("groupMatchMapId")`,
+ ).run();
+ db.prepare(
+ /* sql */ `create index reported_weapon_tournament_match_id on "ReportedWeapon"("tournamentMatchId")`,
+ ).run();
+ db.prepare(
+ /* sql */ `create index reported_weapon_user_id on "ReportedWeapon"("userId")`,
+ ).run();
+
db.pragma("foreign_key_check");
})();