Weapon usage (#1466)

* Initial

* Finished?

* Remove comment
This commit is contained in:
Kalle 2023-08-22 21:16:28 +03:00 committed by GitHub
parent 0e8993530b
commit d4b9dcd516
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 343 additions and 10 deletions

View File

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

View File

@ -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<WeaponUsageStat, "count" | "wins" | "losses"> & { 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);
}

View File

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

View File

@ -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<WeaponUsageLoaderData>(
getWeaponUsage(args),
fetcher
);
return {
weaponUsage: data?.usage,
isLoading: !error && !data,
isError: error,
};
}

View File

@ -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<SerializeFrom<typeof loader>["info"]["stages"]>;
}) {
const { t } = useTranslation(["game-misc"]);
const parentPageData = atOrError(useMatches(), -2).data as UserPageLoaderData;
return (
<div className="stack horizontal justify-center md flex-wrap">
{stageIds.map((id) => {
@ -361,22 +371,112 @@ function Stages({
)} ${winPercentage}${winPercentage ? "%" : ""}`;
return (
<div
<Popover
key={mode}
className="stack horizontal items-center xs text-xs font-semi-bold"
>
<ModeImage mode={mode} size={18} title={infoText} />
{stats ? (
<div>
{stats.wins}W {stats.losses}L
buttonChildren={
<div className="stack horizontal items-center xs text-xs font-semi-bold text-main-forced">
<ModeImage mode={mode} size={18} title={infoText} />
{stats ? (
<div>
{stats.wins}W {stats.losses}L
</div>
) : null}
</div>
) : null}
</div>
}
>
<StageWeaponUsageStats
modeShort={mode}
// TODO: dynamic season
season={0}
stageId={id}
userId={parentPageData.id}
/>
</Popover>
);
})}
</div>
);
})}
<div className="text-xs text-lighter font-semi-bold">
Click a row to show weapon usage stats
</div>
</div>
);
}
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 (
<div className="u__season__weapon-usage__container items-center justify-center text-lighter p-2">
Loading...
</div>
);
}
const usages = (weaponUsage ?? []).filter((u) => u.type === tab);
if (usages.length === 0) {
return (
<div className="u__season__weapon-usage__container items-center justify-center text-lighter p-2">
No reported weapons yet
</div>
);
}
return (
<div className="u__season__weapon-usage__container">
<div className="stack horizontal sm text-xs items-center justify-center">
<ModeImage mode={props.modeShort} width={18} />
{t(`game-misc:STAGE_${props.stageId}`)}
</div>
<Tabs compact className="mb-0">
<Tab active={tab === "SELF"} onClick={() => setTab("SELF")}>
Self
</Tab>
<Tab active={tab === "MATE"} onClick={() => setTab("MATE")}>
Teammates
</Tab>
<Tab active={tab === "ENEMY"} onClick={() => setTab("ENEMY")}>
Opponents
</Tab>
</Tabs>
<div className="u__season__weapon-usage__weapons-container">
{usages.map((u) => {
const winrate = cutToNDecimalPlaces(
(u.wins / (u.wins + u.losses)) * 100
);
return (
<div key={u.weaponSplId}>
<WeaponImage
weaponSplId={u.weaponSplId}
variant="build"
width={48}
className="u__season__weapon-usage__weapon"
/>
<div
className={clsx("text-xs font-bold", {
"text-success": winrate >= 50,
"text-warning": winrate < 50,
})}
>
{winrate}%
</div>
<div className="text-xs">{u.wins} W</div>
<div className="text-xs">{u.losses} L</div>
</div>
);
})}
</div>
</div>
);
}

View File

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

View File

@ -23,6 +23,26 @@ export function badRequestIfFalsy<T>(value: T | null | undefined): T {
return value;
}
export function parseSearchParams<T extends z.ZodTypeAny>({
request,
schema,
}: {
request: Request;
schema: T;
}): z.infer<T> {
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<T extends z.ZodTypeAny>({
request,

View File

@ -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"]) =>

View File

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