Tournament weapon report

This commit is contained in:
Kalle 2026-05-01 16:29:19 +03:00
parent b788839215
commit c802faf151
13 changed files with 392 additions and 55 deletions

View File

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

View File

@ -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 (
<div className={styles.rootCollapsed}>
<SendouButton
@ -78,15 +80,21 @@ export function WeaponReporter({
}
return (
<div className={clsx(styles.root, styles.rootExpanded)}>
<SendouButton
variant="minimal"
size="miniscule"
icon={<ChevronUp size={22} />}
onPress={() => handleToggle(false)}
className={styles.collapseButton}
aria-label={t("q:match.actions.reportWeapons")}
/>
<div
className={clsx(styles.root, styles.rootExpanded, {
[styles.rootStandalone]: standalone,
})}
>
{standalone ? null : (
<SendouButton
variant="minimal"
size="miniscule"
icon={<ChevronUp size={22} />}
onPress={() => handleToggle(false)}
className={styles.collapseButton}
aria-label={t("q:match.actions.reportWeapons")}
/>
)}
{pastReported.length > 0 ? (
<div className={styles.pastRow}>
{pastReported.map((weaponId, i) => (

View File

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

View File

@ -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<Array<{ weaponSplId: MainWeaponId; count: number }>> {
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<number>().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<number>().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<number>("merged.count").as("count"),
])
.groupBy("merged.weaponSplId")
.orderBy("count", "desc")
.execute();
return rows;
}

View File

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

View File

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

View File

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

View File

@ -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 (
<SendouTabPanel id={TAB_KEYS.ACTION}>
{weaponReport ? <WeaponReporter {...weaponReport} standalone /> : null}
</SendouTabPanel>
);
}
const opponentOneId = data.match.opponentOne!.id!;
const opponentTwoId = data.match.opponentTwo!.id!;
@ -129,10 +155,74 @@ export function TournamentMatchActionTab({
{t("q:match.undoReport")}
</SendouButton>
}
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,

View File

@ -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 ? (
) : (
<TournamentMatchActionTab
data={data}
currentMap={currentMap}
ownTeamId={userTeamId ?? opponentOneId}
/>
) : null
)
) : null}
{tabs.includes("admin") ? <TournamentMatchAdminTab data={data} /> : null}
</MatchTabs>
@ -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");
}

View File

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

View File

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

Binary file not shown.

View File

@ -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");
})();