mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-20 01:55:26 -05:00
Consider also tournament matches for leaderboard weapon usage
This commit is contained in:
281
app/features/leaderboards/LeaderboardRepository.server.test.ts
Normal file
281
app/features/leaderboards/LeaderboardRepository.server.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { db } from "~/db/sql";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { dbInsertUsers, dbReset } from "~/utils/Test";
|
||||
import * as LeaderboardRepository from "./LeaderboardRepository.server";
|
||||
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "./leaderboards-constants";
|
||||
|
||||
const SEASON = Seasons.currentOrPrevious()!.nth;
|
||||
const SEASON_RANGE = Seasons.nthToDateRange(SEASON);
|
||||
const OVER_THRESHOLD = MATCHES_COUNT_NEEDED_FOR_LEADERBOARD + 1;
|
||||
const IN_SEASON_TIMESTAMP = dateToDatabaseTimestamp(SEASON_RANGE.starts);
|
||||
const OUT_OF_SEASON_TIMESTAMP = dateToDatabaseTimestamp(
|
||||
new Date(SEASON_RANGE.starts.getTime() - 60 * 1000),
|
||||
);
|
||||
|
||||
const createGroup = () =>
|
||||
db
|
||||
.insertInto("Group")
|
||||
.values({
|
||||
status: "INACTIVE",
|
||||
inviteCode: "test-invite-code",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const createGroupMatch = async (createdAt: number) => {
|
||||
const alphaGroup = await createGroup();
|
||||
const bravoGroup = await createGroup();
|
||||
|
||||
return db
|
||||
.insertInto("GroupMatch")
|
||||
.values({
|
||||
alphaGroupId: alphaGroup.id,
|
||||
bravoGroupId: bravoGroup.id,
|
||||
chatCode: "test-chat-code",
|
||||
createdAt,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
};
|
||||
|
||||
const createTournamentMatch = async ({
|
||||
isFinalized,
|
||||
}: {
|
||||
isFinalized: boolean;
|
||||
}) => {
|
||||
const tournament = await db
|
||||
.insertInto("Tournament")
|
||||
.values({
|
||||
mapPickingStyle: "TO",
|
||||
settings: JSON.stringify({ bracketProgression: [] }),
|
||||
isFinalized: isFinalized ? 1 : 0,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const stage = await db
|
||||
.insertInto("TournamentStage")
|
||||
.values({
|
||||
tournamentId: tournament.id,
|
||||
name: "Main bracket",
|
||||
number: 1,
|
||||
type: "double_elimination",
|
||||
settings: "{}",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const group = await db
|
||||
.insertInto("TournamentGroup")
|
||||
.values({ stageId: stage.id, number: 1 })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const round = await db
|
||||
.insertInto("TournamentRound")
|
||||
.values({
|
||||
stageId: stage.id,
|
||||
groupId: group.id,
|
||||
number: 1,
|
||||
maps: JSON.stringify({ count: 3, type: "BEST_OF" }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return db
|
||||
.insertInto("TournamentMatch")
|
||||
.values({
|
||||
stageId: stage.id,
|
||||
groupId: group.id,
|
||||
roundId: round.id,
|
||||
number: 1,
|
||||
status: 4,
|
||||
opponentOne: JSON.stringify({ id: null }),
|
||||
opponentTwo: JSON.stringify({ id: null }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
};
|
||||
|
||||
const reportSendouqWeapons = async (args: {
|
||||
userId: number;
|
||||
weaponSplId: MainWeaponId;
|
||||
count: number;
|
||||
matchCreatedAt?: number;
|
||||
}) => {
|
||||
const match = await createGroupMatch(
|
||||
args.matchCreatedAt ?? IN_SEASON_TIMESTAMP,
|
||||
);
|
||||
|
||||
await db
|
||||
.insertInto("ReportedWeapon")
|
||||
.values(
|
||||
Array.from({ length: args.count }, (_, mapIndex) => ({
|
||||
groupMatchId: match.id,
|
||||
mapIndex,
|
||||
userId: args.userId,
|
||||
weaponSplId: args.weaponSplId,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
};
|
||||
|
||||
const reportTournamentWeapons = async (args: {
|
||||
userId: number;
|
||||
weaponSplId: MainWeaponId;
|
||||
count: number;
|
||||
isFinalized?: boolean;
|
||||
createdAt?: number;
|
||||
}) => {
|
||||
const match = await createTournamentMatch({
|
||||
isFinalized: args.isFinalized ?? true,
|
||||
});
|
||||
|
||||
await db
|
||||
.insertInto("ReportedWeapon")
|
||||
.values(
|
||||
Array.from({ length: args.count }, (_, mapIndex) => ({
|
||||
tournamentMatchId: match.id,
|
||||
mapIndex,
|
||||
userId: args.userId,
|
||||
weaponSplId: args.weaponSplId,
|
||||
createdAt: args.createdAt ?? IN_SEASON_TIMESTAMP,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
};
|
||||
|
||||
describe("seasonPopularUsersWeapon", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(2);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("returns user's most reported SendouQ weapon", async () => {
|
||||
await reportSendouqWeapons({
|
||||
userId: 1,
|
||||
weaponSplId: 10,
|
||||
count: OVER_THRESHOLD,
|
||||
});
|
||||
|
||||
const result = await LeaderboardRepository.seasonPopularUsersWeapon(SEASON);
|
||||
|
||||
expect(result).toEqual({ 1: 10 });
|
||||
});
|
||||
|
||||
test("requires more reports than the threshold", async () => {
|
||||
await reportSendouqWeapons({
|
||||
userId: 1,
|
||||
weaponSplId: 10,
|
||||
count: MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
|
||||
});
|
||||
|
||||
const result = await LeaderboardRepository.seasonPopularUsersWeapon(SEASON);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test("counts weapons reported in finalized tournaments", async () => {
|
||||
await reportTournamentWeapons({
|
||||
userId: 1,
|
||||
weaponSplId: 1000,
|
||||
count: OVER_THRESHOLD,
|
||||
});
|
||||
|
||||
const result = await LeaderboardRepository.seasonPopularUsersWeapon(SEASON);
|
||||
|
||||
expect(result).toEqual({ 1: 1000 });
|
||||
});
|
||||
|
||||
test("ignores weapons reported in unfinalized tournaments", async () => {
|
||||
await reportTournamentWeapons({
|
||||
userId: 1,
|
||||
weaponSplId: 1000,
|
||||
count: OVER_THRESHOLD,
|
||||
isFinalized: false,
|
||||
});
|
||||
|
||||
const result = await LeaderboardRepository.seasonPopularUsersWeapon(SEASON);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test("combines SendouQ and tournament reports of the same weapon", async () => {
|
||||
const half = Math.ceil(OVER_THRESHOLD / 2);
|
||||
|
||||
await reportSendouqWeapons({ userId: 1, weaponSplId: 10, count: half });
|
||||
await reportTournamentWeapons({
|
||||
userId: 1,
|
||||
weaponSplId: 10,
|
||||
count: OVER_THRESHOLD - half,
|
||||
});
|
||||
|
||||
const result = await LeaderboardRepository.seasonPopularUsersWeapon(SEASON);
|
||||
|
||||
expect(result).toEqual({ 1: 10 });
|
||||
});
|
||||
|
||||
test("picks the most reported weapon across both sources", async () => {
|
||||
await reportSendouqWeapons({
|
||||
userId: 1,
|
||||
weaponSplId: 0,
|
||||
count: OVER_THRESHOLD + 1,
|
||||
});
|
||||
await reportSendouqWeapons({
|
||||
userId: 1,
|
||||
weaponSplId: 10,
|
||||
count: OVER_THRESHOLD - 3,
|
||||
});
|
||||
await reportTournamentWeapons({
|
||||
userId: 1,
|
||||
weaponSplId: 10,
|
||||
count: OVER_THRESHOLD - 3,
|
||||
});
|
||||
|
||||
const result = await LeaderboardRepository.seasonPopularUsersWeapon(SEASON);
|
||||
|
||||
expect(result).toEqual({ 1: 10 });
|
||||
});
|
||||
|
||||
test("returns weapons of multiple users", async () => {
|
||||
await reportSendouqWeapons({
|
||||
userId: 1,
|
||||
weaponSplId: 10,
|
||||
count: OVER_THRESHOLD,
|
||||
});
|
||||
await reportTournamentWeapons({
|
||||
userId: 2,
|
||||
weaponSplId: 1000,
|
||||
count: OVER_THRESHOLD,
|
||||
});
|
||||
|
||||
const result = await LeaderboardRepository.seasonPopularUsersWeapon(SEASON);
|
||||
|
||||
expect(result).toEqual({ 1: 10, 2: 1000 });
|
||||
});
|
||||
|
||||
test("ignores reports outside the season", async () => {
|
||||
await reportSendouqWeapons({
|
||||
userId: 1,
|
||||
weaponSplId: 10,
|
||||
count: OVER_THRESHOLD,
|
||||
matchCreatedAt: OUT_OF_SEASON_TIMESTAMP,
|
||||
});
|
||||
await reportTournamentWeapons({
|
||||
userId: 2,
|
||||
weaponSplId: 1000,
|
||||
count: OVER_THRESHOLD,
|
||||
createdAt: OUT_OF_SEASON_TIMESTAMP,
|
||||
});
|
||||
|
||||
const result = await LeaderboardRepository.seasonPopularUsersWeapon(SEASON);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -400,20 +400,54 @@ export async function seasonPopularUsersWeapon(
|
||||
season: number,
|
||||
): Promise<SeasonPopularUsersWeapon> {
|
||||
const { starts, ends } = Seasons.nthToDateRange(season);
|
||||
const startsTs = dateToDatabaseTimestamp(starts);
|
||||
const endsTs = dateToDatabaseTimestamp(ends);
|
||||
|
||||
const sendouqWeapons = db
|
||||
.selectFrom("ReportedWeapon")
|
||||
.innerJoin("GroupMatch", "ReportedWeapon.groupMatchId", "GroupMatch.id")
|
||||
.select(({ fn }) => [
|
||||
"ReportedWeapon.userId",
|
||||
"ReportedWeapon.weaponSplId",
|
||||
fn.countAll<number>().as("count"),
|
||||
])
|
||||
.where("GroupMatch.createdAt", ">=", startsTs)
|
||||
.where("GroupMatch.createdAt", "<=", endsTs)
|
||||
.groupBy(["ReportedWeapon.userId", "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.userId",
|
||||
"ReportedWeapon.weaponSplId",
|
||||
fn.countAll<number>().as("count"),
|
||||
])
|
||||
.where("Tournament.isFinalized", "=", 1)
|
||||
.where("ReportedWeapon.createdAt", ">=", startsTs)
|
||||
.where("ReportedWeapon.createdAt", "<=", endsTs)
|
||||
.groupBy(["ReportedWeapon.userId", "ReportedWeapon.weaponSplId"]);
|
||||
|
||||
const rows = await db
|
||||
.with("q1", (db) =>
|
||||
db
|
||||
.selectFrom("ReportedWeapon")
|
||||
.innerJoin("GroupMatch", "ReportedWeapon.groupMatchId", "GroupMatch.id")
|
||||
.selectFrom(sendouqWeapons.unionAll(tournamentWeapons).as("merged"))
|
||||
.select(({ fn }) => [
|
||||
"ReportedWeapon.userId",
|
||||
"ReportedWeapon.weaponSplId",
|
||||
fn.countAll<number>().as("count"),
|
||||
"merged.userId",
|
||||
"merged.weaponSplId",
|
||||
fn.sum<number>("merged.count").as("count"),
|
||||
])
|
||||
.where("GroupMatch.createdAt", ">=", dateToDatabaseTimestamp(starts))
|
||||
.where("GroupMatch.createdAt", "<=", dateToDatabaseTimestamp(ends))
|
||||
.groupBy(["ReportedWeapon.userId", "ReportedWeapon.weaponSplId"]),
|
||||
.groupBy(["merged.userId", "merged.weaponSplId"]),
|
||||
)
|
||||
.selectFrom("q1")
|
||||
.select(({ fn }) => [
|
||||
|
||||
Reference in New Issue
Block a user