sendou.ink/app/features/sendouq-match/ReportedWeaponRepository.server.ts

384 lines
9.4 KiB
TypeScript

import type { NotNull, Transaction } from "kysely";
import { db } from "~/db/sql";
import type { DB, TablesInsertable } from "~/db/tables";
import { actorId } from "~/features/auth/core/user.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { assertUnreachable } from "~/utils/types";
export function createMany(
weapons: TablesInsertable["ReportedWeapon"][],
trx?: Transaction<DB>,
) {
if (weapons.length === 0) return;
return (trx ?? db).insertInto("ReportedWeapon").values(weapons).execute();
}
export async function upsertOwn({
groupMatchId,
mapIndex,
weaponSplId,
}: Omit<TablesInsertable["ReportedWeapon"], "userId"> & {
groupMatchId: number;
mapIndex: number;
}) {
const userId = actorId();
await db
.deleteFrom("ReportedWeapon")
.where("groupMatchId", "=", groupMatchId)
.where("mapIndex", "=", mapIndex)
.where("userId", "=", userId)
.execute();
await db
.insertInto("ReportedWeapon")
.values({ groupMatchId, mapIndex, userId, weaponSplId })
.execute();
}
export async function replaceByMatchId(
matchId: number,
weapons: TablesInsertable["ReportedWeapon"][],
trx?: Transaction<DB>,
) {
const executor = trx ?? db;
await executor
.deleteFrom("ReportedWeapon")
.where("groupMatchId", "=", matchId)
.execute();
if (weapons.length > 0) {
await executor.insertInto("ReportedWeapon").values(weapons).execute();
}
}
export async function deleteOwnByMapIndex({
matchId,
mapIndex,
}: {
matchId: number;
mapIndex: number;
}) {
await db
.deleteFrom("ReportedWeapon")
.where("groupMatchId", "=", matchId)
.where("mapIndex", "=", mapIndex)
.where("userId", "=", actorId())
.execute();
}
export async function deleteByMapIndex(
{
matchId,
mapIndex,
}: {
matchId: number;
mapIndex: number;
},
trx?: Transaction<DB>,
) {
await (trx ?? db)
.deleteFrom("ReportedWeapon")
.where("groupMatchId", "=", matchId)
.where("mapIndex", "=", mapIndex)
.execute();
}
export async function findByMatchId(matchId: number) {
const rows = await db
.selectFrom("ReportedWeapon")
.select([
"ReportedWeapon.groupMatchId",
"ReportedWeapon.mapIndex",
"ReportedWeapon.weaponSplId",
"ReportedWeapon.userId",
])
.where("ReportedWeapon.groupMatchId", "=", matchId)
.orderBy("ReportedWeapon.mapIndex", "asc")
.orderBy("ReportedWeapon.userId", "asc")
.$narrowType<{ groupMatchId: NotNull }>()
.execute();
if (rows.length === 0) return null;
return rows;
}
export async function upsertOwnTournament({
tournamentMatchId,
mapIndex,
weaponSplId,
createdAt,
}: Omit<TablesInsertable["ReportedWeapon"], "userId"> & {
tournamentMatchId: number;
mapIndex: number;
createdAt: number;
}) {
const userId = actorId();
await db
.deleteFrom("ReportedWeapon")
.where("tournamentMatchId", "=", tournamentMatchId)
.where("mapIndex", "=", mapIndex)
.where("userId", "=", userId)
.execute();
await db
.insertInto("ReportedWeapon")
.values({ tournamentMatchId, mapIndex, userId, weaponSplId, createdAt })
.execute();
}
export async function deleteOwnByMapIndexTournament({
tournamentMatchId,
mapIndex,
}: {
tournamentMatchId: number;
mapIndex: number;
}) {
await db
.deleteFrom("ReportedWeapon")
.where("tournamentMatchId", "=", tournamentMatchId)
.where("mapIndex", "=", mapIndex)
.where("userId", "=", actorId())
.execute();
}
export async function deleteByMapIndexTournament({
tournamentMatchId,
mapIndex,
}: {
tournamentMatchId: number;
mapIndex: number;
}) {
await db
.deleteFrom("ReportedWeapon")
.where("tournamentMatchId", "=", tournamentMatchId)
.where("mapIndex", "=", mapIndex)
.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")
.select(({ fn }) => [
"ReportedWeapon.weaponSplId",
fn.countAll<number>().as("count"),
])
.where("ReportedWeapon.userId", "=", userId)
.where("ReportedWeapon.groupMatchId", "is not", null)
.where("ReportedWeapon.createdAt", ">=", startsTs)
.where("ReportedWeapon.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")
.select(({ fn }) => [
"ReportedWeapon.weaponSplId",
fn.countAll<number>().as("count"),
])
.where("ReportedWeapon.userId", "=", userId)
.where("Tournament.isFinalized", "=", 1)
.where("ReportedWeapon.createdAt", ">=", startsTs)
.where("ReportedWeapon.createdAt", "<=", 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;
}
export interface WeaponUsageStat {
type: "SELF" | "MATE" | "ENEMY";
weaponSplId: MainWeaponId;
count: number;
wins: number;
losses: number;
}
/**
* Reports how often a user and the mates/enemies they played against used each
* weapon on a given stage and mode during a season, along with win/loss counts.
*/
export async function weaponUsageStats({
userId,
mode,
stageId,
season,
}: {
userId: number;
mode: ModeShort;
stageId: StageId;
season: number;
}): Promise<WeaponUsageStat[]> {
const { starts, ends } = Seasons.nthToDateRange(season);
const rows = await db
.selectFrom("GroupMember")
// cross join pins the join order so SQLite starts from the user's own
// groups instead of scanning every GroupMatch in the season's date range
.crossJoin("GroupMatch")
.leftJoin("GroupMatchMap", "GroupMatchMap.matchId", "GroupMatch.id")
.innerJoin("ReportedWeapon", (join) =>
join
.onRef("ReportedWeapon.groupMatchId", "=", "GroupMatch.id")
.onRef("ReportedWeapon.mapIndex", "=", "GroupMatchMap.index"),
)
.select((eb) => [
"ReportedWeapon.weaponSplId",
"ReportedWeapon.userId as weaponUserId",
"GroupMatchMap.winnerGroupId",
"GroupMember.groupId as ownerGroupId",
eb
.selectFrom("GroupMember as weaponGroupMember")
.select("weaponGroupMember.groupId")
.where((weaponEb) =>
weaponEb.or([
weaponEb.and([
weaponEb(
"weaponGroupMember.userId",
"=",
eb.ref("ReportedWeapon.userId"),
),
weaponEb(
"weaponGroupMember.groupId",
"=",
eb.ref("GroupMatch.alphaGroupId"),
),
]),
weaponEb(
"weaponGroupMember.groupId",
"=",
eb.ref("GroupMatch.bravoGroupId"),
),
]),
)
.as("weaponUserGroupId"),
])
.where("GroupMember.userId", "=", userId)
.where((eb) =>
eb.or([
eb("GroupMatch.alphaGroupId", "=", eb.ref("GroupMember.groupId")),
eb("GroupMatch.bravoGroupId", "=", eb.ref("GroupMember.groupId")),
]),
)
.where("GroupMatch.createdAt", ">=", dateToDatabaseTimestamp(starts))
.where("GroupMatch.createdAt", "<=", dateToDatabaseTimestamp(ends))
.where("GroupMatchMap.mode", "=", mode)
.where("GroupMatchMap.stageId", "=", stageId)
.where("GroupMatchMap.winnerGroupId", "is not", null)
.execute();
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);
}