mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-06-03 06:35:42 -05:00
parent
0e8993530b
commit
d4b9dcd516
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
121
app/features/sendouq/queries/weaponUsageStats.server.ts
Normal file
121
app/features/sendouq/queries/weaponUsageStats.server.ts
Normal 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);
|
||||
}
|
||||
23
app/features/sendouq/routes/weapon-usage.tsx
Normal file
23
app/features/sendouq/routes/weapon-usage.tsx
Normal 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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]) =>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user