diff --git a/app/components/match-page/WeaponReporter.module.css b/app/components/match-page/WeaponReporter.module.css index adc15e82c..ea167ec24 100644 --- a/app/components/match-page/WeaponReporter.module.css +++ b/app/components/match-page/WeaponReporter.module.css @@ -90,6 +90,12 @@ position: relative; } +.rootStandalone { + margin-block-start: calc(-1 * var(--s-6)); + min-height: 200px; + justify-content: center; +} + .collapseButton { position: absolute; top: var(--s-2); diff --git a/app/components/match-page/WeaponReporter.tsx b/app/components/match-page/WeaponReporter.tsx index d426d7c1f..8ceb414f2 100644 --- a/app/components/match-page/WeaponReporter.tsx +++ b/app/components/match-page/WeaponReporter.tsx @@ -27,6 +27,7 @@ export interface WeaponReporterProps { onSubmit: (weaponSplId: MainWeaponId) => void; onUndo: () => void; isSubmitting?: boolean; + standalone?: boolean; } // xxx: on sendouq all weapons report different / component tab..? or not? check usage @@ -37,6 +38,7 @@ export function WeaponReporter({ onSubmit, onUndo, isSubmitting, + standalone, }: WeaponReporterProps) { const { t } = useTranslation(["q", "game-misc", "common"]); const user = useUser(); @@ -62,7 +64,7 @@ export function WeaponReporter({ ); }; - if (!isOpen) { + if (!isOpen && !standalone) { return (
- } - onPress={() => handleToggle(false)} - className={styles.collapseButton} - aria-label={t("q:match.actions.reportWeapons")} - /> +
+ {standalone ? null : ( + } + 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 54cd492f1..f12b1b373 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ 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"); })();