Consider also tournament matches for leaderboard weapon usage

This commit is contained in:
Kalle
2026-06-11 21:13:59 +03:00
parent 5a80c0a7fa
commit e064dfb4e7
2 changed files with 323 additions and 8 deletions

View 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({});
});
});

View File

@@ -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 }) => [