mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-14 23:26:15 -05:00
Allow skipping teams on team leaderboard by staff
This commit is contained in:
@@ -551,6 +551,16 @@ export interface SkillTeamUser {
|
||||
userId: number;
|
||||
}
|
||||
|
||||
/** A team that is shown on the team leaderboard but doesn't count for its placements, e.g. because its players want to qualify with another roster. */
|
||||
export interface LeaderboardTeamSkip {
|
||||
id: GeneratedAlways<number>;
|
||||
season: number;
|
||||
/** The team's roster, same as `Skill.identifier`. */
|
||||
identifier: SkillTeamIdentifier;
|
||||
skippedByUserId: number;
|
||||
createdAt: Generated<number>;
|
||||
}
|
||||
|
||||
/** Used for tournament auto-seeding. Calculates off tournament matches same as SP but does not have seasonal resets. */
|
||||
export interface SeedingSkill {
|
||||
mu: number;
|
||||
@@ -1343,6 +1353,7 @@ export interface DB {
|
||||
ReportedWeapon: ReportedWeapon;
|
||||
Skill: Skill;
|
||||
SkillTeamUser: SkillTeamUser;
|
||||
LeaderboardTeamSkip: LeaderboardTeamSkip;
|
||||
SeedingSkill: SeedingSkill;
|
||||
SplatoonPlayer: SplatoonPlayer;
|
||||
/** VIEW over `AllTeam`, excludes soft-deleted teams. Insert/update via `AllTeam`. */
|
||||
|
||||
@@ -7,12 +7,14 @@ vi.mock("~/features/chat/ChatSystemMessage.server", () => ({
|
||||
setMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
import { actAs } from "~/db/seed/core/actAs";
|
||||
import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory";
|
||||
import * as SQReportedWeaponFactory from "~/db/seed/factories/SQReportedWeaponFactory";
|
||||
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as TournamentReportedWeaponFactory from "~/db/seed/factories/TournamentReportedWeaponFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { userIdsToIdentifier } from "~/features/mmr/mmr-utils";
|
||||
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
@@ -25,7 +27,7 @@ const OVER_THRESHOLD = MATCHES_COUNT_NEEDED_FOR_LEADERBOARD + 1;
|
||||
const IN_SEASON = SEASON_RANGE.starts;
|
||||
const OUT_OF_SEASON = new Date(SEASON_RANGE.starts.getTime() - 60 * 1000);
|
||||
|
||||
/** The first two report their weapons; the rest fill out their SendouQ groups. */
|
||||
/** Players of the tests' SendouQ groups. Weapon reports come from the first two. */
|
||||
const users = UserFactory.pool();
|
||||
|
||||
const createSendouqMatch = (createdAt: Date) =>
|
||||
@@ -233,3 +235,81 @@ describe("findSeasonPopularUsersWeapon", () => {
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LeaderboardRepository.findTeamLeaderboardBySeason", () => {
|
||||
/** The two rosters share three of their players, both beating the third one. */
|
||||
const topRoster = () => [users.id(1), users.id(2), users.id(3), users.id(4)];
|
||||
const sharedPlayersRoster = () => [
|
||||
users.id(1),
|
||||
users.id(2),
|
||||
users.id(3),
|
||||
users.id(9),
|
||||
];
|
||||
const beatenRoster = () => [
|
||||
users.id(5),
|
||||
users.id(6),
|
||||
users.id(7),
|
||||
users.id(8),
|
||||
];
|
||||
|
||||
const playSeasonInWith = async (alphaUserIds: number[]) => {
|
||||
for (let i = 0; i < MATCHES_COUNT_NEEDED_FOR_LEADERBOARD; i++) {
|
||||
await SQMatchFactory.create(
|
||||
{ alphaUserIds, bravoUserIds: beatenRoster() },
|
||||
{ isConcluded: true, createdAt: IN_SEASON },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const skipTeam = (userIds: number[]) =>
|
||||
actAs(users.id(1), () =>
|
||||
LeaderboardRepository.insertTeamSkip({
|
||||
season: SEASON,
|
||||
identifier: userIdsToIdentifier(userIds),
|
||||
}),
|
||||
);
|
||||
|
||||
const placements = async () =>
|
||||
(
|
||||
await LeaderboardRepository.findTeamLeaderboardBySeason({
|
||||
season: SEASON,
|
||||
onlyOneEntryPerUser: true,
|
||||
})
|
||||
).map((entry) => [entry.identifier, entry.placementRank]);
|
||||
|
||||
beforeEach(async () => {
|
||||
await users.create(9);
|
||||
await playSeasonInWith(topRoster());
|
||||
await playSeasonInWith(sharedPlayersRoster());
|
||||
});
|
||||
|
||||
test("shows only the highest placing roster of each player", async () => {
|
||||
expect(await placements()).toEqual([
|
||||
[userIdsToIdentifier(topRoster()), 1],
|
||||
[userIdsToIdentifier(beatenRoster()), 2],
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps a skipped team in its spot without a placement, freeing its players' other roster", async () => {
|
||||
await skipTeam(topRoster());
|
||||
|
||||
expect(await placements()).toEqual([
|
||||
[userIdsToIdentifier(topRoster()), null],
|
||||
[userIdsToIdentifier(sharedPlayersRoster()), 1],
|
||||
[userIdsToIdentifier(beatenRoster()), 2],
|
||||
]);
|
||||
});
|
||||
|
||||
test("gives a team its placement back when it is unskipped", async () => {
|
||||
await skipTeam(topRoster());
|
||||
await LeaderboardRepository.deleteTeamSkip({
|
||||
season: SEASON,
|
||||
identifier: userIdsToIdentifier(topRoster()),
|
||||
});
|
||||
|
||||
expect(await placements()).toEqual([
|
||||
[userIdsToIdentifier(topRoster()), 1],
|
||||
[userIdsToIdentifier(beatenRoster()), 2],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { sql } from "kysely";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { actorId } from "~/features/auth/core/user.server";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
RankedModeShort,
|
||||
@@ -16,10 +17,9 @@ import {
|
||||
} from "~/utils/kysely.server";
|
||||
import { dateToDatabaseTimestamp } from "../../utils/dates";
|
||||
import * as Seasons from "../mmr/core/Seasons";
|
||||
import { ordinalToSp } from "../mmr/mmr-utils";
|
||||
import { ordinalToSp, type SkillTeamIdentifier } from "../mmr/mmr-utils";
|
||||
import {
|
||||
DEFAULT_LEADERBOARD_MAX_SIZE,
|
||||
IGNORED_TEAMS,
|
||||
MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
|
||||
} from "./leaderboards-constants";
|
||||
|
||||
@@ -33,11 +33,16 @@ function addPowers<T extends { ordinal: number }>(entries: T[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
function addPlacementRank<T>(entries: T[]) {
|
||||
return entries.map((entry, index) => ({
|
||||
...entry,
|
||||
placementRank: index + 1,
|
||||
}));
|
||||
/** Numbers the entries by placement. A skipped team keeps its spot in the order but takes no number, the one below it getting the number it would have had. */
|
||||
function addPlacementRank<T extends { isSkipped: boolean }>(entries: T[]) {
|
||||
let placementRank = 0;
|
||||
|
||||
return entries.map((entry): T & { placementRank: number | null } => {
|
||||
if (entry.isSkipped) return { ...entry, placementRank: null };
|
||||
|
||||
placementRank++;
|
||||
return { ...entry, placementRank };
|
||||
});
|
||||
}
|
||||
|
||||
const teamLeaderboardBySeasonQuery = (season: number) =>
|
||||
@@ -47,7 +52,11 @@ const teamLeaderboardBySeasonQuery = (season: number) =>
|
||||
.selectFrom(
|
||||
latestSkillPerSeason({ season, by: "identifier" }).as("LatestOfTeam"),
|
||||
)
|
||||
.select(["LatestOfTeam.latestId as entryId", "LatestOfTeam.ordinal"])
|
||||
.select([
|
||||
"LatestOfTeam.latestId as entryId",
|
||||
"LatestOfTeam.ordinal",
|
||||
"LatestOfTeam.identifier",
|
||||
])
|
||||
.where(
|
||||
"LatestOfTeam.matchesCount",
|
||||
">=",
|
||||
@@ -60,6 +69,7 @@ const teamLeaderboardBySeasonQuery = (season: number) =>
|
||||
.select((eb) => [
|
||||
"Entry.entryId",
|
||||
"Entry.ordinal",
|
||||
"Entry.identifier",
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("SkillTeamUser")
|
||||
@@ -95,10 +105,14 @@ const teamLeaderboardBySeasonQuery = (season: number) =>
|
||||
.whereRef("SkillTeamUser.skillId", "=", "Entry.entryId"),
|
||||
).as("teams"),
|
||||
])
|
||||
.orderBy("Entry.ordinal", "desc");
|
||||
.orderBy("Entry.ordinal", "desc")
|
||||
.$narrowType<{ identifier: SkillTeamIdentifier }>();
|
||||
type TeamLeaderboardBySeasonQueryReturnType = InferResult<
|
||||
ReturnType<typeof teamLeaderboardBySeasonQuery>
|
||||
>;
|
||||
type TeamLeaderboardEntry = TeamLeaderboardBySeasonQueryReturnType[number] & {
|
||||
isSkipped: boolean;
|
||||
};
|
||||
|
||||
export async function findTeamLeaderboardBySeason({
|
||||
season,
|
||||
@@ -107,25 +121,35 @@ export async function findTeamLeaderboardBySeason({
|
||||
season: number;
|
||||
onlyOneEntryPerUser: boolean;
|
||||
}) {
|
||||
const entries = await teamLeaderboardBySeasonQuery(season).execute();
|
||||
const entries = addSkipped({
|
||||
entries: await teamLeaderboardBySeasonQuery(season).execute(),
|
||||
skippedIdentifiers: await findAllTeamSkipIdentifiersBySeason(season),
|
||||
});
|
||||
const withNonSqPlayersHandled = onlyOneEntryPerUser
|
||||
? await filterOutNonSqPlayers({ season, entries })
|
||||
: entries;
|
||||
const withIgnoredHandled = onlyOneEntryPerUser
|
||||
? ignoreTeams({ season, entries: withNonSqPlayersHandled })
|
||||
: withNonSqPlayersHandled;
|
||||
|
||||
const oneEntryPerUser = onlyOneEntryPerUser
|
||||
? filterOneEntryPerUser(withIgnoredHandled)
|
||||
: withIgnoredHandled;
|
||||
? filterOneEntryPerUser(withNonSqPlayersHandled)
|
||||
: withNonSqPlayersHandled;
|
||||
const withSharedTeam = resolveSharedTeam(oneEntryPerUser);
|
||||
const withPower = addPowers(withSharedTeam);
|
||||
|
||||
return addPlacementRank(withPower);
|
||||
}
|
||||
|
||||
async function filterOutNonSqPlayers(args: {
|
||||
function addSkipped(args: {
|
||||
entries: TeamLeaderboardBySeasonQueryReturnType;
|
||||
skippedIdentifiers: Set<SkillTeamIdentifier>;
|
||||
}): TeamLeaderboardEntry[] {
|
||||
return args.entries.map((entry) => ({
|
||||
...entry,
|
||||
isSkipped: args.skippedIdentifiers.has(entry.identifier),
|
||||
}));
|
||||
}
|
||||
|
||||
async function filterOutNonSqPlayers(args: {
|
||||
entries: TeamLeaderboardEntry[];
|
||||
season: number;
|
||||
}) {
|
||||
const validUserIds = new Set(
|
||||
@@ -193,11 +217,13 @@ export async function hasEnoughSqMatchesByUserId(userId: number) {
|
||||
return rows.count >= MATCHES_COUNT_NEEDED_FOR_LEADERBOARD;
|
||||
}
|
||||
|
||||
function filterOneEntryPerUser(
|
||||
entries: TeamLeaderboardBySeasonQueryReturnType,
|
||||
) {
|
||||
/** The highest placing entry of each user. A skipped team is always kept and doesn't spend
|
||||
* its players' entry, so a roster sharing players with it can still place below it. */
|
||||
function filterOneEntryPerUser(entries: TeamLeaderboardEntry[]) {
|
||||
const encounteredUserIds = new Set<number>();
|
||||
return entries.filter((entry) => {
|
||||
if (entry.isSkipped) return true;
|
||||
|
||||
if (entry.members.some((m) => encounteredUserIds.has(m.id))) {
|
||||
return false;
|
||||
}
|
||||
@@ -232,28 +258,37 @@ function resolveSharedTeam(entries: ReturnType<typeof filterOneEntryPerUser>) {
|
||||
});
|
||||
}
|
||||
|
||||
function ignoreTeams({
|
||||
season,
|
||||
entries,
|
||||
}: {
|
||||
async function findAllTeamSkipIdentifiersBySeason(season: number) {
|
||||
const rows = await db
|
||||
.selectFrom("LeaderboardTeamSkip")
|
||||
.select("LeaderboardTeamSkip.identifier")
|
||||
.where("LeaderboardTeamSkip.season", "=", season)
|
||||
.execute();
|
||||
|
||||
return new Set(rows.map((row) => row.identifier));
|
||||
}
|
||||
|
||||
/** Marks a team as not counting for the season's team leaderboard placements. Records the acting user as `skippedByUserId`. */
|
||||
export async function insertTeamSkip(args: {
|
||||
season: number;
|
||||
entries: TeamLeaderboardBySeasonQueryReturnType;
|
||||
identifier: SkillTeamIdentifier;
|
||||
}) {
|
||||
const ignoredTeams = IGNORED_TEAMS.get(season);
|
||||
await db
|
||||
.insertInto("LeaderboardTeamSkip")
|
||||
.values({ ...args, skippedByUserId: actorId() })
|
||||
.onConflict((oc) => oc.columns(["season", "identifier"]).doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
if (!ignoredTeams) return entries;
|
||||
|
||||
return entries.filter((entry) => {
|
||||
if (
|
||||
ignoredTeams.some((team) =>
|
||||
team.every((userId) => entry.members.some((m) => m.id === userId)),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
export async function deleteTeamSkip(args: {
|
||||
season: number;
|
||||
identifier: SkillTeamIdentifier;
|
||||
}) {
|
||||
await db
|
||||
.deleteFrom("LeaderboardTeamSkip")
|
||||
.where("LeaderboardTeamSkip.season", "=", args.season)
|
||||
.where("LeaderboardTeamSkip.identifier", "=", args.identifier)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findSeasonsParticipatedInByUserId(userId: number) {
|
||||
|
||||
51
app/features/leaderboards/actions/leaderboards.server.ts
Normal file
51
app/features/leaderboards/actions/leaderboards.server.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { parseRequestPayload } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { clearCachedTeamLeaderboards } from "../core/leaderboards.server";
|
||||
import { leaderboardsActionSchema } from "../leaderboards-schemas";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
requireRole("STAFF");
|
||||
const user = requireUser();
|
||||
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: leaderboardsActionSchema,
|
||||
});
|
||||
|
||||
switch (data._action) {
|
||||
case "SKIP_TEAM": {
|
||||
await LeaderboardRepository.insertTeamSkip({
|
||||
season: data.season,
|
||||
identifier: data.identifier,
|
||||
});
|
||||
logger.info(
|
||||
`Team leaderboard: user ${user.id} skipped team ${data.identifier} of season ${data.season}`,
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
case "UNSKIP_TEAM": {
|
||||
await LeaderboardRepository.deleteTeamSkip({
|
||||
season: data.season,
|
||||
identifier: data.identifier,
|
||||
});
|
||||
logger.info(
|
||||
`Team leaderboard: user ${user.id} unskipped team ${data.identifier} of season ${data.season}`,
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
clearCachedTeamLeaderboards(data.season);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -48,6 +48,44 @@ export async function cachedFullUserLeaderboard(season: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function cachedTeamLeaderboard({
|
||||
season,
|
||||
onlyOneEntryPerUser,
|
||||
}: {
|
||||
season: number;
|
||||
onlyOneEntryPerUser: boolean;
|
||||
}) {
|
||||
return cachified({
|
||||
key: teamLeaderboardCacheKey({ season, onlyOneEntryPerUser }),
|
||||
cache,
|
||||
ttl: ttl(IN_MILLISECONDS.HALF_HOUR),
|
||||
staleWhileRevalidate: ttl(IN_MILLISECONDS.TWO_HOURS),
|
||||
async getFreshValue() {
|
||||
return LeaderboardRepository.findTeamLeaderboardBySeason({
|
||||
season,
|
||||
onlyOneEntryPerUser,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Clears both variants of a season's cached team leaderboard, so that a change to which teams are skipped shows without waiting for the cache to expire. */
|
||||
export function clearCachedTeamLeaderboards(season: number) {
|
||||
for (const onlyOneEntryPerUser of [true, false]) {
|
||||
cache.delete(teamLeaderboardCacheKey({ season, onlyOneEntryPerUser }));
|
||||
}
|
||||
}
|
||||
|
||||
function teamLeaderboardCacheKey({
|
||||
season,
|
||||
onlyOneEntryPerUser,
|
||||
}: {
|
||||
season: number;
|
||||
onlyOneEntryPerUser: boolean;
|
||||
}) {
|
||||
return `team-leaderboard-season-${season}-${onlyOneEntryPerUser ? "TEAM" : "TEAM-ALL"}`;
|
||||
}
|
||||
|
||||
async function addTiers<T extends UserSPLeaderboardItem>(
|
||||
entries: T[],
|
||||
season: number,
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
export const MATCHES_COUNT_NEEDED_FOR_LEADERBOARD = 7;
|
||||
export const DEFAULT_LEADERBOARD_MAX_SIZE = 500;
|
||||
export const WEAPON_LEADERBOARD_MAX_SIZE = 100;
|
||||
/** How many teams of the team leaderboard qualify, the divider being shown below the last of them. */
|
||||
export const TEAM_LEADERBOARD_QUALIFYING_COUNT = 12;
|
||||
|
||||
export const LEADERBOARD_TYPES = [
|
||||
"USER",
|
||||
@@ -23,10 +25,3 @@ export const LEADERBOARD_TYPES = [
|
||||
(id) => `XP-WEAPON-${id}`,
|
||||
) as `XP-WEAPON-${(typeof mainWeaponIds)[number]}`[]),
|
||||
] as const;
|
||||
|
||||
/** Teams that are ignored from the main leaderboard, because e.g. they want to qualify with another group.
|
||||
* Map key is season.
|
||||
*/
|
||||
export const IGNORED_TEAMS: Map<number, number[][]> = new Map().set(5, [
|
||||
[9403, 13562, 15916, 38062], // Snooze
|
||||
]);
|
||||
|
||||
22
app/features/leaderboards/leaderboards-schemas.ts
Normal file
22
app/features/leaderboards/leaderboards-schemas.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod";
|
||||
import type { SkillTeamIdentifier } from "~/features/mmr/mmr-utils";
|
||||
import { _action } from "~/utils/zod";
|
||||
|
||||
const teamLeaderboardEntry = {
|
||||
season: z.coerce.number().int().nonnegative(),
|
||||
identifier: z
|
||||
.string()
|
||||
.regex(/^\d+-\d+-\d+-\d+$/)
|
||||
.pipe(z.custom<SkillTeamIdentifier>()),
|
||||
};
|
||||
|
||||
export const leaderboardsActionSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("SKIP_TEAM"),
|
||||
...teamLeaderboardEntry,
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("UNSKIP_TEAM"),
|
||||
...teamLeaderboardEntry,
|
||||
}),
|
||||
]);
|
||||
@@ -1,4 +1,3 @@
|
||||
import { cachified } from "@epic-web/cachified";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
|
||||
@@ -8,9 +7,9 @@ import type {
|
||||
RankedModeShort,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { weaponCategories } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
|
||||
import {
|
||||
cachedFullUserLeaderboard,
|
||||
cachedTeamLeaderboard,
|
||||
filterByWeaponCategory,
|
||||
ownEntryPeek,
|
||||
shownUserLeaderboard,
|
||||
@@ -36,17 +35,9 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
|
||||
|
||||
const teamLeaderboard =
|
||||
type === "TEAM" || type === "TEAM-ALL"
|
||||
? await cachified({
|
||||
key: `team-leaderboard-season-${season}-${type}`,
|
||||
cache,
|
||||
ttl: ttl(IN_MILLISECONDS.HALF_HOUR),
|
||||
staleWhileRevalidate: ttl(IN_MILLISECONDS.TWO_HOURS),
|
||||
async getFreshValue() {
|
||||
return LeaderboardRepository.findTeamLeaderboardBySeason({
|
||||
season,
|
||||
onlyOneEntryPerUser: type !== "TEAM-ALL",
|
||||
});
|
||||
},
|
||||
? await cachedTeamLeaderboard({
|
||||
season,
|
||||
onlyOneEntryPerUser: type !== "TEAM-ALL",
|
||||
})
|
||||
: null;
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ import { leaderboardsSearchParams } from "../leaderboards-search-params";
|
||||
import LeaderboardsPage from "./leaderboards";
|
||||
|
||||
vi.mock("../loaders/leaderboards.server", () => ({ loader: vi.fn() }));
|
||||
vi.mock("../actions/leaderboards.server", () => ({ action: vi.fn() }));
|
||||
const mocks = vi.hoisted(() => ({
|
||||
user: null as { id: number; roles: Array<string> } | null,
|
||||
}));
|
||||
vi.mock("~/features/auth/core/user", () => ({ useUser: () => mocks.user }));
|
||||
|
||||
const xpLeaderboardData = {
|
||||
userLeaderboard: undefined,
|
||||
@@ -14,12 +19,56 @@ const xpLeaderboardData = {
|
||||
season: 9,
|
||||
};
|
||||
|
||||
function renderPage() {
|
||||
const teamEntry = ({
|
||||
entryId,
|
||||
username,
|
||||
isSkipped,
|
||||
placementRank,
|
||||
}: {
|
||||
entryId: number;
|
||||
username: string;
|
||||
isSkipped: boolean;
|
||||
placementRank: number | null;
|
||||
}) => ({
|
||||
entryId,
|
||||
identifier: `${entryId}-2-3-4`,
|
||||
ordinal: 25,
|
||||
power: 1500,
|
||||
isSkipped,
|
||||
placementRank,
|
||||
members: [
|
||||
{ id: entryId, username, discordId: `${entryId}`, customUrl: null },
|
||||
],
|
||||
team: undefined,
|
||||
});
|
||||
|
||||
const teamLeaderboardData = {
|
||||
userLeaderboard: undefined,
|
||||
ownEntryPeek: null,
|
||||
teamLeaderboard: [
|
||||
teamEntry({
|
||||
entryId: 1,
|
||||
username: "skipped_team_player",
|
||||
isSkipped: true,
|
||||
placementRank: null,
|
||||
}),
|
||||
teamEntry({
|
||||
entryId: 2,
|
||||
username: "placing_team_player",
|
||||
isSkipped: false,
|
||||
placementRank: 1,
|
||||
}),
|
||||
],
|
||||
xpLeaderboard: null,
|
||||
season: 9,
|
||||
};
|
||||
|
||||
function renderPage(loaderData: unknown) {
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "*",
|
||||
element: <LeaderboardsPage />,
|
||||
loader: () => xpLeaderboardData,
|
||||
loader: () => loaderData,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -28,8 +77,19 @@ function renderPage() {
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
mocks.user = null;
|
||||
});
|
||||
|
||||
const showTeamLeaderboard = () =>
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
leaderboardsSearchParams.href(window.location.pathname, {
|
||||
type: "TEAM",
|
||||
season: 9,
|
||||
}),
|
||||
);
|
||||
|
||||
describe("LeaderboardsPage", () => {
|
||||
test("type select shows the selected XP leaderboard instead of the default SP leaderboard", async () => {
|
||||
window.history.replaceState(
|
||||
@@ -41,11 +101,55 @@ describe("LeaderboardsPage", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const screen = await renderPage();
|
||||
const screen = await renderPage(xpLeaderboardData);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
await expect.element(select).toBeVisible();
|
||||
|
||||
expect((select.element() as HTMLSelectElement).value).toBe("XP-ALL");
|
||||
});
|
||||
|
||||
test("crosses out the players of a skipped team", async () => {
|
||||
showTeamLeaderboard();
|
||||
|
||||
const screen = await renderPage(teamLeaderboardData);
|
||||
|
||||
const skipped = screen.getByRole("link", { name: "skipped_team_player" });
|
||||
await expect.element(skipped).toBeVisible();
|
||||
const placing = screen.getByRole("link", { name: "placing_team_player" });
|
||||
|
||||
expect(textDecorationLineOfRow(skipped.element())).toBe("line-through");
|
||||
expect(textDecorationLineOfRow(placing.element())).toBe("none");
|
||||
});
|
||||
|
||||
test("hides the skip menu from a user without a staff role", async () => {
|
||||
showTeamLeaderboard();
|
||||
|
||||
const screen = await renderPage(teamLeaderboardData);
|
||||
await expect
|
||||
.element(screen.getByRole("link", { name: "placing_team_player" }))
|
||||
.toBeVisible();
|
||||
|
||||
expect(screen.getByRole("button", { name: "Actions" }).all()).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("offers staff the actions of every team", async () => {
|
||||
showTeamLeaderboard();
|
||||
mocks.user = { id: 1, roles: ["STAFF"] };
|
||||
|
||||
const screen = await renderPage(teamLeaderboardData);
|
||||
await expect
|
||||
.element(screen.getByRole("link", { name: "placing_team_player" }))
|
||||
.toBeVisible();
|
||||
|
||||
expect(screen.getByRole("button", { name: "Actions" }).all()).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function textDecorationLineOfRow(memberLink: Element) {
|
||||
return getComputedStyle(memberLink.parentElement!).textDecorationLine;
|
||||
}
|
||||
|
||||
7
app/features/leaderboards/routes/leaderboards.module.css
Normal file
7
app/features/leaderboards/routes/leaderboards.module.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.skippedTeam {
|
||||
text-decoration: line-through;
|
||||
|
||||
& a {
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
import clsx from "clsx";
|
||||
import { Ban, MoreHorizontal, RotateCcw } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
|
||||
import { TierImage, WeaponImage } from "~/components/Image";
|
||||
import { Main } from "~/components/Main";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { ordinalToSp } from "~/features/mmr/mmr-utils";
|
||||
import type { SkillTierInterval } from "~/features/mmr/tiered.server";
|
||||
import { useActionSubmit } from "~/hooks/useActionSubmit";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { weaponCategories } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
|
||||
import { metaTags, type SerializeFrom } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
@@ -22,16 +28,22 @@ import {
|
||||
userSeasonsPage,
|
||||
} from "~/utils/urls";
|
||||
import { InfoPopover } from "../../../components/InfoPopover";
|
||||
import { action } from "../actions/leaderboards.server";
|
||||
import { TopTenPlayer } from "../components/TopTenPlayer";
|
||||
import type { XPLeaderboardItem } from "../LeaderboardRepository.server";
|
||||
import { LEADERBOARD_TYPES } from "../leaderboards-constants";
|
||||
import {
|
||||
LEADERBOARD_TYPES,
|
||||
TEAM_LEADERBOARD_QUALIFYING_COUNT,
|
||||
} from "../leaderboards-constants";
|
||||
import { leaderboardsActionSchema } from "../leaderboards-schemas";
|
||||
import { leaderboardsSearchParams } from "../leaderboards-search-params";
|
||||
import { seasonHasTopTen } from "../leaderboards-utils";
|
||||
import { loader } from "../loaders/leaderboards.server";
|
||||
|
||||
export { loader };
|
||||
export { action, loader };
|
||||
|
||||
import styles from "../../top-search/top-search.module.css";
|
||||
import leaderboardsStyles from "./leaderboards.module.css";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["vods"],
|
||||
@@ -344,13 +356,14 @@ function TeamTable({
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const isStaff = useHasRole("STAFF");
|
||||
const isCurrentSeason = data.season === Seasons.current()?.nth;
|
||||
const showQualificationDividers =
|
||||
_showQualificationDividers && isCurrentSeason && entries.length > 20;
|
||||
|
||||
return (
|
||||
<div className={styles.table}>
|
||||
{entries.map((entry, i) => {
|
||||
{entries.map((entry) => {
|
||||
return (
|
||||
<React.Fragment key={entry.entryId}>
|
||||
<div className={styles.tableRow}>
|
||||
@@ -368,7 +381,11 @@ function TeamTable({
|
||||
/>
|
||||
</Link>
|
||||
) : null}
|
||||
<div className="text-xs">
|
||||
<div
|
||||
className={clsx("text-xs", {
|
||||
[leaderboardsStyles.skippedTeam]: entry.isSkipped,
|
||||
})}
|
||||
>
|
||||
{entry.members.map((member, i) => {
|
||||
return (
|
||||
<React.Fragment key={member.id}>
|
||||
@@ -381,9 +398,11 @@ function TeamTable({
|
||||
<div className={styles.tablePower}>
|
||||
{entry.power.toFixed(2)}
|
||||
</div>
|
||||
{isStaff ? <TeamStaffMenu entry={entry} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{i === 11 && showQualificationDividers ? (
|
||||
{entry.placementRank === TEAM_LEADERBOARD_QUALIFYING_COUNT &&
|
||||
showQualificationDividers ? (
|
||||
<div
|
||||
className={`${styles.tableRow} ${styles.tableRowQualification}`}
|
||||
>
|
||||
@@ -400,6 +419,49 @@ function TeamTable({
|
||||
);
|
||||
}
|
||||
|
||||
function TeamStaffMenu({
|
||||
entry,
|
||||
}: {
|
||||
entry: NonNullable<SerializeFrom<typeof loader>["teamLeaderboard"]>[number];
|
||||
}) {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { submit } = useActionSubmit(leaderboardsActionSchema, {
|
||||
encType: "application/json",
|
||||
});
|
||||
|
||||
const fields = { season: data.season, identifier: entry.identifier };
|
||||
|
||||
return (
|
||||
<SendouMenu
|
||||
trigger={
|
||||
<SendouButton
|
||||
size="miniscule"
|
||||
variant="outlined"
|
||||
icon={<MoreHorizontal />}
|
||||
aria-label="Actions"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{entry.isSkipped ? (
|
||||
<SendouMenuItem
|
||||
icon={<RotateCcw />}
|
||||
onAction={() => submit("UNSKIP_TEAM", fields)}
|
||||
>
|
||||
Unskip
|
||||
</SendouMenuItem>
|
||||
) : (
|
||||
<SendouMenuItem
|
||||
icon={<Ban />}
|
||||
isDestructive
|
||||
onAction={() => submit("SKIP_TEAM", fields)}
|
||||
>
|
||||
Skip
|
||||
</SendouMenuItem>
|
||||
)}
|
||||
</SendouMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function XPTable({ entries }: { entries: XPLeaderboardItem[] }) {
|
||||
return (
|
||||
<div className={styles.table}>
|
||||
|
||||
@@ -205,8 +205,9 @@ async function findTeamEntry({
|
||||
})
|
||||
).find(hasUser);
|
||||
|
||||
// a skipped team is on the leaderboard without taking a placement
|
||||
if (rankedEntry)
|
||||
return { entry: rankedEntry, rank: rankedEntry.placementRank };
|
||||
return { entry: rankedEntry, rank: rankedEntry.placementRank ?? undefined };
|
||||
|
||||
// rosters that only show up on the "all entries" leaderboard have no
|
||||
// placement comparable to the one shown on the main team leaderboard
|
||||
|
||||
35
migrations/20260815070111-leaderboard-team-skip.ts
Normal file
35
migrations/20260815070111-leaderboard-team-skip.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type Kysely, sql } from "kysely";
|
||||
|
||||
/** Teams that don't count for team leaderboard placements, e.g. because they want to qualify with another roster */
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await trx.schema
|
||||
.createTable("LeaderboardTeamSkip")
|
||||
.addColumn("id", "integer", (col) => col.primaryKey())
|
||||
.addColumn("season", "integer", (col) => col.notNull())
|
||||
.addColumn("identifier", "text", (col) => col.notNull())
|
||||
.addColumn("skippedByUserId", "integer", (col) =>
|
||||
col.notNull().references("User.id").onDelete("cascade"),
|
||||
)
|
||||
.addColumn("createdAt", "integer", (col) =>
|
||||
col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
|
||||
)
|
||||
// every table in this schema is strict
|
||||
.modifyEnd(sql`strict`)
|
||||
.execute();
|
||||
|
||||
await trx.schema
|
||||
.createIndex("leaderboard_team_skip_season_identifier")
|
||||
.on("LeaderboardTeamSkip")
|
||||
.columns(["season", "identifier"])
|
||||
.unique()
|
||||
.execute();
|
||||
|
||||
// the team that the hardcoded list this table replaces held. Attributed to the
|
||||
// admin, who made the call back then. Inserts nothing on databases without them
|
||||
await sql`
|
||||
insert into "LeaderboardTeamSkip" ("season", "identifier", "skippedByUserId")
|
||||
select 5, '9403-13562-15916-38062', "User"."id" from "User" where "User"."id" = 274
|
||||
`.execute(trx);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user