Report weapons initial

This commit is contained in:
Kalle
2026-04-15 17:30:18 +03:00
parent 4fad8d64d1
commit b6851288f3
25 changed files with 381 additions and 23 deletions

View File

@@ -19,6 +19,7 @@ import {
type MatchTimelineProps,
type TimelineMap,
} from "./MatchTimeline";
import { WeaponReporter, type WeaponReporterProps } from "./WeaponReporter";
interface ActionTabTeam {
id: number;
@@ -41,6 +42,7 @@ interface MatchActionTabProps {
isSubmitting?: boolean;
setEnding?: SetEndingData;
actionButtons?: React.ReactNode;
weaponReport?: WeaponReporterProps;
}
export function MatchActionTab({
@@ -53,6 +55,7 @@ export function MatchActionTab({
isSubmitting,
setEnding,
actionButtons,
weaponReport,
}: MatchActionTabProps) {
const { t } = useTranslation(["q", "game-misc", "common"]);
const [winnerId, setWinnerId] = useState<number | null>(null);
@@ -170,6 +173,7 @@ export function MatchActionTab({
</SendouButton>
</div>
)}
{weaponReport ? <WeaponReporter {...weaponReport} /> : null}
</SendouTabPanel>
);
}

View File

@@ -0,0 +1,57 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-4);
background-color: var(--color-bg-higher);
border-radius: 0 0 var(--radius-box) var(--radius-box);
padding: var(--s-4);
margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-4));
}
.pastRow {
display: flex;
align-items: center;
gap: var(--s-2);
}
.mapRow {
display: flex;
align-items: center;
gap: var(--s-3);
}
.mapInfo {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
}
.mapLabel {
display: flex;
align-items: center;
gap: var(--s-1);
font-size: var(--font-3xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.stageImage {
border-radius: var(--radius-box);
}
.inputRow {
display: flex;
align-items: flex-end;
gap: var(--s-3);
}
.weaponSelectContainer {
min-width: 200px;
}
.unreportedRow {
display: flex;
gap: var(--s-1);
}

View File

@@ -0,0 +1,131 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import { abilityImageUrl } from "~/utils/urls";
import { SendouButton } from "../elements/Button";
import { Image, ModeImage, StageImage, WeaponImage } from "../Image";
import { WeaponSelect } from "../WeaponSelect";
import styles from "./WeaponReporter.module.css";
interface WeaponReporterMap {
stageId: StageId;
mode: ModeShort;
}
export interface WeaponReporterProps {
maps: WeaponReporterMap[];
pastReported: MainWeaponId[];
quickSelectWeaponIds?: MainWeaponId[];
onSubmit: (weaponSplId: MainWeaponId) => void;
onUndo: () => void;
isSubmitting?: boolean;
}
// xxx: default collapsed, small minimal button to uncollapse
// xxx: on sendouq all weapons report different / component tab..? or not? check usage
export function WeaponReporter({
maps,
pastReported,
quickSelectWeaponIds,
onSubmit,
onUndo,
isSubmitting,
}: WeaponReporterProps) {
const { t } = useTranslation(["q", "game-misc", "common"]);
const [selectedWeapon, setSelectedWeapon] = useState<MainWeaponId | null>(
null,
);
const inputTargetIndex = pastReported.length;
const inputTargetMap = maps[inputTargetIndex];
const unreportedCount =
maps.length - inputTargetIndex - (inputTargetMap ? 1 : 0);
return (
<div className={styles.root}>
{pastReported.length > 0 ? (
<div className={styles.pastRow}>
{pastReported.map((weaponId, i) => (
<WeaponImage
key={i}
weaponSplId={weaponId}
variant="badge"
size={24}
/>
))}
<SendouButton
variant="minimal"
size="small"
isDisabled={isSubmitting}
onPress={onUndo}
>
{t("q:match.weapon.undoWeapon")}
</SendouButton>
</div>
) : null}
{inputTargetMap ? (
<div className={styles.mapRow}>
<MapInfo map={inputTargetMap} />
<div className={styles.inputRow}>
<div className={styles.weaponSelectContainer}>
<WeaponSelect
label={t("q:match.weapon.yourWeapon")}
value={selectedWeapon}
onChange={setSelectedWeapon}
quickSelectWeaponsIds={quickSelectWeaponIds}
/>
</div>
<SendouButton
variant="primary"
size="small"
isDisabled={selectedWeapon === null || isSubmitting}
onPress={() => {
if (selectedWeapon === null) return;
onSubmit(selectedWeapon);
setSelectedWeapon(null);
}}
>
{t("common:actions.submit")}
</SendouButton>
</div>
</div>
) : null}
{unreportedCount > 0 ? (
<div className={styles.unreportedRow}>
{Array.from({ length: unreportedCount }, (_, i) => (
<Image
key={i}
path={abilityImageUrl("UNKNOWN")}
alt="?"
size={24}
/>
))}
</div>
) : null}
</div>
);
}
function MapInfo({ map }: { map: WeaponReporterMap }) {
const { t } = useTranslation(["game-misc"]);
return (
<div className={styles.mapInfo}>
<StageImage
stageId={map.stageId}
width={60}
className={styles.stageImage}
/>
<div className={styles.mapLabel}>
<ModeImage mode={map.mode} size={14} />
<span>{shortStageName(t(`game-misc:STAGE_${map.stageId}`))}</span>
</div>
</div>
);
}

View File

@@ -40,6 +40,31 @@ export async function replaceByMatchId(
}
}
export async function deleteByUserMapIndex({
matchId,
userId,
mapIndex,
}: {
matchId: number;
userId: number;
mapIndex: number;
}) {
const groupMatchMap = await db
.selectFrom("GroupMatchMap")
.select("id")
.where("matchId", "=", matchId)
.where("index", "=", mapIndex)
.executeTakeFirst();
if (!groupMatchMap) return;
await db
.deleteFrom("ReportedWeapon")
.where("groupMatchMapId", "=", groupMatchMap.id)
.where("userId", "=", userId)
.execute();
}
export async function findByMatchId(matchId: number) {
const rows = await db
.selectFrom("ReportedWeapon")

View File

@@ -135,9 +135,18 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
throw redirect(SENDOUQ_PREPARING_PAGE);
}
// xxx: why not REPORT_WEAPON
case "REPORT_WEAPONS": {
const match = notFoundIfFalsy(await SQMatchRepository.findById(matchId));
errorToastIfFalsy(match.reportedAt, "Match has not been reported yet");
const members = [
...match.groupAlpha.members,
...match.groupBravo.members,
];
invariant(
members.some((m) => m.id === user.id),
"User is not a member of any group",
);
const oldReportedWeapons =
(await ReportedWeaponRepository.findByMatchId(matchId)) ?? [];
@@ -161,6 +170,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
break;
}
case "UNDO_WEAPON_REPORT": {
notFoundIfFalsy(await SQMatchRepository.findById(matchId));
await ReportedWeaponRepository.deleteByUserMapIndex({
matchId,
userId: user.id,
mapIndex: data.mapIndex,
});
break;
}
case "ADD_PRIVATE_USER_NOTE": {
await PrivateUserNoteRepository.upsert({
authorId: user.id,

View File

@@ -7,8 +7,13 @@ import { FormWithConfirm } from "~/components/FormWithConfirm";
import { MatchActionTab } from "~/components/match-page/MatchActionTab";
import { TAB_KEYS } from "~/components/match-page/MatchTabs";
import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants";
import { useRecentlyReportedWeapons } from "~/features/sendouq/q-hooks";
import { isSetOverByScore } from "~/features/tournament-bracket/tournament-bracket-utils";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server";
import styles from "./SendouQMatchActionTab.module.css";
@@ -16,17 +21,22 @@ export function SendouQMatchActionTab({
data,
currentMap,
ownTeamId,
ownUserId,
reportedCount,
}: {
data: SendouQMatchLoaderData;
currentMap: { stageId: StageId; mode: ModeShort };
ownTeamId: number;
ownUserId: number;
reportedCount: number;
}) {
const { t } = useTranslation(["q", "common"]);
const fetcher = useFetcher();
const undoFetcher = useFetcher();
const cancelFetcher = useFetcher();
const weaponFetcher = useFetcher();
const { recentlyReportedWeapons, addRecentlyReportedWeapon } =
useRecentlyReportedWeapons();
const alphaScore = data.match.mapList.filter(
(m) => m.winnerGroupId === data.match.groupAlpha.id,
@@ -131,6 +141,16 @@ export function SendouQMatchActionTab({
const scoreIsNotZero = alphaScore > 0 || bravoScore > 0;
const weaponReportMaps = data.match.mapList
.slice(0, reportedCount + 1)
.map((m) => ({ stageId: m.stageId, mode: m.mode }));
const weaponPastReported: MainWeaponId[] = data.reportedWeapons
? data.reportedWeapons
.filter((w) => w.userId === ownUserId)
.map((w) => w.weaponSplId)
: [];
return (
<MatchActionTab
key={reportedCount}
@@ -154,6 +174,42 @@ export function SendouQMatchActionTab({
{ method: "post" },
);
}}
weaponReport={{
maps: weaponReportMaps,
pastReported: weaponPastReported,
quickSelectWeaponIds: recentlyReportedWeapons,
isSubmitting: weaponFetcher.state !== "idle",
onSubmit: (weaponSplId) => {
addRecentlyReportedWeapon(weaponSplId);
const mapIndex = weaponPastReported.length;
const map = data.match.mapList[mapIndex];
weaponFetcher.submit(
{
_action: "REPORT_WEAPONS",
weapons: JSON.stringify([
{
weaponSplId,
userId: ownUserId,
mapIndex,
groupMatchMapId: map.id,
},
]),
},
{ method: "post" },
);
},
onUndo: () => {
const mapIndex = weaponPastReported.length - 1;
if (mapIndex < 0) return;
weaponFetcher.submit(
{
_action: "UNDO_WEAPON_REPORT",
mapIndex: String(mapIndex),
},
{ method: "post" },
);
},
}}
actionButtons={
<>
<FormWithConfirm

View File

@@ -111,7 +111,7 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
alpha: alphaWins,
bravo: bravoWins,
}}
maps={resolveTimelineMaps(data.match)}
maps={resolveTimelineMaps(data.match, data.reportedWeapons)}
spChanges={resolveTimelineSpChanges(data.match)}
>
{data.match.cancelRequestedByUserId ? (
@@ -175,6 +175,8 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
data={data}
currentMap={currentMap}
ownTeamId={ownTeamId}
// xxx: why not just useUser in SendouQMatchActionTab?
ownUserId={user!.id}
reportedCount={reportedCount}
/>
) : null}
@@ -208,7 +210,7 @@ function ConfirmerTab({
(m) => m.winnerGroupId === data.match.groupBravo.id,
).length,
}}
maps={resolveTimelineMaps(data.match)}
maps={resolveTimelineMaps(data.match, data.reportedWeapons)}
/>
<div className="stack md items-center mt-4">
<SendouButton
@@ -252,7 +254,7 @@ function ReporterWaitingTab({ data }: { data: SendouQMatchLoaderData }) {
(m) => m.winnerGroupId === data.match.groupBravo.id,
).length,
}}
maps={resolveTimelineMaps(data.match)}
maps={resolveTimelineMaps(data.match, data.reportedWeapons)}
/>
<div className="stack md items-center mt-4">
<p className="text-lighter text-sm">
@@ -292,22 +294,47 @@ function resolveTimelineTeams(match: MatchData) {
};
}
function resolveTimelineMaps(match: MatchData): TimelineMap[] {
function resolveTimelineMaps(
match: MatchData,
reportedWeapons: SendouQMatchLoaderData["reportedWeapons"],
): TimelineMap[] {
return match.mapList
.filter((m) => m.winnerGroupId !== null)
.map((map) => ({
stageId: map.stageId,
mode: map.mode,
timestamp: match.createdAt,
winner:
map.winnerGroupId === match.groupAlpha.id
? ("ALPHA" as const)
: ("BRAVO" as const),
rosters: {
alpha: match.groupAlpha.members,
bravo: match.groupBravo.members,
},
}));
.map((map) => {
const alphaWeapons = match.groupAlpha.members.map((member) => {
const w = reportedWeapons?.find(
(rw) => rw.groupMatchMapId === map.id && rw.userId === member.id,
);
return w ? w.weaponSplId : null;
});
const bravoWeapons = match.groupBravo.members.map((member) => {
const w = reportedWeapons?.find(
(rw) => rw.groupMatchMapId === map.id && rw.userId === member.id,
);
return w ? w.weaponSplId : null;
});
const hasAnyWeapon =
alphaWeapons.some((w) => w !== null) ||
bravoWeapons.some((w) => w !== null);
return {
stageId: map.stageId,
mode: map.mode,
timestamp: match.createdAt,
winner:
map.winnerGroupId === match.groupAlpha.id
? ("ALPHA" as const)
: ("BRAVO" as const),
rosters: {
alpha: match.groupAlpha.members,
bravo: match.groupBravo.members,
},
weapons: hasAnyWeapon
? { alpha: alphaWeapons, bravo: bravoWeapons }
: undefined,
};
});
}
function resolveTimelineSpChanges(

View File

@@ -4,6 +4,7 @@ import { chatAccessible } from "~/features/chat/chat-utils";
import * as RoomLinkRepository from "~/features/chat/RoomLinkRepository.server";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.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 { databaseTimestampToDate } from "~/utils/dates";
@@ -27,15 +28,15 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
...matchUnmapped.groupBravo.members,
].map((m) => m.id);
const [privateNotes, roomLinks, anyUserPrefersNoSplatnet] = await Promise.all(
[
const [privateNotes, roomLinks, anyUserPrefersNoSplatnet, reportedWeapons] =
await Promise.all([
user
? PrivateUserNoteRepository.byAuthorUserId(user.id, matchUsers)
: undefined,
RoomLinkRepository.findByUserIds(matchUsers, 3),
UserRepository.anyUserPrefersNoSplatnet(matchUsers),
],
);
ReportedWeaponRepository.findByMatchId(matchId),
]);
const match = SendouQ.mapMatch(matchUnmapped, user, privateNotes);
@@ -43,6 +44,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
match,
roomLinks,
anyUserPrefersNoSplatnet,
reportedWeapons,
chatCode: (() => {
const isStaff = user?.roles.includes("STAFF") ?? false;
const isParticipant = user && matchUsers.includes(user.id);

View File

@@ -54,6 +54,10 @@ export const matchSchema = z.union([
z.object({
_action: _action("UNDO_MAP_REPORT"),
}),
z.object({
_action: _action("UNDO_WEAPON_REPORT"),
mapIndex: z.coerce.number().int().nonnegative(),
}),
z.object({
_action: _action("REQUEST_CANCEL"),
}),

View File

@@ -193,6 +193,8 @@
"match.timeline.teamSp": "",
"match.waitingForConfirmation": "",
"match.undoReport": "",
"match.weapon.yourWeapon": "",
"match.weapon.undoWeapon": "",
"match.confirmScore": "",
"match.confirmScore.wrongHint": "",
"preparing.joinQ": "",

View File

@@ -193,6 +193,8 @@
"match.timeline.teamSp": "",
"match.waitingForConfirmation": "",
"match.undoReport": "",
"match.weapon.yourWeapon": "",
"match.weapon.undoWeapon": "",
"match.confirmScore": "",
"match.confirmScore.wrongHint": "",
"preparing.joinQ": "",

View File

@@ -193,6 +193,8 @@
"match.timeline.teamSp": "Team SP",
"match.waitingForConfirmation": "Waiting for the other team to confirm the result",
"match.undoReport": "Undo report",
"match.weapon.yourWeapon": "Your weapon",
"match.weapon.undoWeapon": "Undo weapon",
"match.confirmScore": "Confirm score",
"match.confirmScore.wrongHint": "Wrong score? Ask the other team to undo their report and adjust it.",
"preparing.joinQ": "Join the queue",

View File

@@ -193,6 +193,8 @@
"match.timeline.teamSp": "",
"match.waitingForConfirmation": "",
"match.undoReport": "",
"match.weapon.yourWeapon": "",
"match.weapon.undoWeapon": "",
"match.confirmScore": "",
"match.confirmScore.wrongHint": "",
"preparing.joinQ": "Unirte a la fila",

View File

@@ -193,6 +193,8 @@
"match.timeline.teamSp": "",
"match.waitingForConfirmation": "",
"match.undoReport": "",
"match.weapon.yourWeapon": "",
"match.weapon.undoWeapon": "",
"match.confirmScore": "",
"match.confirmScore.wrongHint": "",
"preparing.joinQ": "Unirte a la fila",

View File

@@ -193,6 +193,8 @@
"match.timeline.teamSp": "",
"match.waitingForConfirmation": "",
"match.undoReport": "",
"match.weapon.yourWeapon": "",
"match.weapon.undoWeapon": "",
"match.confirmScore": "",
"match.confirmScore.wrongHint": "",
"preparing.joinQ": "",

View File

@@ -193,6 +193,8 @@
"match.timeline.teamSp": "",
"match.waitingForConfirmation": "",
"match.undoReport": "",
"match.weapon.yourWeapon": "",
"match.weapon.undoWeapon": "",
"match.confirmScore": "",
"match.confirmScore.wrongHint": "",
"preparing.joinQ": "Rejoindre la queue",

View File

@@ -193,6 +193,8 @@
"match.timeline.teamSp": "",
"match.waitingForConfirmation": "",
"match.undoReport": "",
"match.weapon.yourWeapon": "",
"match.weapon.undoWeapon": "",
"match.confirmScore": "",
"match.confirmScore.wrongHint": "",
"preparing.joinQ": "",

View File

@@ -193,6 +193,8 @@
"match.timeline.teamSp": "",
"match.waitingForConfirmation": "",
"match.undoReport": "",
"match.weapon.yourWeapon": "",
"match.weapon.undoWeapon": "",
"match.confirmScore": "",
"match.confirmScore.wrongHint": "",
"preparing.joinQ": "Unisciti alla coda",

View File

@@ -193,6 +193,8 @@
"match.timeline.teamSp": "",
"match.waitingForConfirmation": "",
"match.undoReport": "",
"match.weapon.yourWeapon": "",
"match.weapon.undoWeapon": "",
"match.confirmScore": "",
"match.confirmScore.wrongHint": "",
"preparing.joinQ": "列に入る",

View File

@@ -193,6 +193,8 @@
"match.timeline.teamSp": "",
"match.waitingForConfirmation": "",
"match.undoReport": "",
"match.weapon.yourWeapon": "",
"match.weapon.undoWeapon": "",
"match.confirmScore": "",
"match.confirmScore.wrongHint": "",
"preparing.joinQ": "",

View File

@@ -193,6 +193,8 @@
"match.timeline.teamSp": "",
"match.waitingForConfirmation": "",
"match.undoReport": "",
"match.weapon.yourWeapon": "",
"match.weapon.undoWeapon": "",
"match.confirmScore": "",
"match.confirmScore.wrongHint": "",
"preparing.joinQ": "",

View File

@@ -193,6 +193,8 @@
"match.timeline.teamSp": "",
"match.waitingForConfirmation": "",
"match.undoReport": "",
"match.weapon.yourWeapon": "",
"match.weapon.undoWeapon": "",
"match.confirmScore": "",
"match.confirmScore.wrongHint": "",
"preparing.joinQ": "",

View File

@@ -193,6 +193,8 @@
"match.timeline.teamSp": "",
"match.waitingForConfirmation": "",
"match.undoReport": "",
"match.weapon.yourWeapon": "",
"match.weapon.undoWeapon": "",
"match.confirmScore": "",
"match.confirmScore.wrongHint": "",
"preparing.joinQ": "Entrar na fila",

View File

@@ -193,6 +193,8 @@
"match.timeline.teamSp": "",
"match.waitingForConfirmation": "",
"match.undoReport": "",
"match.weapon.yourWeapon": "",
"match.weapon.undoWeapon": "",
"match.confirmScore": "",
"match.confirmScore.wrongHint": "",
"preparing.joinQ": "Присоединиться к очереди",

View File

@@ -193,6 +193,8 @@
"match.timeline.teamSp": "",
"match.waitingForConfirmation": "",
"match.undoReport": "",
"match.weapon.yourWeapon": "",
"match.weapon.undoWeapon": "",
"match.confirmScore": "",
"match.confirmScore.wrongHint": "",
"preparing.joinQ": "开始匹配",