diff --git a/app/features/sendouq/q-schemas.server.ts b/app/features/sendouq/q-schemas.server.ts index 8a0060dd5..3706225bd 100644 --- a/app/features/sendouq/q-schemas.server.ts +++ b/app/features/sendouq/q-schemas.server.ts @@ -11,6 +11,8 @@ import { id, safeJSONParse, weaponSplId, + stageId, + modeShort, } from "~/utils/zod"; import { matchEndedAtIndex } from "./core/match"; @@ -114,3 +116,10 @@ export const matchSchema = z.union([ ), }), ]); + +export const weaponUsageSearchParamsSchema = z.object({ + userId: id, + season: z.coerce.number().int(), + stageId, + modeShort, +}); diff --git a/app/features/sendouq/queries/weaponUsageStats.server.ts b/app/features/sendouq/queries/weaponUsageStats.server.ts new file mode 100644 index 000000000..6ab2a4a0b --- /dev/null +++ b/app/features/sendouq/queries/weaponUsageStats.server.ts @@ -0,0 +1,121 @@ +import { sql } from "~/db/sql"; +import { seasonObject } from "~/features/mmr/season"; +import type { MainWeaponId, ModeShort, StageId } from "~/modules/in-game-lists"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { assertUnreachable } from "~/utils/types"; + +const stm = sql.prepare(/* sql */ ` + select + "ReportedWeapon"."weaponSplId", + "ReportedWeapon"."userId" as "weaponUserId", + "GroupMatchMap"."winnerGroupId", + "GroupMember"."groupId" as "ownerGroupId", + ( + select "groupId" + from "GroupMember" + where "GroupMember"."userId" = "ReportedWeapon"."userId" + and "GroupMember"."groupId" = "GroupMatch"."alphaGroupId" + or "GroupMember"."groupId" = "GroupMatch"."bravoGroupId" + ) as "weaponUserGroupId" + from "GroupMember" + left join "Group" on "Group"."id" = "GroupMember"."groupId" + inner join "GroupMatch" on + "GroupMatch"."alphaGroupId" = "Group"."id" + or "GroupMatch"."bravoGroupId" = "Group"."id" + left join "GroupMatchMap" on "GroupMatchMap"."matchId" = "GroupMatch"."id" + inner join "ReportedWeapon" on "ReportedWeapon"."groupMatchMapId" = "GroupMatchMap"."id" + where + "GroupMember"."userId" = @userId + and "GroupMatch"."createdAt" between @starts and @ends + and "GroupMatchMap"."mode" = @mode + and "GroupMatchMap"."stageId" = @stageId + and "GroupMatchMap"."winnerGroupId" is not null +`); + +interface WeaponUsageStat { + type: "SELF" | "MATE" | "ENEMY"; + weaponSplId: MainWeaponId; + count: number; + wins: number; + losses: number; +} + +export function weaponUsageStats({ + userId, + mode, + stageId, + season, +}: { + userId: number; + mode: ModeShort; + stageId: StageId; + season: number; +}) { + const { starts, ends } = seasonObject(season); + + const rows = stm.all({ + starts: dateToDatabaseTimestamp(starts), + ends: dateToDatabaseTimestamp(ends), + userId, + mode, + stageId, + }) as Array<{ + weaponSplId: MainWeaponId; + weaponUserId: number; + winnerGroupId: number; + ownerGroupId: number; + weaponUserGroupId: number; + }>; + + const result: WeaponUsageStat[] = []; + + const addDelta = ( + stat: Omit & { won: boolean } + ) => { + const existing = result.find( + (s) => s.weaponSplId === stat.weaponSplId && s.type === stat.type + ); + + if (existing) { + existing.count += 1; + if (stat.won) { + existing.wins += 1; + } else { + existing.losses += 1; + } + } else { + result.push({ + ...stat, + count: 1, + wins: stat.won ? 1 : 0, + losses: stat.won ? 0 : 1, + }); + } + }; + for (const row of rows) { + const type = + row.weaponUserId === userId + ? "SELF" + : row.weaponUserGroupId === row.ownerGroupId + ? "MATE" + : "ENEMY"; + + const won = () => { + const targetWon = row.winnerGroupId === row.ownerGroupId; + + if (type === "SELF") return targetWon; + if (type === "MATE") return targetWon; + if (type === "ENEMY") return !targetWon; + + assertUnreachable(type); + }; + + addDelta({ + type, + weaponSplId: row.weaponSplId, + won: won(), + }); + } + + return result.sort((a, b) => b.count - a.count); +} diff --git a/app/features/sendouq/routes/weapon-usage.tsx b/app/features/sendouq/routes/weapon-usage.tsx new file mode 100644 index 000000000..593ad0a07 --- /dev/null +++ b/app/features/sendouq/routes/weapon-usage.tsx @@ -0,0 +1,23 @@ +import { parseSearchParams } from "~/utils/remix"; +import { weaponUsageStats } from "../queries/weaponUsageStats.server"; +import type { LoaderArgs, SerializeFrom } from "@remix-run/node"; +import { weaponUsageSearchParamsSchema } from "../q-schemas.server"; +import type { ModeShort, StageId } from "~/modules/in-game-lists"; + +export type WeaponUsageLoaderData = SerializeFrom; + +export const loader = ({ request }: LoaderArgs) => { + const data = parseSearchParams({ + request, + schema: weaponUsageSearchParamsSchema, + }); + + return { + usage: weaponUsageStats({ + mode: data.modeShort as ModeShort, + season: data.season, + stageId: data.stageId as StageId, + userId: data.userId, + }), + }; +}; diff --git a/app/hooks/swr.ts b/app/hooks/swr.ts index 6d83bf160..ae55844b6 100644 --- a/app/hooks/swr.ts +++ b/app/hooks/swr.ts @@ -1,9 +1,12 @@ import useSWRImmutable from "swr/immutable"; +import type { WeaponUsageLoaderData } from "~/features/sendouq/routes/weapon-usage"; +import type { ModeShort, StageId } from "~/modules/in-game-lists"; import type { EventsWithMapPoolsLoaderData } from "~/routes/calendar/map-pool-events"; import type { UsersLoaderData } from "~/routes/users"; import { GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE, GET_ALL_USERS_ROUTE, + getWeaponUsage, } from "~/utils/urls"; const fetcher = async (url: string) => { @@ -36,3 +39,21 @@ export function useAllEventsWithMapPools() { isError: error, }; } + +export function useWeaponUsage(args: { + userId: number; + season: number; + modeShort: ModeShort; + stageId: StageId; +}) { + const { data, error } = useSWRImmutable( + getWeaponUsage(args), + fetcher + ); + + return { + weaponUsage: data?.usage, + isLoading: !error && !data, + isError: error, + }; +} diff --git a/app/routes/u.$identifier/seasons.tsx b/app/routes/u.$identifier/seasons.tsx index 1febe6660..c19266492 100644 --- a/app/routes/u.$identifier/seasons.tsx +++ b/app/routes/u.$identifier/seasons.tsx @@ -39,13 +39,21 @@ import { databaseTimestampToDate } from "~/utils/dates"; import { SubNav, SubNavLink } from "~/components/SubNav"; import { z } from "zod"; import { seasonStagesByUserId } from "~/features/sendouq/queries/seasonStagesByUserId.server"; -import { stageIds } from "~/modules/in-game-lists"; +import { + type ModeShort, + type StageId, + stageIds, +} from "~/modules/in-game-lists"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import { seasonsMatesEnemiesByUserId } from "~/features/sendouq/queries/seasonsMatesEnemiesByUserId.server"; import Chart from "~/components/Chart"; import { AlertIcon } from "~/components/icons/Alert"; import { seasonMapWinrateByUserId } from "~/features/sendouq/queries/seasonMapWinrateByUserId.server"; import { seasonSetWinrateByUserId } from "~/features/sendouq/queries/seasonSetWinrateByUserId.server"; +import { Popover } from "~/components/Popover"; +import { useWeaponUsage } from "~/hooks/swr"; +import { atOrError } from "~/utils/arrays"; +import { Tab, Tabs } from "~/components/Tabs"; export const seasonsSearchParamsSchema = z.object({ page: z.coerce.number().default(1), @@ -343,6 +351,8 @@ function Stages({ stages: NonNullable["info"]["stages"]>; }) { const { t } = useTranslation(["game-misc"]); + const parentPageData = atOrError(useMatches(), -2).data as UserPageLoaderData; + return (
{stageIds.map((id) => { @@ -361,22 +371,112 @@ function Stages({ )} ${winPercentage}${winPercentage ? "%" : ""}`; return ( -
- - {stats ? ( -
- {stats.wins}W {stats.losses}L + buttonChildren={ +
+ + {stats ? ( +
+ {stats.wins}W {stats.losses}L +
+ ) : null}
- ) : null} -
+ } + > + + ); })}
); })} +
+ Click a row to show weapon usage stats +
+
+ ); +} + +function StageWeaponUsageStats(props: { + userId: number; + season: number; + modeShort: ModeShort; + stageId: StageId; +}) { + const { t } = useTranslation(["game-misc"]); + const [tab, setTab] = React.useState<"SELF" | "MATE" | "ENEMY">("SELF"); + const { weaponUsage, isLoading } = useWeaponUsage(props); + + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + const usages = (weaponUsage ?? []).filter((u) => u.type === tab); + + if (usages.length === 0) { + return ( +
+ No reported weapons yet +
+ ); + } + + return ( +
+
+ + {t(`game-misc:STAGE_${props.stageId}`)} +
+ + setTab("SELF")}> + Self + + setTab("MATE")}> + Teammates + + setTab("ENEMY")}> + Opponents + + +
+ {usages.map((u) => { + const winrate = cutToNDecimalPlaces( + (u.wins / (u.wins + u.losses)) * 100 + ); + + return ( +
+ +
= 50, + "text-warning": winrate < 50, + })} + > + {winrate}% +
+
{u.wins} W
+
{u.losses} L
+
+ ); + })} +
); } diff --git a/app/styles/u.css b/app/styles/u.css index b59c54cdd..4d8591e80 100644 --- a/app/styles/u.css +++ b/app/styles/u.css @@ -394,3 +394,26 @@ font-size: var(--fonts-xs); font-weight: var(--semi-bold); } + +.u__season__weapon-usage__container { + display: flex; + flex-direction: column; + gap: var(--s-2); + width: 300px; + min-height: 175px; +} + +.u__season__weapon-usage__weapons-container { + display: flex; + flex-wrap: wrap; + gap: var(--s-2-5); + justify-content: center; + max-height: 300px; + overflow-y: auto; +} + +.u__season__weapon-usage__weapon { + border-radius: 100%; + background-color: var(--bg-lighter); + padding: var(--s-1-5); +} diff --git a/app/utils/remix.ts b/app/utils/remix.ts index 614a99cd0..b2ce04c07 100644 --- a/app/utils/remix.ts +++ b/app/utils/remix.ts @@ -23,6 +23,26 @@ export function badRequestIfFalsy(value: T | null | undefined): T { return value; } +export function parseSearchParams({ + request, + schema, +}: { + request: Request; + schema: T; +}): z.infer { + try { + const url = new URL(request.url); + return schema.parse(Object.fromEntries(url.searchParams)); + } catch (e) { + if (e instanceof z.ZodError) { + console.error(e); + throw new Response(JSON.stringify(e), { status: 400 }); + } + + throw e; + } +} + /** Parse formData of a request with the given schema. Throws HTTP 400 response if fails. */ export async function parseRequestFormData({ request, diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 63e6b5ad7..02bc4eba2 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -257,6 +257,20 @@ export const sendouQMatchPage = (id: GroupMatch["id"]) => { return `${SENDOUQ_PAGE}/match/${id}`; }; +export const getWeaponUsage = ({ + userId, + season, + modeShort, + stageId, +}: { + userId: number; + season: number; + modeShort: ModeShort; + stageId: StageId; +}) => { + return `/weapon-usage?userId=${userId}&season=${season}&modeShort=${modeShort}&stageId=${stageId}`; +}; + export const mapsPage = (eventId?: MapPoolMap["calendarEventId"]) => `/maps${eventId ? `?eventId=${eventId}` : ""}`; export const readonlyMapsPage = (eventId: CalendarEvent["id"]) => diff --git a/remix.config.js b/remix.config.js index 9fe808fa4..65daf62ef 100644 --- a/remix.config.js +++ b/remix.config.js @@ -107,6 +107,8 @@ module.exports = { route("/q/preparing", "features/sendouq/routes/q.preparing.tsx"); route("/q/match/:id", "features/sendouq/routes/q.match.$id.tsx"); + route("/weapon-usage", "features/sendouq/routes/weapon-usage.tsx"); + route("/tiers", "features/sendouq/routes/tiers.tsx"); }); },