mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-03 08:38:42 -05:00
Trophy release (#3426)
This commit is contained in:
@@ -20,7 +20,6 @@ import { ScheduleNudge } from "~/features/availability/components/ScheduleNudge"
|
||||
import { useChatContext } from "~/features/chat/ChatProvider";
|
||||
import { FriendMenu } from "~/features/friends/components/FriendMenu";
|
||||
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import { useClosePopoversOnNavigation } from "~/hooks/useClosePopoversOnNavigation";
|
||||
import { useScrollLock } from "~/hooks/useScrollLock";
|
||||
import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests";
|
||||
@@ -435,29 +434,25 @@ function MenuPanel({
|
||||
|
||||
<nav aria-label={t("front:mobileNav.menu")}>
|
||||
<ul className={styles.navGrid}>
|
||||
{navItems
|
||||
.filter(
|
||||
(item) => item.name !== "trophies" || canAccessTrophies(user),
|
||||
)
|
||||
.map((item) => (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
to={`/${item.url}`}
|
||||
prefetch="intent"
|
||||
className={styles.navItem}
|
||||
>
|
||||
<div className={styles.navItemImage}>
|
||||
<Image
|
||||
path={navIconUrl(item.name)}
|
||||
height={32}
|
||||
width={32}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<span>{t(`common:pages.${item.name}` as any)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{navItems.map((item) => (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
to={`/${item.url}`}
|
||||
prefetch="intent"
|
||||
className={styles.navItem}
|
||||
>
|
||||
<div className={styles.navItemImage}>
|
||||
<Image
|
||||
path={navIconUrl(item.name)}
|
||||
height={32}
|
||||
width={32}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<span>{t(`common:pages.${item.name}` as any)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useUser } from "~/features/auth/core/user";
|
||||
import { useGlobalStatus } from "~/features/global-status/GlobalStatusProvider";
|
||||
import { lfgNewPostPage } from "~/features/lfg/lfg-urls";
|
||||
import { plusSuggestionsNewPage } from "~/features/plus-suggestions/plus-suggestions-urls";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import { userNewBuildPage } from "~/features/user-page/user-page-urls";
|
||||
import { newVodPage } from "~/features/vods/vods-urls";
|
||||
import {
|
||||
@@ -103,15 +102,13 @@ export function AnythingAdder() {
|
||||
imagePath: navIconUrl("plus"),
|
||||
href: plusSuggestionsNewPage(),
|
||||
},
|
||||
canAccessTrophies(user)
|
||||
? {
|
||||
id: "trophy",
|
||||
children: t("header.adder.trophy"),
|
||||
imagePath: navIconUrl("trophies"),
|
||||
href: NEW_TROPHY_PAGE,
|
||||
}
|
||||
: null,
|
||||
].filter((item) => item !== null);
|
||||
{
|
||||
id: "trophy",
|
||||
children: t("header.adder.trophy"),
|
||||
imagePath: navIconUrl("trophies"),
|
||||
href: NEW_TROPHY_PAGE,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SendouMenu
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Config } from "~/config";
|
||||
import { NZAP_TEST_ID } from "~/db/seed/constants";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import {
|
||||
impersonateUrl,
|
||||
navIconUrl,
|
||||
@@ -243,7 +242,6 @@ function CategoryMenu({
|
||||
|
||||
const visibleItems = category.items.filter((item) => {
|
||||
if ("staffOnly" in item && !showStaffOnly) return false;
|
||||
if (item.name === "trophies" && !canAccessTrophies(user)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -8,3 +8,5 @@ export const ORG_ADMIN_TEST_ID = 3;
|
||||
/** Matches STAFF_IDS[0] (Panda) so the seeded user is recognized as STAFF. */
|
||||
export const STAFF_TEST_ID = 11329;
|
||||
export const STAFF_TEST_DISCORD_ID = "138757634500067328";
|
||||
/** Matches DEV_IDS[0] so the seeded user is recognized as DEV. */
|
||||
export const DEV_TEST_ID = 27883;
|
||||
|
||||
@@ -23,6 +23,19 @@ import type { SeededTournaments } from "./tournaments";
|
||||
import type { SeededUsers } from "./users";
|
||||
|
||||
const NZAP_PLAYER_SPL_ID = "qx6imlx72tfeqrhqfnmm";
|
||||
const NZAP_XP_TROPHY_PLACEMENTS: Array<{
|
||||
weaponSplId: MainWeaponId;
|
||||
power: number;
|
||||
region: Tables["XRankPlacement"]["region"];
|
||||
mode: ModeShort;
|
||||
month: number;
|
||||
}> = [
|
||||
{ weaponSplId: 40, power: 3621.4, region: "WEST", mode: "SZ", month: 2 },
|
||||
{ weaponSplId: 40, power: 3544.9, region: "JPN", mode: "TC", month: 3 },
|
||||
{ weaponSplId: 2030, power: 3238.2, region: "WEST", mode: "RM", month: 4 },
|
||||
{ weaponSplId: 1010, power: 3071.6, region: "JPN", mode: "CB", month: 5 },
|
||||
{ weaponSplId: 6000, power: 4012.8, region: "WEST", mode: "SZ", month: 6 },
|
||||
];
|
||||
const FRIEND_COUNT = 8;
|
||||
/** Friends of the admin, none of them a teammate, so that friends-only surfaces have something to show. */
|
||||
const ADMIN_FRIEND_COUNT = 3;
|
||||
@@ -77,15 +90,25 @@ async function seedXRankPlacements(users: SeededUsers) {
|
||||
? unaffiliatedTopPlayerId
|
||||
: undefined;
|
||||
|
||||
await XRankPlacementFactory.create({
|
||||
...placement,
|
||||
mode: placement.mode as ModeShort,
|
||||
region: placement.region as Tables["XRankPlacement"]["region"],
|
||||
weaponSplId: placement.weaponSplId as MainWeaponId,
|
||||
playerUserId,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [i, placement] of NZAP_XP_TROPHY_PLACEMENTS.entries()) {
|
||||
await XRankPlacementFactory.create(
|
||||
{
|
||||
...placement,
|
||||
mode: placement.mode as ModeShort,
|
||||
region: placement.region as Tables["XRankPlacement"]["region"],
|
||||
weaponSplId: placement.weaponSplId as MainWeaponId,
|
||||
playerUserId,
|
||||
year: 2024,
|
||||
rank: 10 + i,
|
||||
playerSplId: NZAP_PLAYER_SPL_ID,
|
||||
playerUserId: users.nzapId,
|
||||
},
|
||||
{ refreshPeakXp: i === placements.length - 1 },
|
||||
{ refreshPeakXp: i === NZAP_XP_TROPHY_PLACEMENTS.length - 1 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -96,10 +119,10 @@ async function seedArts(users: SeededUsers) {
|
||||
for (const authorId of [users.nzapId, ...users.artistIds]) {
|
||||
const artCount = faker.helpers.arrayElement([1, 2, 3, 3, 4]);
|
||||
|
||||
for (let i = 0; i < artCount; i++) {
|
||||
for (let i = 0; i < artCount && nextUrl < SEED_ART_URLS.length; i++) {
|
||||
await ArtFactory.create({
|
||||
authorId,
|
||||
url: getArtFilename(nextUrl++ % SEED_ART_URLS.length),
|
||||
url: getArtFilename(nextUrl++),
|
||||
description:
|
||||
faker.number.float(1) < 0.5 ? faker.lorem.paragraph() : null,
|
||||
linkedUsers:
|
||||
|
||||
@@ -74,6 +74,8 @@ const LUTI_MIN_MEMBERS = 4;
|
||||
const LUTI_MAX_MEMBERS = 8;
|
||||
|
||||
const HISTORICAL_COUNT = 5;
|
||||
/** PICNIC editions from before the series had a trophy, to backfill it for. */
|
||||
const PRE_TROPHY_PICNIC_COUNT = 3;
|
||||
/** Showcase users seeded into every played tournament, so their results paginate. */
|
||||
const CORE_PLAYER_COUNT = 8;
|
||||
/** Share of teams registering as one of the site's teams, the rest being pickups. */
|
||||
@@ -229,6 +231,14 @@ export async function seedTournaments({
|
||||
seriesLogoImgIds,
|
||||
});
|
||||
|
||||
await seedPreTrophyPicnics({
|
||||
users,
|
||||
organizations,
|
||||
badges,
|
||||
rosters,
|
||||
seriesLogoImgIds,
|
||||
});
|
||||
|
||||
return { regOpen: inTheZone, nzapTeamIds, luti };
|
||||
}
|
||||
|
||||
@@ -793,6 +803,44 @@ async function seedHistoricalTournaments({
|
||||
return nzapTeamIds;
|
||||
}
|
||||
|
||||
async function seedPreTrophyPicnics({
|
||||
users,
|
||||
organizations,
|
||||
badges,
|
||||
rosters,
|
||||
seriesLogoImgIds,
|
||||
}: Ctx & { badges: SeededBadges; seriesLogoImgIds: Map<string, number> }) {
|
||||
const stem = TOURNAMENT_NAME_STEMS[0];
|
||||
const organizationId = organizations[0].id;
|
||||
|
||||
for (let i = 0; i < PRE_TROPHY_PICNIC_COUNT; i++) {
|
||||
const startsAt = sub(new Date(), { months: 10 + i });
|
||||
const authorId = users.adminId;
|
||||
|
||||
const tournament = await TournamentFactory.create(
|
||||
{
|
||||
name: `${stem.name} ${PRE_TROPHY_PICNIC_COUNT - i}`,
|
||||
avatarImgId: await seriesLogoImgId(seriesLogoImgIds, stem, authorId),
|
||||
authorId,
|
||||
organizationId,
|
||||
startTimes: [dateToDatabaseTimestamp(startsAt)],
|
||||
bracketProgression: DOUBLE_ELIMINATION,
|
||||
badges: i === 0 ? [badges.ids[0]] : [],
|
||||
},
|
||||
{ tier: 4 },
|
||||
);
|
||||
|
||||
await registerTeams({
|
||||
tournamentId: tournament.id,
|
||||
rosters: rosters.take({ teamCount: 8, teamSize: 5 }),
|
||||
isCheckedIn: true,
|
||||
registeredAt: sub(startsAt, { days: 2 }),
|
||||
});
|
||||
|
||||
await TournamentFactory.playOut(tournament.id, "all");
|
||||
}
|
||||
}
|
||||
|
||||
/** Editions of a series share one logo image, an image row not being allowed the url of another. */
|
||||
async function seriesLogoImgId(
|
||||
imgIds: Map<string, number>,
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import {
|
||||
SUPPORTER_TROPHY_CODE,
|
||||
XP_TROPHY_CODE_PREFIX,
|
||||
} from "~/features/trophies/trophies-constants";
|
||||
import { faker } from "../core/faker";
|
||||
import trophies from "../data/trophies.json";
|
||||
import * as TrophyFactory from "../factories/TrophyFactory";
|
||||
@@ -44,27 +39,9 @@ export async function seedTrophies({
|
||||
return { ids };
|
||||
}
|
||||
|
||||
/** Non-tournament trophies handed to everybody eligible like the nightly sync. Runs last: eligibility follows from patrons and X Rank placements. */
|
||||
/** X Power trophies handed to everybody eligible like the X Rank placement import does. Runs last: eligibility follows from X Rank placements. */
|
||||
export async function seedSpecialTrophies() {
|
||||
const models = TrophyFactory.MODELS;
|
||||
|
||||
await TrophyFactory.create({
|
||||
name: "Supporter",
|
||||
model: models[0],
|
||||
code: SUPPORTER_TROPHY_CODE,
|
||||
});
|
||||
await TrophyFactory.create({
|
||||
name: "3000 X Power",
|
||||
model: models[1],
|
||||
code: `${XP_TROPHY_CODE_PREFIX}3000`,
|
||||
});
|
||||
await TrophyFactory.create({
|
||||
name: "2600 X Power",
|
||||
model: models[2],
|
||||
code: `${XP_TROPHY_CODE_PREFIX}2600`,
|
||||
});
|
||||
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
await TrophyFactory.createXpTrophies();
|
||||
}
|
||||
|
||||
async function seedPendingTrophies({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from "~/db/sql";
|
||||
import type { TablesInsertable } from "~/db/tables";
|
||||
import * as XpTrophy from "~/features/trophies/core/XpTrophy";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import { defineFactory } from "../core/defineFactory";
|
||||
// the e2e process loads this factory as plain ESM, where the attribute is required
|
||||
@@ -26,6 +27,23 @@ export const { create } = defineFactory({
|
||||
.executeTakeFirstOrThrow(),
|
||||
});
|
||||
|
||||
/** Every X Power trophy, awarded for the placements already in like the migration adding them does. */
|
||||
export async function createXpTrophies() {
|
||||
const created: Array<{ id: number; code: string }> = [];
|
||||
for (const variant of XpTrophy.VARIANTS) {
|
||||
const trophy = await create({
|
||||
name: variant.name,
|
||||
code: variant.code,
|
||||
model: "",
|
||||
});
|
||||
created.push({ id: trophy.id, code: variant.code });
|
||||
}
|
||||
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
type PendingOptions = {
|
||||
/** Who approves the submission; enough of them and the trophy is created. */
|
||||
approverUserIds?: number[];
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as UserCardRepository from "~/features/user-card/UserCardRepository.ser
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { invariant } from "~/utils/invariant";
|
||||
import {
|
||||
DEV_TEST_ID,
|
||||
ORG_ADMIN_TEST_ID,
|
||||
REGULAR_USER_TEST_ID,
|
||||
STAFF_TEST_ID,
|
||||
@@ -250,6 +251,12 @@ export const createStaff = (
|
||||
options?: Options,
|
||||
) => createPinned(STAFF_TEST_ID, overrides, options);
|
||||
|
||||
/** The dev id user. Create after every unpinned user so the ids in between stay free. */
|
||||
export const createDev = (
|
||||
overrides?: Partial<UpsertArgs> | null,
|
||||
options?: Options,
|
||||
) => createPinned(DEV_TEST_ID, overrides, options);
|
||||
|
||||
async function createPinned(
|
||||
pinnedId: number,
|
||||
overrides?: Partial<UpsertArgs> | null,
|
||||
|
||||
@@ -476,8 +476,6 @@ export async function forcePatron(args: {
|
||||
})
|
||||
.where("User.id", "=", args.id)
|
||||
.execute();
|
||||
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
}
|
||||
|
||||
export async function findAllBannedUsers() {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
tournamentFromDB,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import { parseFormDataWithImages } from "~/form/parse.server";
|
||||
import {
|
||||
requirePermission,
|
||||
@@ -78,10 +77,6 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
}
|
||||
|
||||
if (data.trophyId) {
|
||||
if (!canAccessTrophies(user)) {
|
||||
errorToast("Trophies are not released yet");
|
||||
}
|
||||
|
||||
const trophyOrganizationId = await TrophyRepository.findOrganizationIdById(
|
||||
data.trophyId,
|
||||
);
|
||||
|
||||
@@ -28,6 +28,10 @@ interface CommonEvent {
|
||||
slug: string;
|
||||
} | null;
|
||||
authorId: number;
|
||||
badges: Array<
|
||||
Pick<Tables["Badge"], "id" | "code" | "displayName" | "hue">
|
||||
> | null;
|
||||
trophy: Pick<Tables["Trophy"], "model"> | null;
|
||||
}
|
||||
|
||||
export interface CalendarEvent extends CommonEvent {
|
||||
@@ -39,10 +43,6 @@ export interface CalendarEvent extends CommonEvent {
|
||||
normalizedTeamCount: number;
|
||||
/** For multi-day tournaments, which day of the event is this */
|
||||
day?: number;
|
||||
badges: Array<
|
||||
Pick<Tables["Badge"], "id" | "code" | "displayName" | "hue">
|
||||
> | null;
|
||||
trophy: Pick<Tables["Trophy"], "model"> | null;
|
||||
}
|
||||
|
||||
export interface ShowcaseCalendarEvent extends CommonEvent {
|
||||
|
||||
@@ -153,9 +153,8 @@ export function TournamentCard({
|
||||
) : null}
|
||||
{modes ? <ModesRow modes={modes} /> : null}
|
||||
<div className={styles.pillsContainer}>
|
||||
{isCalendar &&
|
||||
(tournament.trophy ||
|
||||
(tournament.badges && tournament.badges.length > 0)) ? (
|
||||
{tournament.trophy ||
|
||||
(tournament.badges && tournament.badges.length > 0) ? (
|
||||
<PrizesPill
|
||||
badges={tournament.badges}
|
||||
trophy={tournament.trophy?.model}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { tournamentData } from "~/features/tournament-bracket/core/Tournament.se
|
||||
import { tournamentBracketsPage } from "~/features/tournament-bracket/tournament-bracket-urls";
|
||||
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import { hasPermission } from "~/modules/permissions/utils";
|
||||
import { calendarNewSearchParams } from "../calendar-search-params";
|
||||
@@ -96,9 +95,8 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
|
||||
typeof org === "string" ? [] : [org.id],
|
||||
);
|
||||
|
||||
const trophies = canAccessTrophies(user)
|
||||
? await TrophyRepository.findByOrganizationIds(validOrganizationIds)
|
||||
: [];
|
||||
const trophies =
|
||||
await TrophyRepository.findByOrganizationIds(validOrganizationIds);
|
||||
|
||||
const eventToCopy = eventToCopyRaw
|
||||
? {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { getUser } from "~/features/auth/core/user.server";
|
||||
import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants";
|
||||
import { calendarFiltersSearchParamsSchema } from "~/features/calendar/calendar-schemas";
|
||||
import { calendarSearchParams } from "~/features/calendar/calendar-search-params";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import * as CalendarRepository from "../CalendarRepository.server";
|
||||
import * as CalendarEvent from "../core/CalendarEvent";
|
||||
@@ -54,24 +53,8 @@ export const loader = async (args: LoaderFunctionArgs) => {
|
||||
: CalendarEvent.defaultFilters(),
|
||||
);
|
||||
|
||||
const eventTimes = canAccessTrophies(user)
|
||||
? filtered
|
||||
: filtered.map((time) => ({
|
||||
...time,
|
||||
events: {
|
||||
shown: time.events.shown.map((event) => ({
|
||||
...event,
|
||||
trophy: null,
|
||||
})),
|
||||
hidden: time.events.hidden.map((event) => ({
|
||||
...event,
|
||||
trophy: null,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
eventTimes,
|
||||
eventTimes: filtered,
|
||||
dateViewed,
|
||||
filters,
|
||||
canSaveAsDefault,
|
||||
|
||||
@@ -316,6 +316,8 @@ function mapTournamentFromDB(
|
||||
minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4,
|
||||
modes: null,
|
||||
hasVods: (tournament.vodCount ?? 0) > 0,
|
||||
badges: tournament.badges,
|
||||
trophy: tournament.trophy,
|
||||
firstPlacers,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import { SplatoonRotations } from "~/features/front-page/components/SplatoonRota
|
||||
import type * as Changelog from "~/features/front-page/core/Changelog.server";
|
||||
import { leaderboardsPage } from "~/features/leaderboards/leaderboards-urls";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import styles from "~/styles/front.module.css";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
@@ -334,12 +333,9 @@ const DISCOVER_EXCLUDED_ITEMS = new Set(["settings", "luti"]);
|
||||
function DiscoverFeatures() {
|
||||
const { t } = useTranslation(["front", "common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const user = useUser();
|
||||
|
||||
const filteredNavItems = navItems.filter(
|
||||
(item) =>
|
||||
!DISCOVER_EXCLUDED_ITEMS.has(item.name) &&
|
||||
(item.name !== "trophies" || canAccessTrophies(user)),
|
||||
(item) => !DISCOVER_EXCLUDED_ITEMS.has(item.name),
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -152,7 +152,11 @@ export async function findPlacementsByPlayerId(
|
||||
|
||||
export async function findPlacementsByUserId(
|
||||
userId: Tables["User"]["id"],
|
||||
options?: { limit?: number; weaponId?: MainWeaponId },
|
||||
options?: {
|
||||
limit?: number;
|
||||
weaponId?: MainWeaponId;
|
||||
weaponIds?: readonly MainWeaponId[];
|
||||
},
|
||||
) {
|
||||
let query = xRankPlacementsQueryBase()
|
||||
.where("SplatoonPlayer.userId", "=", userId)
|
||||
@@ -162,6 +166,12 @@ export async function findPlacementsByUserId(
|
||||
query = query.where("XRankPlacement.weaponSplId", "=", options.weaponId);
|
||||
}
|
||||
|
||||
if (options?.weaponIds) {
|
||||
query = query.where("XRankPlacement.weaponSplId", "in", [
|
||||
...options.weaponIds,
|
||||
]);
|
||||
}
|
||||
|
||||
if (options?.limit) {
|
||||
query = query.limit(options.limit);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
dateToDatabaseTimestamp,
|
||||
} from "~/utils/dates";
|
||||
import {
|
||||
calendarEventNameMatchesSeries,
|
||||
commonUserSelect,
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
@@ -437,15 +438,6 @@ export function findAllUnfinalizedEvents(organizationId: number) {
|
||||
.execute();
|
||||
}
|
||||
|
||||
const nameMatchesSeries =
|
||||
(substringMatches: string[]) =>
|
||||
(eb: ExpressionBuilder<DB, "CalendarEvent">) =>
|
||||
eb.or(
|
||||
substringMatches.map((match) =>
|
||||
eb("CalendarEvent.name", "like", `%${match}%`),
|
||||
),
|
||||
);
|
||||
|
||||
export async function findPaginatedEventsBySeries({
|
||||
organizationId,
|
||||
substringMatches,
|
||||
@@ -467,7 +459,7 @@ export async function findPaginatedEventsBySeries({
|
||||
.select("CalendarEvent.id")
|
||||
.where("CalendarEvent.organizationId", "=", organizationId)
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.where(nameMatchesSeries(substringMatches))
|
||||
.where(calendarEventNameMatchesSeries(substringMatches))
|
||||
.groupBy("CalendarEvent.id")
|
||||
.orderBy(({ fn }) => fn.min("CalendarEventDate.startsAt"), "desc")
|
||||
.limit(TOURNAMENT_SERIES_EVENTS_PER_PAGE)
|
||||
@@ -507,7 +499,7 @@ export async function findAllEventsBySeries({
|
||||
])
|
||||
.where("CalendarEvent.organizationId", "=", organizationId)
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.where(nameMatchesSeries(substringMatches))
|
||||
.where(calendarEventNameMatchesSeries(substringMatches))
|
||||
.groupBy("CalendarEvent.id")
|
||||
.orderBy("CalendarEventDate.startsAt", "desc")
|
||||
.execute();
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { LoaderFunctionArgs } from "react-router";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import { calculateTentativeTier } from "~/features/tournament/core/tiering";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { eventLeaderboards } from "../core/leaderboards.server";
|
||||
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
|
||||
@@ -79,9 +78,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
series: await seriesInfo(),
|
||||
month,
|
||||
year,
|
||||
trophies: canAccessTrophies(user)
|
||||
? await TrophyRepository.findByOrganizationId(organization.id)
|
||||
: [],
|
||||
trophies: await TrophyRepository.findByOrganizationId(organization.id),
|
||||
bannedUsers:
|
||||
user?.id && organization.permissions.BAN.includes(user.id)
|
||||
? await TournamentOrganizationRepository.findAllBannedUsersByOrganizationId(
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type Insertable,
|
||||
type Kysely,
|
||||
type NotNull,
|
||||
type SqlBool,
|
||||
sql,
|
||||
type Transaction,
|
||||
} from "kysely";
|
||||
@@ -26,6 +25,7 @@ import type {
|
||||
TournamentTrophyReceiver,
|
||||
} from "~/features/tournament-bracket/tournament-bracket-schemas";
|
||||
import type { TournamentOrganizationRole } from "~/features/tournament-organization/tournament-organization-constants";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { isSupporter } from "~/modules/permissions/utils";
|
||||
import { nullFilledArray, nullifyingAvg } from "~/utils/arrays";
|
||||
@@ -836,6 +836,20 @@ export function findAllForShowcase() {
|
||||
"CalendarEvent.organizationId",
|
||||
),
|
||||
).as("organization"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("CalendarEventBadge")
|
||||
.innerJoin("Badge", "CalendarEventBadge.badgeId", "Badge.id")
|
||||
.select(["Badge.id", "Badge.code", "Badge.hue", "Badge.displayName"])
|
||||
.whereRef("CalendarEventBadge.eventId", "=", "CalendarEvent.id")
|
||||
.orderBy("Badge.id", "asc"),
|
||||
).as("badges"),
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("Trophy")
|
||||
.select(["Trophy.model"])
|
||||
.whereRef("Trophy.id", "=", "CalendarEvent.trophyId"),
|
||||
).as("trophy"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TournamentResult")
|
||||
@@ -1663,28 +1677,20 @@ export function finalize({
|
||||
);
|
||||
await trx.insertInto("TournamentBadgeOwner").values(badgeOwners).execute();
|
||||
|
||||
if (trophyReceiver && trophyReceiver.userIds.length > 0) {
|
||||
const tier = await trophyTier(trx, {
|
||||
tournamentId,
|
||||
tournamentTeamId: summary.tournamentResults.find((result) =>
|
||||
trophyReceiver.userIds.includes(result.userId),
|
||||
)?.tournamentTeamId,
|
||||
});
|
||||
|
||||
await trx
|
||||
.insertInto("TrophyOwner")
|
||||
.values(
|
||||
trophyReceiver.userIds.map((userId) => ({
|
||||
if (trophyReceiver) {
|
||||
await TrophyRepository.insertTournamentOwners(
|
||||
[
|
||||
{
|
||||
tournamentId,
|
||||
tournamentTeamId: summary.tournamentResults.find((result) =>
|
||||
trophyReceiver.userIds.includes(result.userId),
|
||||
)?.tournamentTeamId,
|
||||
trophyId: trophyReceiver.trophyId,
|
||||
userId,
|
||||
tier,
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(["tournamentId", "userId", "trophyId"]).doNothing(),
|
||||
)
|
||||
.execute();
|
||||
userIds: trophyReceiver.userIds,
|
||||
},
|
||||
],
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
const tournamentResults = summary.tournamentResults
|
||||
@@ -1945,41 +1951,6 @@ export async function findRunningTournamentIds() {
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
/** Tier of the winning team's division, falling back to the tournament's tier when unknown or never tiered. */
|
||||
async function trophyTier(
|
||||
trx: Transaction<DB>,
|
||||
{
|
||||
tournamentId,
|
||||
tournamentTeamId,
|
||||
}: { tournamentId: number; tournamentTeamId?: number },
|
||||
) {
|
||||
const divisionTier = tournamentTeamId
|
||||
? await trx
|
||||
.selectFrom("TournamentDivisionTier")
|
||||
.innerJoin(
|
||||
"TournamentTeam",
|
||||
"TournamentTeam.tournamentId",
|
||||
"TournamentDivisionTier.tournamentId",
|
||||
)
|
||||
.select("TournamentDivisionTier.tier")
|
||||
.where("TournamentTeam.id", "=", tournamentTeamId)
|
||||
.where(
|
||||
sql<SqlBool>`"TournamentDivisionTier"."bracketIdx" = coalesce("TournamentTeam"."startingBracketIdx", 0)`,
|
||||
)
|
||||
.executeTakeFirst()
|
||||
: undefined;
|
||||
|
||||
if (divisionTier) return divisionTier.tier;
|
||||
|
||||
const tournament = await trx
|
||||
.selectFrom("Tournament")
|
||||
.select("tier")
|
||||
.where("id", "=", tournamentId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return tournament?.tier ?? null;
|
||||
}
|
||||
|
||||
/** Which seeding skill the tournament ranks by, resolved once: inline in the join it parsed the settings JSON per member row. */
|
||||
function seedingSkillType(tournamentId: number) {
|
||||
return sql<
|
||||
|
||||
@@ -3,9 +3,13 @@ import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
|
||||
import * as TrophyFactory from "~/db/seed/factories/TrophyFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import * as XRankPlacementFactory from "~/db/seed/factories/XRankPlacementFactory";
|
||||
import { db } from "~/db/sql";
|
||||
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { ConcurrentModificationError } from "~/utils/errors";
|
||||
import * as XpTrophy from "./core/XpTrophy";
|
||||
import * as TrophyRepository from "./TrophyRepository.server";
|
||||
import { TROPHY_APPROVALS_REQUIRED } from "./trophies-constants";
|
||||
|
||||
@@ -424,3 +428,273 @@ async function trophyCount() {
|
||||
|
||||
const daysFromNow = (days: number) =>
|
||||
new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
||||
|
||||
describe("backfill", () => {
|
||||
const users = UserFactory.pool();
|
||||
const winnerIds = () => users.ids(4);
|
||||
|
||||
beforeEach(async () => {
|
||||
await users.create(8);
|
||||
});
|
||||
|
||||
const createFinalized = (
|
||||
overrides: Partial<Parameters<typeof TournamentFactory.create>[0]> = {},
|
||||
) =>
|
||||
TournamentFactory.createPlayed(
|
||||
{ authorId: users.id(1), ...overrides },
|
||||
{
|
||||
tier: 3,
|
||||
teamRosters: [winnerIds(), users.ids(8).slice(4)],
|
||||
playedOut: "all",
|
||||
},
|
||||
);
|
||||
|
||||
const ownersOf = (trophyId: number) =>
|
||||
db
|
||||
.selectFrom("TrophyOwner")
|
||||
.select([
|
||||
"TrophyOwner.tournamentId",
|
||||
"TrophyOwner.userId",
|
||||
"TrophyOwner.tier",
|
||||
])
|
||||
.where("TrophyOwner.trophyId", "=", trophyId)
|
||||
.orderBy("TrophyOwner.userId", "asc")
|
||||
.execute();
|
||||
|
||||
test("links the trophy to the tournament and awards the chosen players at its tier", async () => {
|
||||
const trophy = await TrophyFactory.create();
|
||||
const tournament = await createFinalized();
|
||||
|
||||
await TrophyRepository.backfill({
|
||||
trophyId: trophy.id,
|
||||
awards: [
|
||||
{
|
||||
tournamentId: tournament.id,
|
||||
tournamentTeamId: tournament.teams[0].id,
|
||||
userIds: winnerIds().slice(0, 2),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
(await TrophyRepository.findTournamentsByTrophyId(trophy.id)).map(
|
||||
(row) => row.tournamentId,
|
||||
),
|
||||
).toEqual([tournament.id]);
|
||||
expect(await ownersOf(trophy.id)).toEqual(
|
||||
winnerIds()
|
||||
.slice(0, 2)
|
||||
.map((userId) => ({ tournamentId: tournament.id, userId, tier: 3 })),
|
||||
);
|
||||
});
|
||||
|
||||
test("awards nothing when one of the tournaments already has a trophy", async () => {
|
||||
const trophy = await TrophyFactory.create();
|
||||
const otherTrophy = await TrophyFactory.create();
|
||||
const withoutTrophy = await createFinalized();
|
||||
const withTrophy = await createFinalized({ trophyId: otherTrophy.id });
|
||||
|
||||
await expect(
|
||||
TrophyRepository.backfill({
|
||||
trophyId: trophy.id,
|
||||
awards: [withoutTrophy, withTrophy].map((tournament) => ({
|
||||
tournamentId: tournament.id,
|
||||
tournamentTeamId: tournament.teams[0].id,
|
||||
userIds: winnerIds(),
|
||||
})),
|
||||
}),
|
||||
).rejects.toThrow(ConcurrentModificationError);
|
||||
|
||||
expect(await TrophyRepository.findTournamentsByTrophyId(trophy.id)).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(await ownersOf(trophy.id)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("X Power trophies", () => {
|
||||
const SPLATTERSHOT = 40;
|
||||
const SPLASH_O_MATIC = 20;
|
||||
const SPLAT_CHARGER = 2010;
|
||||
|
||||
const users = UserFactory.pool();
|
||||
const playerId = () => users.id(1);
|
||||
const otherPlayerId = () => users.id(2);
|
||||
|
||||
let trophyIdByCode: Map<string, number>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await users.create(2);
|
||||
trophyIdByCode = new Map(
|
||||
(await TrophyFactory.createXpTrophies()).map((trophy) => [
|
||||
trophy.code,
|
||||
trophy.id,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
const place = (args: {
|
||||
userId?: number;
|
||||
weaponSplId: MainWeaponId;
|
||||
power: number;
|
||||
region?: "WEST" | "JPN";
|
||||
}) =>
|
||||
XRankPlacementFactory.create({
|
||||
playerUserId: args.userId,
|
||||
playerSplId: args.userId ? undefined : "unlinked-player",
|
||||
weaponSplId: args.weaponSplId,
|
||||
power: args.power,
|
||||
...(args.region ? { region: args.region } : {}),
|
||||
});
|
||||
|
||||
const ownedCodes = async (userId: number) =>
|
||||
(
|
||||
await db
|
||||
.selectFrom("SpecialTrophyOwner")
|
||||
.innerJoin("Trophy", "Trophy.id", "SpecialTrophyOwner.trophyId")
|
||||
.select("Trophy.code")
|
||||
.where("SpecialTrophyOwner.userId", "=", userId)
|
||||
.orderBy("Trophy.code", "asc")
|
||||
.execute()
|
||||
).map((row) => row.code);
|
||||
|
||||
describe("syncSpecialTrophies", () => {
|
||||
test("awards the highest milestone reached per weapon category", async () => {
|
||||
await place({
|
||||
userId: playerId(),
|
||||
weaponSplId: SPLATTERSHOT,
|
||||
power: 3100,
|
||||
});
|
||||
await place({
|
||||
userId: playerId(),
|
||||
weaponSplId: SPLASH_O_MATIC,
|
||||
power: 3520,
|
||||
});
|
||||
await place({
|
||||
userId: playerId(),
|
||||
weaponSplId: SPLAT_CHARGER,
|
||||
power: 3210,
|
||||
});
|
||||
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
|
||||
expect(await ownedCodes(playerId())).toEqual([
|
||||
"xp-chargers-3200",
|
||||
"xp-shooters-3500",
|
||||
]);
|
||||
});
|
||||
|
||||
test("awards nothing below the lowest milestone", async () => {
|
||||
await place({
|
||||
userId: playerId(),
|
||||
weaponSplId: SPLATTERSHOT,
|
||||
power: 2999,
|
||||
});
|
||||
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
|
||||
expect(await ownedCodes(playerId())).toEqual([]);
|
||||
});
|
||||
|
||||
test("awards nothing for placements of players no user linked", async () => {
|
||||
await place({ weaponSplId: SPLATTERSHOT, power: 3600 });
|
||||
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
|
||||
expect(
|
||||
await db.selectFrom("SpecialTrophyOwner").select("userId").execute(),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("moves an owner up when they reach a higher milestone", async () => {
|
||||
await place({
|
||||
userId: playerId(),
|
||||
weaponSplId: SPLATTERSHOT,
|
||||
power: 3100,
|
||||
});
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
|
||||
await place({
|
||||
userId: playerId(),
|
||||
weaponSplId: SPLATTERSHOT,
|
||||
power: 3300,
|
||||
});
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
|
||||
expect(await ownedCodes(playerId())).toEqual(["xp-shooters-3200"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByOwnerUserId", () => {
|
||||
test("shows the division each X Power trophy was won in, Takoroka over Tentatek", async () => {
|
||||
await place({
|
||||
userId: playerId(),
|
||||
weaponSplId: SPLATTERSHOT,
|
||||
power: 3620,
|
||||
region: "WEST",
|
||||
});
|
||||
await place({
|
||||
userId: playerId(),
|
||||
weaponSplId: SPLASH_O_MATIC,
|
||||
power: 3540,
|
||||
region: "JPN",
|
||||
});
|
||||
await place({
|
||||
userId: playerId(),
|
||||
weaponSplId: SPLAT_CHARGER,
|
||||
power: 3050,
|
||||
region: "WEST",
|
||||
});
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
|
||||
const trophies = await TrophyRepository.findByOwnerUserId(playerId());
|
||||
|
||||
expect(
|
||||
trophies
|
||||
.map((trophy) => ({ code: trophy.code, division: trophy.division }))
|
||||
.toSorted((a, b) => (a.code ?? "").localeCompare(b.code ?? "")),
|
||||
).toEqual([
|
||||
{ code: "xp-chargers-3000", division: "WEST" },
|
||||
{ code: "xp-shooters-3500", division: "JPN" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findXpWeaponCountsById", () => {
|
||||
test("counts the owners who reached the milestone with each weapon", async () => {
|
||||
await place({
|
||||
userId: playerId(),
|
||||
weaponSplId: SPLATTERSHOT,
|
||||
power: 3550,
|
||||
});
|
||||
await place({
|
||||
userId: playerId(),
|
||||
weaponSplId: SPLASH_O_MATIC,
|
||||
power: 3510,
|
||||
});
|
||||
await place({
|
||||
userId: otherPlayerId(),
|
||||
weaponSplId: SPLATTERSHOT,
|
||||
power: 3600,
|
||||
});
|
||||
// below the milestone, so not what got them the trophy
|
||||
await place({
|
||||
userId: otherPlayerId(),
|
||||
weaponSplId: SPLASH_O_MATIC,
|
||||
power: 3100,
|
||||
});
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
|
||||
const counts = await TrophyRepository.findXpWeaponCountsById({
|
||||
trophyId: trophyIdByCode.get("xp-shooters-3500")!,
|
||||
weaponIds: XpTrophy.categoryWeaponIds("shooters"),
|
||||
milestone: 3500,
|
||||
});
|
||||
|
||||
expect(counts).toEqual([
|
||||
{ weaponSplId: SPLATTERSHOT, ownerCount: 2 },
|
||||
{ weaponSplId: SPLASH_O_MATIC, ownerCount: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
import { sub } from "date-fns";
|
||||
import type { ExpressionBuilder, NotNull, Transaction } from "kysely";
|
||||
import {
|
||||
type ExpressionBuilder,
|
||||
type NotNull,
|
||||
type SqlBool,
|
||||
sql,
|
||||
type Transaction,
|
||||
} from "kysely";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB } from "~/db/tables";
|
||||
import { isSupporter } from "~/modules/permissions/utils";
|
||||
import { DEV_IDS } from "~/features/admin/admin-constants";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
dateToDatabaseTimestamp,
|
||||
} from "~/utils/dates";
|
||||
import { ConcurrentModificationError } from "~/utils/errors";
|
||||
import {
|
||||
calendarEventNameMatchesSeries,
|
||||
calendarEventStartTime,
|
||||
commonUserSelect,
|
||||
jsonArrayFrom,
|
||||
jsonObjectFrom,
|
||||
peakXpOverallSql,
|
||||
tournamentLogoWithDefault,
|
||||
tournamentTeamCount,
|
||||
} from "~/utils/kysely.server";
|
||||
import { getTentativeTier } from "../tournament-organization/core/tentativeTiers.server";
|
||||
import { sortTrophiesByFavorites } from "../user-page/core/trophy-sorting.server";
|
||||
import {
|
||||
SUPPORTER_TROPHY_CODE,
|
||||
TROPHY_APPROVALS_REQUIRED,
|
||||
XP_TROPHY_CODE_PREFIX,
|
||||
} from "./trophies-constants";
|
||||
import {
|
||||
hasUpcomingTournamentSoon,
|
||||
parseSpecialTrophyCode,
|
||||
} from "./trophies-utils";
|
||||
import * as XpTrophy from "./core/XpTrophy";
|
||||
import { TROPHY_APPROVALS_REQUIRED } from "./trophies-constants";
|
||||
import { hasUpcomingTournamentSoon } from "./trophies-utils";
|
||||
|
||||
type TrophyRecentTournament = {
|
||||
tier: number | null;
|
||||
@@ -36,10 +38,12 @@ type TrophyRecentTournament = {
|
||||
startTime: number | null;
|
||||
};
|
||||
|
||||
/** Tournament trophies, special ones are listed by their own functions. */
|
||||
export async function all() {
|
||||
const rows = await db
|
||||
.selectFrom("Trophy")
|
||||
.select((eb) => ["id", "name", "model", withRecentTournaments(eb)])
|
||||
.where("code", "is", null)
|
||||
.execute();
|
||||
|
||||
return sortByEffectiveTier(rows.map(addEffectiveTier));
|
||||
@@ -233,9 +237,35 @@ async function findOwnedTrophies(userId: number) {
|
||||
.where("SpecialTrophyOwner.userId", "=", userId)
|
||||
.execute();
|
||||
|
||||
const xpPlacements = specialRows.some((row) => XpTrophy.parseCode(row.code))
|
||||
? await db
|
||||
.selectFrom("XRankPlacement")
|
||||
.innerJoin(
|
||||
"SplatoonPlayer",
|
||||
"SplatoonPlayer.id",
|
||||
"XRankPlacement.playerId",
|
||||
)
|
||||
.select([
|
||||
"XRankPlacement.weaponSplId",
|
||||
"XRankPlacement.power",
|
||||
"XRankPlacement.region",
|
||||
])
|
||||
.where("SplatoonPlayer.userId", "=", userId)
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
return [
|
||||
...tournamentRows,
|
||||
...specialRows.map((row) => ({ ...row, count: 1, tier: null })),
|
||||
...tournamentRows.map((row) => ({ ...row, division: null })),
|
||||
...specialRows.map((row) => {
|
||||
const xpVariant = XpTrophy.parseCode(row.code);
|
||||
|
||||
return {
|
||||
...row,
|
||||
count: 1,
|
||||
tier: null,
|
||||
division: xpVariant ? XpTrophy.division(xpPlacements, xpVariant) : null,
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -275,6 +305,7 @@ export async function findById(trophyId: number) {
|
||||
"Trophy.name",
|
||||
"Trophy.model",
|
||||
"Trophy.code",
|
||||
"Trophy.organizationId",
|
||||
withCreator(eb),
|
||||
withManager(eb),
|
||||
withOrganization(eb),
|
||||
@@ -291,9 +322,7 @@ export async function findById(trophyId: number) {
|
||||
return {
|
||||
...trophy,
|
||||
owners: [...trophy.owners, ...specialOwners],
|
||||
permissions: {
|
||||
EDIT: trophy.manager ? [trophy.manager.id] : [],
|
||||
},
|
||||
permissions: trophyPermissions(trophy.manager?.id ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -491,91 +520,218 @@ export async function existsByName(args: {
|
||||
return Boolean(pending);
|
||||
}
|
||||
|
||||
export async function findManagedBy(userId: number) {
|
||||
export function findAllXp() {
|
||||
return db
|
||||
.selectFrom("Trophy")
|
||||
.select(["id", "name", "model", "organizationId", "managerId", "creatorId"])
|
||||
.where("managerId", "=", userId)
|
||||
.select(["Trophy.id", "Trophy.name", "Trophy.model", "Trophy.code"])
|
||||
.where("Trophy.code", "like", XpTrophy.CODE_LIKE_PATTERN)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findCodeById(trophyId: number) {
|
||||
const row = await db
|
||||
.selectFrom("Trophy")
|
||||
.select("Trophy.code")
|
||||
.where("Trophy.id", "=", trophyId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return row?.code ?? null;
|
||||
}
|
||||
|
||||
export function findXpWeaponCountsById({
|
||||
trophyId,
|
||||
weaponIds,
|
||||
milestone,
|
||||
}: {
|
||||
trophyId: number;
|
||||
weaponIds: readonly MainWeaponId[];
|
||||
milestone: number;
|
||||
}) {
|
||||
return db
|
||||
.selectFrom("XRankPlacement")
|
||||
.innerJoin("SplatoonPlayer", "SplatoonPlayer.id", "XRankPlacement.playerId")
|
||||
.innerJoin("SpecialTrophyOwner", (join) =>
|
||||
join
|
||||
.onRef("SpecialTrophyOwner.userId", "=", "SplatoonPlayer.userId")
|
||||
.on("SpecialTrophyOwner.trophyId", "=", trophyId),
|
||||
)
|
||||
.select(({ fn }) => [
|
||||
"XRankPlacement.weaponSplId",
|
||||
fn.count<number>("SplatoonPlayer.userId").distinct().as("ownerCount"),
|
||||
])
|
||||
.where("XRankPlacement.weaponSplId", "in", [...weaponIds])
|
||||
.where("XRankPlacement.power", ">=", milestone)
|
||||
.groupBy("XRankPlacement.weaponSplId")
|
||||
.orderBy("ownerCount", "desc")
|
||||
.orderBy("XRankPlacement.weaponSplId", "asc")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findAllForEditing() {
|
||||
return db
|
||||
const rows = await db
|
||||
.selectFrom("Trophy")
|
||||
.select(["id", "name", "model", "organizationId", "managerId", "creatorId"])
|
||||
.where("code", "is", null)
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
permissions: trophyPermissions(row.managerId),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Recomputes special trophy (supporter, XP) ownership; still-eligible owners keep their `createdAt`. */
|
||||
export function findAllBackfillableTournaments({
|
||||
organizationId,
|
||||
substringMatches,
|
||||
}: {
|
||||
organizationId: number;
|
||||
substringMatches: string[];
|
||||
}) {
|
||||
return db
|
||||
.selectFrom("CalendarEvent")
|
||||
.innerJoin("Tournament", "Tournament.id", "CalendarEvent.tournamentId")
|
||||
.select((eb) => [
|
||||
"Tournament.id as tournamentId",
|
||||
"Tournament.settings",
|
||||
"CalendarEvent.name",
|
||||
tournamentLogoWithDefault(eb).as("logoUrl"),
|
||||
calendarEventStartTime(eb).as("startTime"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TournamentResult")
|
||||
.innerJoin("User", "User.id", "TournamentResult.userId")
|
||||
.innerJoin(
|
||||
"TournamentTeam",
|
||||
"TournamentTeam.id",
|
||||
"TournamentResult.tournamentTeamId",
|
||||
)
|
||||
.select((resultEb) => [
|
||||
...commonUserSelect(resultEb),
|
||||
"TournamentResult.tournamentTeamId",
|
||||
"TournamentResult.div",
|
||||
"TournamentResult.setResults",
|
||||
"TournamentTeam.name as teamName",
|
||||
])
|
||||
.whereRef("TournamentResult.tournamentId", "=", "Tournament.id")
|
||||
.where("TournamentResult.placement", "=", 1)
|
||||
.orderBy("User.id", "asc"),
|
||||
).as("firstPlacers"),
|
||||
])
|
||||
.where("CalendarEvent.organizationId", "=", organizationId)
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.where("CalendarEvent.trophyId", "is", null)
|
||||
.where("Tournament.isFinalized", "=", 1)
|
||||
.where(calendarEventNameMatchesSeries(substringMatches))
|
||||
.orderBy("startTime", "desc")
|
||||
.execute();
|
||||
}
|
||||
|
||||
type TournamentOwnersArgs = {
|
||||
tournamentId: number;
|
||||
tournamentTeamId?: number;
|
||||
trophyId: number;
|
||||
userIds: number[];
|
||||
};
|
||||
|
||||
export async function insertTournamentOwners(
|
||||
awards: TournamentOwnersArgs[],
|
||||
trx: Transaction<DB>,
|
||||
) {
|
||||
const tieredAwards: Array<
|
||||
TournamentOwnersArgs & { tier: Awaited<ReturnType<typeof trophyTier>> }
|
||||
> = [];
|
||||
for (const award of awards) {
|
||||
tieredAwards.push({ ...award, tier: await trophyTier(trx, award) });
|
||||
}
|
||||
|
||||
const rows = tieredAwards.flatMap(
|
||||
({ tournamentId, trophyId, userIds, tier }) =>
|
||||
userIds.map((userId) => ({ tournamentId, trophyId, userId, tier })),
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
await trx
|
||||
.insertInto("TrophyOwner")
|
||||
.values(rows)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(["tournamentId", "userId", "trophyId"]).doNothing(),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function backfill({
|
||||
trophyId,
|
||||
awards,
|
||||
}: {
|
||||
trophyId: number;
|
||||
awards: Array<Omit<TournamentOwnersArgs, "trophyId">>;
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const tournamentIds = awards.map((award) => award.tournamentId);
|
||||
|
||||
const { numUpdatedRows } = await trx
|
||||
.updateTable("CalendarEvent")
|
||||
.set({ trophyId })
|
||||
.where("CalendarEvent.tournamentId", "in", tournamentIds)
|
||||
.where("CalendarEvent.trophyId", "is", null)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (Number(numUpdatedRows) !== tournamentIds.length) {
|
||||
throw new ConcurrentModificationError(
|
||||
"A tournament to backfill already has a trophy",
|
||||
);
|
||||
}
|
||||
|
||||
await insertTournamentOwners(
|
||||
awards.map((award) => ({ ...award, trophyId })),
|
||||
trx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes X Power trophy ownership from the Top 500 placements of linked players (see
|
||||
* {@link XpTrophy.awards}). Still-eligible owners keep their `createdAt`.
|
||||
*/
|
||||
export function syncSpecialTrophies() {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await syncSupporterTrophyOwners(trx);
|
||||
await syncXpTrophyOwners(trx);
|
||||
});
|
||||
}
|
||||
|
||||
async function syncSupporterTrophyOwners(trx: Transaction<DB>) {
|
||||
const trophy = await trx
|
||||
.selectFrom("Trophy")
|
||||
.select("id")
|
||||
.where("code", "=", SUPPORTER_TROPHY_CODE)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!trophy) return;
|
||||
|
||||
const patrons = await trx
|
||||
.selectFrom("User")
|
||||
.select(["id", "patronTier"])
|
||||
.where("patronTier", "is not", null)
|
||||
.execute();
|
||||
|
||||
await replaceSpecialTrophyOwners({
|
||||
trx,
|
||||
trophyId: trophy.id,
|
||||
userIds: patrons.filter(isSupporter).map((patron) => patron.id),
|
||||
});
|
||||
}
|
||||
|
||||
async function syncXpTrophyOwners(trx: Transaction<DB>) {
|
||||
const xpTrophies = (
|
||||
await trx
|
||||
const trophies = await trx
|
||||
.selectFrom("Trophy")
|
||||
.select(["id", "code"])
|
||||
.where("code", "like", `${XP_TROPHY_CODE_PREFIX}%`)
|
||||
.execute()
|
||||
).flatMap((trophy) => {
|
||||
const parsed = parseSpecialTrophyCode(trophy.code);
|
||||
return parsed?.type === "xp"
|
||||
? [{ id: trophy.id, value: parsed.value }]
|
||||
: [];
|
||||
.select(["Trophy.id", "Trophy.code"])
|
||||
.where("Trophy.code", "like", XpTrophy.CODE_LIKE_PATTERN)
|
||||
.execute();
|
||||
if (trophies.length === 0) return;
|
||||
|
||||
const placements = await trx
|
||||
.selectFrom("XRankPlacement")
|
||||
.innerJoin(
|
||||
"SplatoonPlayer",
|
||||
"SplatoonPlayer.id",
|
||||
"XRankPlacement.playerId",
|
||||
)
|
||||
.select(({ fn }) => [
|
||||
"SplatoonPlayer.userId",
|
||||
"XRankPlacement.weaponSplId",
|
||||
fn.max<number>("XRankPlacement.power").as("power"),
|
||||
])
|
||||
.where("SplatoonPlayer.userId", "is not", null)
|
||||
.groupBy(["SplatoonPlayer.userId", "XRankPlacement.weaponSplId"])
|
||||
.$narrowType<{ userId: NotNull }>()
|
||||
.execute();
|
||||
|
||||
const ownerIdsByCode = R.mapValues(
|
||||
R.groupBy(XpTrophy.awards(placements), (award) => award.code),
|
||||
(awards) => awards.map((award) => award.userId),
|
||||
);
|
||||
|
||||
for (const trophy of trophies) {
|
||||
await replaceSpecialTrophyOwners({
|
||||
trx,
|
||||
trophyId: trophy.id,
|
||||
userIds: (trophy.code ? ownerIdsByCode[trophy.code] : undefined) ?? [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (xpTrophies.length === 0) return;
|
||||
|
||||
const byValueDesc = R.sortBy(xpTrophies, [(trophy) => trophy.value, "desc"]);
|
||||
|
||||
const userPeakXps = await trx
|
||||
.selectFrom("SplatoonPlayer")
|
||||
.select(["userId", peakXpOverallSql().as("peakXp")])
|
||||
.where("userId", "is not", null)
|
||||
.where("peakXp", "is not", null)
|
||||
.$narrowType<{ userId: NotNull; peakXp: NotNull }>()
|
||||
.execute();
|
||||
|
||||
const ownersByTrophyId = new Map<number, number[]>(
|
||||
xpTrophies.map((trophy) => [trophy.id, []]),
|
||||
);
|
||||
for (const { userId, peakXp } of userPeakXps) {
|
||||
const highestReached = byValueDesc.find((trophy) => peakXp >= trophy.value);
|
||||
if (!highestReached) continue;
|
||||
|
||||
ownersByTrophyId.get(highestReached.id)?.push(userId);
|
||||
}
|
||||
|
||||
for (const [trophyId, userIds] of ownersByTrophyId) {
|
||||
await replaceSpecialTrophyOwners({ trx, trophyId, userIds });
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceSpecialTrophyOwners({
|
||||
@@ -595,6 +751,8 @@ async function replaceSpecialTrophyOwners({
|
||||
}
|
||||
await deleteStale.execute();
|
||||
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
await trx
|
||||
.insertInto("SpecialTrophyOwner")
|
||||
.values(
|
||||
@@ -903,3 +1061,43 @@ export async function addApproval(args: {
|
||||
.executeTakeFirstOrThrow();
|
||||
});
|
||||
}
|
||||
|
||||
async function trophyTier(
|
||||
trx: Transaction<DB>,
|
||||
{
|
||||
tournamentId,
|
||||
tournamentTeamId,
|
||||
}: { tournamentId: number; tournamentTeamId?: number },
|
||||
) {
|
||||
const divisionTier = tournamentTeamId
|
||||
? await trx
|
||||
.selectFrom("TournamentDivisionTier")
|
||||
.innerJoin(
|
||||
"TournamentTeam",
|
||||
"TournamentTeam.tournamentId",
|
||||
"TournamentDivisionTier.tournamentId",
|
||||
)
|
||||
.select("TournamentDivisionTier.tier")
|
||||
.where("TournamentTeam.id", "=", tournamentTeamId)
|
||||
.where(
|
||||
sql<SqlBool>`"TournamentDivisionTier"."bracketIdx" = coalesce("TournamentTeam"."startingBracketIdx", 0)`,
|
||||
)
|
||||
.executeTakeFirst()
|
||||
: undefined;
|
||||
|
||||
if (divisionTier) return divisionTier.tier;
|
||||
|
||||
const tournament = await trx
|
||||
.selectFrom("Tournament")
|
||||
.select("tier")
|
||||
.where("id", "=", tournamentId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return tournament?.tier ?? null;
|
||||
}
|
||||
|
||||
function trophyPermissions(managerId: number | null) {
|
||||
return {
|
||||
EDIT: R.unique([...(managerId ? [managerId] : []), ...DEV_IDS]),
|
||||
};
|
||||
}
|
||||
|
||||
213
app/features/trophies/actions/trophies.new.server.test.ts
Normal file
213
app/features/trophies/actions/trophies.new.server.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import type * as v from "valibot";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { DEV_TEST_ID } from "~/db/seed/constants";
|
||||
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
|
||||
import * as TrophyFactory from "~/db/seed/factories/TrophyFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
|
||||
import { decompressFromBase64 } from "~/utils/compression";
|
||||
import {
|
||||
assertResponseErrored,
|
||||
type TestUser,
|
||||
wrappedAction,
|
||||
wrappedLoader,
|
||||
} from "~/utils/Test";
|
||||
import {
|
||||
loader as backfillLoader,
|
||||
type TrophyBackfillLoaderData,
|
||||
} from "../routes/trophies.$id.backfill.$seriesId";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import type { trophyFormSchema } from "../trophies-schemas";
|
||||
import { action } from "./trophies.new.server";
|
||||
|
||||
type BackfillFormFields = {
|
||||
_action: "BACKFILL";
|
||||
trophyId: number;
|
||||
seriesId: number;
|
||||
awards: string;
|
||||
};
|
||||
|
||||
const submitAction = wrappedAction<typeof trophyFormSchema>({
|
||||
action,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
const backfillAction = wrappedAction<v.GenericSchema<BackfillFormFields>>({
|
||||
action,
|
||||
});
|
||||
const loadBackfillable = wrappedLoader<TrophyBackfillLoaderData>({
|
||||
loader: backfillLoader,
|
||||
});
|
||||
|
||||
const users = UserFactory.pool();
|
||||
const managerId = () => users.id(1);
|
||||
const winnerIds = () => users.ids(5).slice(1, 5);
|
||||
const loserIds = () => users.ids(9).slice(5, 9);
|
||||
|
||||
describe("trophy submissions", () => {
|
||||
const submitterId = () => users.id(1);
|
||||
const artistId = () => users.id(2);
|
||||
|
||||
let organizationId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
await users.create(2);
|
||||
organizationId = (
|
||||
await TournamentOrganizationFactory.create({ ownerId: submitterId() })
|
||||
).id;
|
||||
});
|
||||
|
||||
const submit = (overrides: { name: string; creatorId?: number }) =>
|
||||
submitAction(
|
||||
{
|
||||
_action: "CREATE",
|
||||
model: decompressFromBase64(TrophyFactory.MODELS[0]) ?? "",
|
||||
organizationId,
|
||||
description: null,
|
||||
...overrides,
|
||||
},
|
||||
{ user: submitterId() },
|
||||
);
|
||||
|
||||
test("a submission awaits review with the submitter as its creator", async () => {
|
||||
expect(await submit({ name: "Regular Trophy" })).toBe(null);
|
||||
|
||||
const pending = await TrophyRepository.pendingBySubmitter(submitterId());
|
||||
expect(pending.map((trophy) => trophy.name)).toEqual(["Regular Trophy"]);
|
||||
expect(pending[0].creatorId).toBe(submitterId());
|
||||
});
|
||||
|
||||
test("a submission can name someone else as the creator", async () => {
|
||||
expect(
|
||||
await submit({ name: "Commissioned Trophy", creatorId: artistId() }),
|
||||
).toBe(null);
|
||||
|
||||
const [pending] = await TrophyRepository.pendingBySubmitter(submitterId());
|
||||
expect(pending.creatorId).toBe(artistId());
|
||||
expect(pending.creator?.id).toBe(artistId());
|
||||
});
|
||||
});
|
||||
|
||||
describe("trophy backfill", () => {
|
||||
let trophyId: number;
|
||||
let seriesId: number;
|
||||
let tournamentId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
await UserFactory.createAdmin();
|
||||
await users.create(9);
|
||||
await UserFactory.createDev();
|
||||
|
||||
const organization = await TournamentOrganizationFactory.create(
|
||||
{ ownerId: managerId() },
|
||||
{
|
||||
series: [
|
||||
{ name: "Weekly Cup", description: null, showLeaderboard: false },
|
||||
],
|
||||
},
|
||||
);
|
||||
const [series] =
|
||||
await TournamentOrganizationRepository.findAllSeriesByOrganizationIds([
|
||||
organization.id,
|
||||
]);
|
||||
seriesId = series.id;
|
||||
|
||||
trophyId = (
|
||||
await TrophyFactory.create({
|
||||
organizationId: organization.id,
|
||||
managerId: managerId(),
|
||||
})
|
||||
).id;
|
||||
|
||||
tournamentId = (
|
||||
await TournamentFactory.createPlayed(
|
||||
{
|
||||
authorId: managerId(),
|
||||
name: "Weekly Cup #1",
|
||||
organizationId: organization.id,
|
||||
},
|
||||
{ teamRosters: [winnerIds(), loserIds()], playedOut: "all" },
|
||||
)
|
||||
).id;
|
||||
});
|
||||
|
||||
const backfill = (userIds: number[], user: TestUser = "admin") =>
|
||||
backfillAction(
|
||||
{
|
||||
_action: "BACKFILL",
|
||||
trophyId,
|
||||
seriesId,
|
||||
awards: JSON.stringify([{ tournamentId, userIds }]),
|
||||
},
|
||||
{ user },
|
||||
);
|
||||
|
||||
const awardedTournamentIds = async () =>
|
||||
(await TrophyRepository.findTournamentsByTrophyId(trophyId)).map(
|
||||
(tournament) => tournament.tournamentId,
|
||||
);
|
||||
|
||||
test("an admin lists the series' tournaments with their winners", async () => {
|
||||
const data = await loadBackfillable({
|
||||
user: "admin",
|
||||
params: { id: String(trophyId), seriesId: String(seriesId) },
|
||||
});
|
||||
|
||||
expect(
|
||||
data.tournaments.map((tournament) => tournament.tournamentId),
|
||||
).toEqual([tournamentId]);
|
||||
expect(data.tournaments[0].winners.map((winner) => winner.id)).toEqual(
|
||||
winnerIds(),
|
||||
);
|
||||
});
|
||||
|
||||
test("the trophy's manager can't list the tournaments", async () => {
|
||||
await expect(
|
||||
loadBackfillable({
|
||||
user: managerId(),
|
||||
params: { id: String(trophyId), seriesId: String(seriesId) },
|
||||
}),
|
||||
).rejects.toThrow("403");
|
||||
});
|
||||
|
||||
test("an admin awards the trophy to the chosen winners", async () => {
|
||||
await backfill(winnerIds().slice(0, 3));
|
||||
|
||||
expect(await awardedTournamentIds()).toEqual([tournamentId]);
|
||||
|
||||
const ownerIds = (await TrophyRepository.findById(trophyId))?.owners.map(
|
||||
(owner) => owner.id,
|
||||
);
|
||||
expect(ownerIds?.toSorted((a, b) => a - b)).toEqual(
|
||||
winnerIds().slice(0, 3),
|
||||
);
|
||||
});
|
||||
|
||||
test("a dev awards the trophy", async () => {
|
||||
await backfill(winnerIds(), DEV_TEST_ID);
|
||||
|
||||
expect(await awardedTournamentIds()).toEqual([tournamentId]);
|
||||
});
|
||||
|
||||
test("the trophy's manager can't award it", async () => {
|
||||
const response = await backfill(winnerIds(), managerId());
|
||||
|
||||
assertResponseErrored(response, "Not allowed");
|
||||
expect(await awardedTournamentIds()).toEqual([]);
|
||||
});
|
||||
|
||||
test("a player who didn't win the tournament can't receive the trophy", async () => {
|
||||
const response = await backfill([winnerIds()[0], loserIds()[0]]);
|
||||
|
||||
assertResponseErrored(response, "Only the winners");
|
||||
expect(await awardedTournamentIds()).toEqual([]);
|
||||
});
|
||||
|
||||
test("a tournament can't be awarded twice", async () => {
|
||||
await backfill(winnerIds());
|
||||
|
||||
const response = await backfill(winnerIds());
|
||||
|
||||
assertResponseErrored(response, "can't be backfilled");
|
||||
});
|
||||
});
|
||||
@@ -4,22 +4,32 @@ import {
|
||||
type AuthenticatedUser,
|
||||
requireUser,
|
||||
} from "~/features/auth/core/user.server";
|
||||
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
|
||||
import { clearTrophiesCache } from "~/features/trophies/loaders/trophies.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { ConcurrentModificationError } from "~/utils/errors";
|
||||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
errorToast,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
successToast,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { stripDisabledEffects } from "../core/model-analysis";
|
||||
import * as TrophyBackfill from "../core/TrophyBackfill.server";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import { TROPHY_PENDING_PER_USER_LIMIT } from "../trophies-constants";
|
||||
import { trophyActionSchema, trophyFormSchema } from "../trophies-schemas";
|
||||
import {
|
||||
pendingTrophyActionSchema,
|
||||
trophyFormSchema,
|
||||
} from "../trophies-schemas";
|
||||
import { canReviewTrophies, compressTrophyModel } from "../trophies-utils";
|
||||
canBackfillTrophies,
|
||||
canReviewTrophies,
|
||||
compressTrophyModel,
|
||||
} from "../trophies-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = requireUser();
|
||||
@@ -104,7 +114,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: pendingTrophyActionSchema,
|
||||
schema: trophyActionSchema,
|
||||
});
|
||||
|
||||
switch (data._action) {
|
||||
@@ -205,6 +215,58 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
|
||||
return null;
|
||||
}
|
||||
case "BACKFILL": {
|
||||
errorToastIfFalsy(canBackfillTrophies(user), "Not allowed");
|
||||
|
||||
const trophy = await TrophyRepository.findById(data.trophyId);
|
||||
errorToastIfFalsy(trophy, "Trophy not found");
|
||||
errorToastIfFalsy(trophy.organizationId, "Trophy has no organization");
|
||||
|
||||
const tournaments = await TrophyBackfill.backfillableTournaments({
|
||||
organizationId: trophy.organizationId,
|
||||
seriesId: data.seriesId,
|
||||
});
|
||||
errorToastIfFalsy(tournaments, "Series not found");
|
||||
|
||||
const awards = data.awards.map((award) => {
|
||||
const tournament = tournaments.find(
|
||||
(candidate) => candidate.tournamentId === award.tournamentId,
|
||||
);
|
||||
errorToastIfFalsy(tournament, "Tournament can't be backfilled");
|
||||
errorToastIfFalsy(
|
||||
award.userIds.every((userId) =>
|
||||
tournament.winners.some((winner) => winner.id === userId),
|
||||
),
|
||||
"Only the winners of a tournament can receive its trophy",
|
||||
);
|
||||
|
||||
return {
|
||||
tournamentId: award.tournamentId,
|
||||
tournamentTeamId: tournament.tournamentTeamId,
|
||||
userIds: award.userIds,
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await TrophyRepository.backfill({ trophyId: trophy.id, awards });
|
||||
} catch (error) {
|
||||
if (error instanceof ConcurrentModificationError) {
|
||||
errorToast("A tournament got a trophy in the meantime, try again");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Trophy ${trophy.id} backfilled by user ${user.id}: ${JSON.stringify(awards)}`,
|
||||
);
|
||||
|
||||
clearTrophiesCache();
|
||||
ShowcaseTournaments.clearCachedTournaments();
|
||||
|
||||
return successToast(
|
||||
`${trophy.name} awarded for ${awards.length} tournament(s)`,
|
||||
);
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ function SortableTrophyItem({
|
||||
☰
|
||||
</button>
|
||||
<div className={styles.preview}>
|
||||
<Trophy model={trophy.model} preview />
|
||||
<Trophy model={trophy.model} code={trophy.code} preview />
|
||||
</div>
|
||||
<span className={styles.name}>{trophy.name}</span>
|
||||
<SendouButton
|
||||
|
||||
@@ -81,6 +81,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
.animationProgress {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
background-color: var(--color-fill-accent);
|
||||
transform: scaleX(var(--animation-progress, 0));
|
||||
transform-origin: left;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.interactive {
|
||||
cursor: grab;
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ import { TierPill } from "~/components/TierPill";
|
||||
import { useTheme } from "~/features/theme/core/provider";
|
||||
import { usePrefersReducedMotion } from "~/hooks/usePrefersReducedMotion";
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
import { decompressTrophyModel } from "../trophies-utils";
|
||||
import * as XpTrophy from "../core/XpTrophy";
|
||||
import { decompressTrophyModel, useXpTrophyState } from "../trophies-utils";
|
||||
import style from "./Trophy.module.css";
|
||||
|
||||
type TrophyCtxValue =
|
||||
@@ -88,6 +89,7 @@ export function TrophyGrid({
|
||||
|
||||
export function Trophy({
|
||||
model,
|
||||
code,
|
||||
preview,
|
||||
tier,
|
||||
tentativeTier,
|
||||
@@ -98,8 +100,10 @@ export function Trophy({
|
||||
colorScheme: forcedColorScheme,
|
||||
fps = 60,
|
||||
deferred,
|
||||
showAnimationProgress,
|
||||
}: {
|
||||
model: string;
|
||||
code?: string | null;
|
||||
preview?: boolean;
|
||||
tier?: number | null;
|
||||
tentativeTier?: number | null;
|
||||
@@ -110,6 +114,7 @@ export function Trophy({
|
||||
colorScheme?: ColorScheme;
|
||||
fps?: number;
|
||||
deferred?: boolean;
|
||||
showAnimationProgress?: boolean;
|
||||
}) {
|
||||
const ctxValue = useContext(TrophyCtx);
|
||||
const context = ctxValue?.context;
|
||||
@@ -119,22 +124,30 @@ export function Trophy({
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
const [drawn, setDrawn] = useState(false);
|
||||
const [everDrawn, setEverDrawn] = useState(false);
|
||||
const [activeModel, setActiveModel] = useState(model);
|
||||
const [active, setActive] = useState({ model, code });
|
||||
const [hasAnimation, setHasAnimation] = useState(false);
|
||||
const animationProgressRef = useRef<HTMLDivElement>(null);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
|
||||
const onRenderStatsRef = useRef(onRenderStats);
|
||||
onRenderStatsRef.current = onRenderStats;
|
||||
|
||||
const prevModelRef = useRef(model);
|
||||
if (prevModelRef.current !== model) {
|
||||
prevModelRef.current = model;
|
||||
const prevSourceRef = useRef({ model, code });
|
||||
if (
|
||||
prevSourceRef.current.model !== model ||
|
||||
prevSourceRef.current.code !== code
|
||||
) {
|
||||
prevSourceRef.current = { model, code };
|
||||
setError(false);
|
||||
if (!drawn || reducedMotion) setActiveModel(model);
|
||||
if (!drawn || reducedMotion) setActive({ model, code });
|
||||
setDrawn(false);
|
||||
}
|
||||
|
||||
const swapping = activeModel !== model;
|
||||
const modelState = decompressTrophyModel(activeModel);
|
||||
const swapping = active.model !== model || active.code !== code;
|
||||
const xpVariant = XpTrophy.parseCode(active.code);
|
||||
const xpState = useXpTrophyState(xpVariant);
|
||||
const isLoadingXpState = xpVariant !== null && xpState === null;
|
||||
const modelState = xpVariant ? xpState : decompressTrophyModel(active.model);
|
||||
const siteColorScheme = useTrophyColorScheme();
|
||||
const colorScheme = forcedColorScheme ?? siteColorScheme;
|
||||
|
||||
@@ -199,15 +212,25 @@ export function Trophy({
|
||||
return;
|
||||
}
|
||||
|
||||
if (context && onRenderStats) {
|
||||
const animationDuration = viewer.modelInfo?.animationDuration ?? 0;
|
||||
const tracksAnimationProgress = Boolean(
|
||||
showAnimationProgress && animationDuration > 0,
|
||||
);
|
||||
setHasAnimation(tracksAnimationProgress);
|
||||
|
||||
if ((context && onRenderStats) || tracksAnimationProgress) {
|
||||
viewer.onFrame = () => {
|
||||
onRenderStatsRef.current?.({ ...context.stats });
|
||||
if (context) onRenderStatsRef.current?.({ ...context.stats });
|
||||
animationProgressRef.current?.style.setProperty(
|
||||
"--animation-progress",
|
||||
String(viewer.animation.time / animationDuration),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
viewer.startRenderLoop(false);
|
||||
viewer.whenReady().then(() => {
|
||||
if (viewerRef.current !== viewer) return;
|
||||
viewer.startRenderLoop(false);
|
||||
setDrawn(true);
|
||||
setEverDrawn(true);
|
||||
});
|
||||
@@ -234,6 +257,7 @@ export function Trophy({
|
||||
disableCameraControls,
|
||||
colorScheme,
|
||||
fps,
|
||||
showAnimationProgress,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -261,7 +285,7 @@ export function Trophy({
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (error || modelState === null) {
|
||||
if (error || (modelState === null && !isLoadingXpState)) {
|
||||
return (
|
||||
<div className={style.container} style={containerStyle}>
|
||||
<div className={clsx(style.trophy, style.error)}>
|
||||
@@ -275,7 +299,10 @@ export function Trophy({
|
||||
|
||||
return (
|
||||
<div className={style.container} style={containerStyle} aria-busy={!drawn}>
|
||||
{deferred || isLoadingSharedContext || !RENDERS_MODELS ? (
|
||||
{deferred ||
|
||||
isLoadingSharedContext ||
|
||||
isLoadingXpState ||
|
||||
!RENDERS_MODELS ? (
|
||||
<div className={style.trophy} />
|
||||
) : (
|
||||
<canvas
|
||||
@@ -285,10 +312,17 @@ export function Trophy({
|
||||
[style.interactive]: !preview && !disableCameraControls,
|
||||
})}
|
||||
onTransitionEnd={() => {
|
||||
if (swapping) setActiveModel(model);
|
||||
if (swapping) setActive({ model, code });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{hasAnimation ? (
|
||||
<div
|
||||
ref={animationProgressRef}
|
||||
className={style.animationProgress}
|
||||
data-testid="trophy-animation-progress"
|
||||
/>
|
||||
) : null}
|
||||
{everDrawn || swapping ? null : (
|
||||
<div className={style.loading}>
|
||||
<div className={style.spinner} />
|
||||
|
||||
63
app/features/trophies/components/TrophyBackfill.module.css
Normal file
63
app/features/trophies/components/TrophyBackfill.module.css
Normal file
@@ -0,0 +1,63 @@
|
||||
.tournamentList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tournament {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-2-5);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
.tournamentHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.logo {
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.tournamentName {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.winners {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1-5);
|
||||
padding: 0 0 0 var(--s-4);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.winner {
|
||||
display: grid;
|
||||
grid-template-columns: max-content max-content 1fr max-content;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.winnerName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
311
app/features/trophies/components/TrophyBackfill.tsx
Normal file
311
app/features/trophies/components/TrophyBackfill.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher } from "react-router";
|
||||
import { ActionButton } from "~/components/ActionButton";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { ParticipationPill } from "~/features/user-page/components/ParticipationPill";
|
||||
import { trophyBackfillPage } from "~/utils/urls";
|
||||
import type { NewTrophyLoaderData } from "../loaders/trophies.new.server";
|
||||
import type { TrophyBackfillLoaderData } from "../routes/trophies.$id.backfill.$seriesId";
|
||||
import { trophyActionSchema } from "../trophies-schemas";
|
||||
import styles from "./TrophyBackfill.module.css";
|
||||
|
||||
type BackfillTrophy = NewTrophyLoaderData["backfillTrophies"][number];
|
||||
type BackfillTournament = TrophyBackfillLoaderData["tournaments"][number];
|
||||
|
||||
type Awards = Record<number, number[]>;
|
||||
|
||||
export function TrophyBackfill({
|
||||
trophies,
|
||||
series,
|
||||
}: {
|
||||
trophies: NewTrophyLoaderData["backfillTrophies"];
|
||||
series: NewTrophyLoaderData["backfillSeries"];
|
||||
}) {
|
||||
const { t } = useTranslation(["trophies", "common"]);
|
||||
const fetcher = useFetcher<TrophyBackfillLoaderData>();
|
||||
const [trophyId, setTrophyId] = React.useState<number | null>(null);
|
||||
const [seriesId, setSeriesId] = React.useState<number | null>(null);
|
||||
|
||||
const backfillableTrophies = trophies.filter((trophy) =>
|
||||
series.some(
|
||||
(oneSeries) => oneSeries.organizationId === trophy.organizationId,
|
||||
),
|
||||
);
|
||||
const selectedTrophy = backfillableTrophies.find(
|
||||
(trophy) => trophy.id === trophyId,
|
||||
);
|
||||
const trophySeries = selectedTrophy
|
||||
? series.filter(
|
||||
(oneSeries) =>
|
||||
oneSeries.organizationId === selectedTrophy.organizationId,
|
||||
)
|
||||
: [];
|
||||
|
||||
const tournaments =
|
||||
fetcher.data?.trophyId === trophyId && fetcher.data?.seriesId === seriesId
|
||||
? fetcher.data.tournaments
|
||||
: null;
|
||||
|
||||
if (backfillableTrophies.length === 0) {
|
||||
return <Alert>{t("trophies:new.backfill.noTrophies")}</Alert>;
|
||||
}
|
||||
|
||||
const handleTrophyChange = (newTrophyId: number) => {
|
||||
setTrophyId(newTrophyId);
|
||||
setSeriesId(null);
|
||||
};
|
||||
|
||||
const handleSeriesChange = (newSeriesId: number) => {
|
||||
setSeriesId(newSeriesId);
|
||||
if (trophyId) {
|
||||
fetcher.load(trophyBackfillPage({ trophyId, seriesId: newSeriesId }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<p className="text-sm">{t("trophies:new.backfill.explanation")}</p>
|
||||
<Alert variation="WARNING">{t("trophies:new.backfill.warning")}</Alert>
|
||||
<SendouSelect
|
||||
label={t("trophies:new.backfill.trophyLabel")}
|
||||
items={backfillableTrophies}
|
||||
search={{ placeholder: t("trophies:new.update.searchPlaceholder") }}
|
||||
selectedKey={trophyId}
|
||||
onSelectionChange={(key) => handleTrophyChange(key as number)}
|
||||
data-testid="backfill-trophy-select"
|
||||
>
|
||||
{(trophy) => (
|
||||
<SendouSelectItem
|
||||
key={trophy.id}
|
||||
id={trophy.id}
|
||||
textValue={trophy.name}
|
||||
>
|
||||
{trophy.name}
|
||||
</SendouSelectItem>
|
||||
)}
|
||||
</SendouSelect>
|
||||
{selectedTrophy ? (
|
||||
<SendouSelect
|
||||
key={selectedTrophy.id}
|
||||
label={t("trophies:new.backfill.seriesLabel")}
|
||||
items={trophySeries}
|
||||
selectedKey={seriesId}
|
||||
onSelectionChange={(key) => handleSeriesChange(key as number)}
|
||||
data-testid="backfill-series-select"
|
||||
>
|
||||
{(oneSeries) => (
|
||||
<SendouSelectItem
|
||||
key={oneSeries.id}
|
||||
id={oneSeries.id}
|
||||
textValue={oneSeries.name}
|
||||
>
|
||||
{oneSeries.name}
|
||||
</SendouSelectItem>
|
||||
)}
|
||||
</SendouSelect>
|
||||
) : null}
|
||||
{selectedTrophy && seriesId ? (
|
||||
tournaments ? (
|
||||
tournaments.length > 0 ? (
|
||||
<BackfillTournamentList
|
||||
key={tournaments
|
||||
.map((tournament) => tournament.tournamentId)
|
||||
.join()}
|
||||
trophy={selectedTrophy}
|
||||
seriesId={seriesId}
|
||||
tournaments={tournaments}
|
||||
/>
|
||||
) : (
|
||||
<Alert>{t("trophies:new.backfill.noTournaments")}</Alert>
|
||||
)
|
||||
) : (
|
||||
<div className="text-lighter text-sm">
|
||||
{t("common:actions.loading")}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackfillTournamentList({
|
||||
trophy,
|
||||
seriesId,
|
||||
tournaments,
|
||||
}: {
|
||||
trophy: BackfillTrophy;
|
||||
seriesId: number;
|
||||
tournaments: BackfillTournament[];
|
||||
}) {
|
||||
const { t } = useTranslation(["trophies", "common"]);
|
||||
const [awards, setAwards] = React.useState<Awards>({});
|
||||
|
||||
const selectedAwards = Object.entries(awards).map(
|
||||
([tournamentId, userIds]) => ({
|
||||
tournamentId: Number(tournamentId),
|
||||
userIds,
|
||||
}),
|
||||
);
|
||||
const playerCount = selectedAwards.reduce(
|
||||
(sum, award) => sum + award.userIds.length,
|
||||
0,
|
||||
);
|
||||
const hasTournamentWithoutPlayers = selectedAwards.some(
|
||||
(award) => award.userIds.length === 0,
|
||||
);
|
||||
|
||||
const allWinners = (tournament: BackfillTournament) =>
|
||||
tournament.winners.map((winner) => winner.id);
|
||||
|
||||
const toggleTournament = (tournament: BackfillTournament) => {
|
||||
const { [tournament.tournamentId]: existing, ...rest } = awards;
|
||||
setAwards(
|
||||
existing
|
||||
? rest
|
||||
: { ...awards, [tournament.tournamentId]: allWinners(tournament) },
|
||||
);
|
||||
};
|
||||
|
||||
const toggleWinner = (tournamentId: number, userId: number) => {
|
||||
const userIds = awards[tournamentId] ?? [];
|
||||
setAwards({
|
||||
...awards,
|
||||
[tournamentId]: userIds.includes(userId)
|
||||
? userIds.filter((id) => id !== userId)
|
||||
: [...userIds, userId],
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setAwards(
|
||||
Object.fromEntries(
|
||||
tournaments.map((tournament) => [
|
||||
tournament.tournamentId,
|
||||
allWinners(tournament),
|
||||
]),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="small"
|
||||
className="self-start"
|
||||
onClick={selectAll}
|
||||
>
|
||||
{t("common:actions.selectAll")}
|
||||
</SendouButton>
|
||||
<ul className={styles.tournamentList}>
|
||||
{tournaments.map((tournament) => {
|
||||
const receiverIds = awards[tournament.tournamentId];
|
||||
|
||||
return (
|
||||
<li
|
||||
key={tournament.tournamentId}
|
||||
className={styles.tournament}
|
||||
data-testid="backfill-tournament"
|
||||
>
|
||||
<div className={styles.tournamentHeader}>
|
||||
<SendouSwitch
|
||||
isSelected={Boolean(receiverIds)}
|
||||
onChange={() => toggleTournament(tournament)}
|
||||
aria-label={tournament.name}
|
||||
/>
|
||||
<img
|
||||
src={tournament.logoUrl}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
className={styles.logo}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="stack">
|
||||
<span className={styles.tournamentName}>
|
||||
{tournament.name}
|
||||
</span>
|
||||
<span className="text-xs text-lighter">
|
||||
{tournament.startTime ? (
|
||||
<>
|
||||
<LocaleTime
|
||||
date={tournament.startTime}
|
||||
options={{
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}}
|
||||
inline
|
||||
/>
|
||||
{" • "}
|
||||
</>
|
||||
) : null}
|
||||
{tournament.teamName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className={styles.winners}>
|
||||
{tournament.winners.map((winner) => (
|
||||
<li key={winner.id} className={styles.winner}>
|
||||
<SendouSwitch
|
||||
size="small"
|
||||
isSelected={receiverIds?.includes(winner.id) ?? false}
|
||||
isDisabled={!receiverIds}
|
||||
onChange={() =>
|
||||
toggleWinner(tournament.tournamentId, winner.id)
|
||||
}
|
||||
aria-label={winner.username}
|
||||
/>
|
||||
<Avatar user={winner} size="xxs" />
|
||||
<span className={styles.winnerName}>{winner.username}</span>
|
||||
<ParticipationPill setResults={winner.setResults} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className={styles.footer}>
|
||||
<span className="text-sm text-lighter">
|
||||
{t("trophies:new.backfill.selected", {
|
||||
count: selectedAwards.length,
|
||||
playerCount,
|
||||
})}
|
||||
</span>
|
||||
<ActionButton
|
||||
schema={trophyActionSchema}
|
||||
action="BACKFILL"
|
||||
fields={{ trophyId: trophy.id, seriesId, awards: selectedAwards }}
|
||||
isDisabled={
|
||||
selectedAwards.length === 0 || hasTournamentWithoutPlayers
|
||||
}
|
||||
testId="backfill-submit-button"
|
||||
confirm={{
|
||||
dialogHeading: t("trophies:new.backfill.confirm.heading", {
|
||||
trophyName: trophy.name,
|
||||
count: selectedAwards.length,
|
||||
playerCount,
|
||||
}),
|
||||
description: t("trophies:new.backfill.confirm.description"),
|
||||
submitButtonText: t("trophies:new.backfill.submit"),
|
||||
}}
|
||||
>
|
||||
{t("trophies:new.backfill.submit")}
|
||||
</ActionButton>
|
||||
</div>
|
||||
{hasTournamentWithoutPlayers ? (
|
||||
<FormMessage type="error">
|
||||
{t("trophies:new.backfill.noPlayersSelected")}
|
||||
</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,8 @@
|
||||
.winMembers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: var(--s-2);
|
||||
border-left: var(--border-style);
|
||||
}
|
||||
|
||||
.winMember {
|
||||
@@ -46,10 +48,6 @@
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&:nth-child(odd) {
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
}
|
||||
|
||||
@container (width >= 560px) and (width <= 750px) {
|
||||
@@ -91,3 +89,8 @@
|
||||
color: var(--color-text-high);
|
||||
padding-top: var(--s-2);
|
||||
}
|
||||
|
||||
.placements {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -6,20 +6,22 @@ import { Avatar } from "~/components/Avatar";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { DotPagination } from "~/components/DotPagination";
|
||||
import { WeaponImage } from "~/components/Image";
|
||||
import { DivisionImage } from "~/features/top-search/components/DivisionImage";
|
||||
import type { XRankPlacementRegion } from "~/features/top-search/top-search-types";
|
||||
import { UserCard } from "~/features/user-card/components/UserCard";
|
||||
import { ParticipationPill } from "~/features/user-page/components/ParticipationPill";
|
||||
import { usePagination } from "~/hooks/usePagination";
|
||||
import { trophyWinsPage } from "~/utils/urls";
|
||||
import { trophyPlacementsPage, trophyWinsPage } from "~/utils/urls";
|
||||
import * as XpTrophy from "../core/XpTrophy";
|
||||
import type { TrophyPlacementsLoaderData } from "../routes/trophies.$id.placements.$userId";
|
||||
import type { TrophyWinsLoaderData } from "../routes/trophies.$id.wins.$userId";
|
||||
import { SMALL_TROPHIES_PER_DISPLAY_PAGE } from "../trophies-constants";
|
||||
import {
|
||||
parseSpecialTrophyCode,
|
||||
useProgressiveRender,
|
||||
} from "../trophies-utils";
|
||||
import { useProgressiveRender } from "../trophies-utils";
|
||||
import { TournamentSummaryRow } from "./TournamentSummaryRow";
|
||||
import { Trophy, TrophyContextProvider, TrophyGrid } from "./Trophy";
|
||||
import styles from "./TrophyDisplay.module.css";
|
||||
import { TrophyShowcaseModal } from "./TrophyShowcase";
|
||||
import { XPlacementSummaryRow } from "./XPlacementSummaryRow";
|
||||
|
||||
type TrophyItem = {
|
||||
id: number;
|
||||
@@ -28,6 +30,8 @@ type TrophyItem = {
|
||||
tier?: number | null;
|
||||
code?: string | null;
|
||||
count?: number | null;
|
||||
/** Division an X Power trophy was won in. */
|
||||
division?: XRankPlacementRegion | null;
|
||||
};
|
||||
|
||||
export interface TrophyDisplayProps {
|
||||
@@ -41,6 +45,7 @@ export function TrophyDisplay({
|
||||
userId,
|
||||
className,
|
||||
}: TrophyDisplayProps) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const [openTrophy, setOpenTrophy] = React.useState<TrophyItem | null>(null);
|
||||
|
||||
const {
|
||||
@@ -78,6 +83,7 @@ export function TrophyDisplay({
|
||||
>
|
||||
<Trophy
|
||||
model={trophy.model}
|
||||
code={trophy.code}
|
||||
tier={trophy.tier ?? null}
|
||||
preview={!!openTrophy}
|
||||
staticOnSoftwareRendering
|
||||
@@ -85,9 +91,15 @@ export function TrophyDisplay({
|
||||
fps={30}
|
||||
deferred={i >= visibleCount}
|
||||
pill={
|
||||
trophy.count && trophy.count > 1
|
||||
? `×${trophy.count}`
|
||||
: undefined
|
||||
trophy.division ? (
|
||||
<DivisionImage
|
||||
region={trophy.division}
|
||||
size={16}
|
||||
alt={t(`common:divisions.${trophy.division}`)}
|
||||
/>
|
||||
) : trophy.count && trophy.count > 1 ? (
|
||||
`×${trophy.count}`
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
@@ -123,33 +135,53 @@ function TrophyModal({
|
||||
userId: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
const { t } = useTranslation(["trophies", "common"]);
|
||||
const fetcher = useFetcher<TrophyWinsLoaderData>();
|
||||
const placementsFetcher = useFetcher<TrophyPlacementsLoaderData>();
|
||||
const data = fetcher.data;
|
||||
const placements = placementsFetcher.data?.placements;
|
||||
|
||||
const special = parseSpecialTrophyCode(trophy.code);
|
||||
const xpVariant = XpTrophy.parseCode(trophy.code);
|
||||
|
||||
const loadedRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (parseSpecialTrophyCode(trophy.code) || loadedRef.current) return;
|
||||
if (loadedRef.current) return;
|
||||
loadedRef.current = true;
|
||||
fetcher.load(trophyWinsPage({ trophyId: trophy.id, userId }));
|
||||
}, [fetcher.load, trophy.id, trophy.code, userId]);
|
||||
|
||||
if (XpTrophy.parseCode(trophy.code)) {
|
||||
placementsFetcher.load(
|
||||
trophyPlacementsPage({ trophyId: trophy.id, userId }),
|
||||
);
|
||||
} else {
|
||||
fetcher.load(trophyWinsPage({ trophyId: trophy.id, userId }));
|
||||
}
|
||||
}, [fetcher.load, placementsFetcher.load, trophy.id, trophy.code, userId]);
|
||||
|
||||
return (
|
||||
<TrophyShowcaseModal trophy={trophy} onClose={onClose}>
|
||||
{special ? (
|
||||
{xpVariant ? (
|
||||
<div>
|
||||
<Divider />
|
||||
<p className={styles.specialDescription}>
|
||||
{special.type === "supporter"
|
||||
? t("trophies:special.supporter.description")
|
||||
: t("trophies:special.xp.description", {
|
||||
value: special.value,
|
||||
})}
|
||||
{t("trophies:special.xp.categoryDescription", {
|
||||
value: xpVariant.milestone,
|
||||
category: t(
|
||||
`common:weapon.category.${XpTrophy.categoryKey(xpVariant.category)}`,
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{placements && placements.length > 0 ? (
|
||||
<div className="stack xs">
|
||||
<Divider />
|
||||
<div className={styles.placements}>
|
||||
{placements.map((placement) => (
|
||||
<XPlacementSummaryRow key={placement.id} placement={placement} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{data
|
||||
? data.wins.map((win) => (
|
||||
<div key={win.tournamentId}>
|
||||
|
||||
@@ -9,11 +9,13 @@ import styles from "./TrophyShowcase.module.css";
|
||||
|
||||
export function TrophyShowcase({
|
||||
model,
|
||||
code,
|
||||
children,
|
||||
className,
|
||||
detailsClassName,
|
||||
}: {
|
||||
model: string;
|
||||
code?: string | null;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
detailsClassName?: string;
|
||||
@@ -21,7 +23,7 @@ export function TrophyShowcase({
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={clsx(styles.content, className)}>
|
||||
<Trophy model={model} />
|
||||
<Trophy model={model} code={code} />
|
||||
<div className={clsx(styles.details, detailsClassName, "scrollbar")}>
|
||||
{children}
|
||||
</div>
|
||||
@@ -35,7 +37,7 @@ export function TrophyShowcaseModal({
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
trophy: { id: number; name: string; model: string };
|
||||
trophy: { id: number; name: string; model: string; code?: string | null };
|
||||
onClose: () => void;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
@@ -49,7 +51,7 @@ export function TrophyShowcaseModal({
|
||||
isDismissable
|
||||
className={styles.modal}
|
||||
>
|
||||
<TrophyShowcase model={trophy.model}>
|
||||
<TrophyShowcase model={trophy.model} code={trophy.code}>
|
||||
<div className="stack xxs">
|
||||
<p className={styles.trophyName}>{trophy.name}</p>
|
||||
<Link to={trophyPage(trophy.id)} className={styles.trophyPageLink}>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-1-5);
|
||||
border-radius: var(--radius-field);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
}
|
||||
|
||||
.power {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-1-5);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.rank {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s-1) var(--s-2);
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
71
app/features/trophies/components/XPlacementSummaryRow.tsx
Normal file
71
app/features/trophies/components/XPlacementSummaryRow.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { clsx } from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { ModeImage, WeaponImage } from "~/components/Image";
|
||||
import { DivisionImage } from "~/features/top-search/components/DivisionImage";
|
||||
import type { XRankPlacementRegion } from "~/features/top-search/top-search-types";
|
||||
import { topSearchPage } from "~/features/top-search/top-search-urls";
|
||||
import { monthYearToSpan } from "~/features/top-search/top-search-utils";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import type { MainWeaponId, ModeShort } from "~/modules/in-game-lists/types";
|
||||
import styles from "./XPlacementSummaryRow.module.css";
|
||||
|
||||
export function XPlacementSummaryRow({
|
||||
placement,
|
||||
className,
|
||||
}: {
|
||||
placement: {
|
||||
weaponSplId: MainWeaponId;
|
||||
power: number;
|
||||
rank: number;
|
||||
region: XRankPlacementRegion;
|
||||
mode: ModeShort;
|
||||
month: number;
|
||||
year: number;
|
||||
};
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { formatter } = useDateTimeFormat({ month: "short", year: "numeric" });
|
||||
|
||||
const season = monthYearToSpan(placement);
|
||||
const divisionName = t(`common:divisions.${placement.region}`);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={topSearchPage(placement)}
|
||||
className={clsx(styles.row, className)}
|
||||
data-testid="trophy-placement"
|
||||
>
|
||||
<WeaponImage
|
||||
weaponSplId={placement.weaponSplId}
|
||||
variant="badge"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<div className="stack xxs">
|
||||
<span className={styles.power}>
|
||||
{placement.power.toFixed(1)}
|
||||
<span className={styles.rank}>#{placement.rank}</span>
|
||||
</span>
|
||||
<div className={styles.meta}>
|
||||
<span className={styles.metaItem}>
|
||||
<DivisionImage
|
||||
region={placement.region}
|
||||
size={14}
|
||||
alt={divisionName}
|
||||
/>
|
||||
{divisionName}
|
||||
</span>
|
||||
<ModeImage mode={placement.mode} size={14} />
|
||||
<span>
|
||||
{formatter.formatRange(
|
||||
new Date(season.from.year, season.from.month - 1),
|
||||
new Date(season.to.year, season.to.month - 1),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
251
app/features/trophies/core/TrophyBackfill.server.test.ts
Normal file
251
app/features/trophies/core/TrophyBackfill.server.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { sub } from "date-fns";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as BadgeFactory from "~/db/seed/factories/BadgeFactory";
|
||||
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
|
||||
import * as TrophyFactory from "~/db/seed/factories/TrophyFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import type * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import * as TrophyBackfill from "./TrophyBackfill.server";
|
||||
|
||||
const SINGLE_BRACKET: Progression.ParsedBracket[] = [
|
||||
{
|
||||
name: "Main Bracket",
|
||||
type: "single_elimination",
|
||||
settings: {},
|
||||
requiresCheckIn: false,
|
||||
},
|
||||
];
|
||||
|
||||
const TWO_STARTING_BRACKETS: Progression.ParsedBracket[] = [
|
||||
{
|
||||
name: "Division 1",
|
||||
type: "single_elimination",
|
||||
settings: {},
|
||||
requiresCheckIn: false,
|
||||
},
|
||||
{
|
||||
name: "Division 2",
|
||||
type: "single_elimination",
|
||||
settings: {},
|
||||
requiresCheckIn: false,
|
||||
},
|
||||
];
|
||||
|
||||
const AB_DIVISIONS_FINALS: Progression.ParsedBracket[] = [
|
||||
{
|
||||
name: "Finals",
|
||||
type: "round_robin",
|
||||
settings: { hasAbDivisions: true },
|
||||
requiresCheckIn: false,
|
||||
},
|
||||
];
|
||||
|
||||
const placer = (
|
||||
userId: number,
|
||||
div: string | null,
|
||||
setResults: Array<"W" | "L" | null> = ["W"],
|
||||
) => ({ userId, div, setResults });
|
||||
|
||||
describe("TrophyBackfill.eligibleWinners", () => {
|
||||
test.each([
|
||||
{
|
||||
why: "every first placer without divisions",
|
||||
bracketProgression: SINGLE_BRACKET,
|
||||
firstPlacers: [placer(1, null), placer(2, null)],
|
||||
expected: [1, 2],
|
||||
},
|
||||
{
|
||||
why: "the first starting bracket's division",
|
||||
bracketProgression: TWO_STARTING_BRACKETS,
|
||||
firstPlacers: [placer(1, "Division 1"), placer(2, "Division 2")],
|
||||
expected: [1],
|
||||
},
|
||||
{
|
||||
why: "the A division of A/B division finals",
|
||||
bracketProgression: AB_DIVISIONS_FINALS,
|
||||
firstPlacers: [placer(1, "B"), placer(2, "A")],
|
||||
expected: [2],
|
||||
},
|
||||
{
|
||||
why: "no one when the top division has no winners",
|
||||
bracketProgression: TWO_STARTING_BRACKETS,
|
||||
firstPlacers: [placer(1, "Division 2")],
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
why: "no one who didn't play a single set",
|
||||
bracketProgression: SINGLE_BRACKET,
|
||||
firstPlacers: [
|
||||
placer(1, null, ["W", null]),
|
||||
placer(2, null, [null, null]),
|
||||
placer(3, null, []),
|
||||
],
|
||||
expected: [1],
|
||||
},
|
||||
])("picks $why", ({ bracketProgression, firstPlacers, expected }) => {
|
||||
expect(
|
||||
TrophyBackfill.eligibleWinners({ firstPlacers, bracketProgression }).map(
|
||||
(winner) => winner.userId,
|
||||
),
|
||||
).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TrophyBackfill.backfillableTournaments", () => {
|
||||
const users = UserFactory.pool();
|
||||
const organizerId = () => users.id(1);
|
||||
const winnerIds = () => users.ids(9).slice(1, 5);
|
||||
const benchedWinnerId = () => users.id(6);
|
||||
const loserRoster = () => users.ids(9).slice(6, 9);
|
||||
|
||||
let organizationId: number;
|
||||
let seriesId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
await users.create(9);
|
||||
|
||||
const organization = await TournamentOrganizationFactory.create(
|
||||
{ ownerId: organizerId() },
|
||||
{
|
||||
series: [
|
||||
{ name: "Weekly Cup", description: null, showLeaderboard: false },
|
||||
],
|
||||
},
|
||||
);
|
||||
organizationId = organization.id;
|
||||
|
||||
const [series] =
|
||||
await TournamentOrganizationRepository.findAllSeriesByOrganizationIds([
|
||||
organizationId,
|
||||
]);
|
||||
seriesId = series.id;
|
||||
});
|
||||
|
||||
const createFinalized = (
|
||||
overrides: Partial<Parameters<typeof TournamentFactory.create>[0]> = {},
|
||||
) =>
|
||||
TournamentFactory.createPlayed(
|
||||
{
|
||||
authorId: organizerId(),
|
||||
name: "Weekly Cup #1",
|
||||
organizationId,
|
||||
...overrides,
|
||||
},
|
||||
{
|
||||
teamRosters: [
|
||||
[...winnerIds(), benchedWinnerId()],
|
||||
[organizerId(), ...loserRoster()],
|
||||
],
|
||||
playedOut: "all",
|
||||
},
|
||||
);
|
||||
|
||||
const backfillable = () =>
|
||||
TrophyBackfill.backfillableTournaments({ organizationId, seriesId });
|
||||
|
||||
test("lists a finalized tournament of the series with its winners who played", async () => {
|
||||
const tournament = await createFinalized();
|
||||
|
||||
const tournaments = await backfillable();
|
||||
|
||||
expect(tournaments).toHaveLength(1);
|
||||
expect(tournaments?.[0]).toMatchObject({
|
||||
tournamentId: tournament.id,
|
||||
name: "Weekly Cup #1",
|
||||
tournamentTeamId: tournament.teams[0].id,
|
||||
});
|
||||
expect(tournaments?.[0].winners.map((winner) => winner.id)).toEqual(
|
||||
winnerIds(),
|
||||
);
|
||||
});
|
||||
|
||||
test("lists a tournament whose winners already got a badge", async () => {
|
||||
const badge = await BadgeFactory.create();
|
||||
await createFinalized({ badges: [badge.id] });
|
||||
|
||||
expect(await backfillable()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("skips a tournament that already has a trophy", async () => {
|
||||
const trophy = await TrophyFactory.create({ organizationId });
|
||||
await createFinalized({ trophyId: trophy.id });
|
||||
|
||||
expect(await backfillable()).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips a tournament outside of the series", async () => {
|
||||
await createFinalized({ name: "Monthly Showdown" });
|
||||
|
||||
expect(await backfillable()).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips a tournament of another organization", async () => {
|
||||
const otherOrganization = await TournamentOrganizationFactory.create({
|
||||
ownerId: organizerId(),
|
||||
});
|
||||
await createFinalized({ organizationId: otherOrganization.id });
|
||||
|
||||
expect(await backfillable()).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips a test tournament", async () => {
|
||||
await createFinalized({ isTest: true });
|
||||
|
||||
expect(await backfillable()).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips a tournament that is not finalized", async () => {
|
||||
await TournamentFactory.createPlayed(
|
||||
{ authorId: organizerId(), name: "Weekly Cup #2", organizationId },
|
||||
{
|
||||
teamRosters: [winnerIds(), [organizerId(), ...loserRoster()]],
|
||||
},
|
||||
);
|
||||
|
||||
expect(await backfillable()).toEqual([]);
|
||||
});
|
||||
|
||||
test("lists newest tournaments first", async () => {
|
||||
const older = await createFinalized({
|
||||
name: "Weekly Cup #1",
|
||||
startTimes: [daysAgoTimestamp(14)],
|
||||
});
|
||||
const newer = await createFinalized({
|
||||
name: "Weekly Cup #2",
|
||||
startTimes: [daysAgoTimestamp(7)],
|
||||
});
|
||||
|
||||
expect(
|
||||
(await backfillable())?.map((tournament) => tournament.tournamentId),
|
||||
).toEqual([newer.id, older.id]);
|
||||
});
|
||||
|
||||
test("is null for a series of another organization", async () => {
|
||||
const otherOrganization = await TournamentOrganizationFactory.create(
|
||||
{ ownerId: organizerId() },
|
||||
{
|
||||
series: [
|
||||
{ name: "Other Cup", description: null, showLeaderboard: false },
|
||||
],
|
||||
},
|
||||
);
|
||||
const [otherSeries] =
|
||||
await TournamentOrganizationRepository.findAllSeriesByOrganizationIds([
|
||||
otherOrganization.id,
|
||||
]);
|
||||
|
||||
expect(
|
||||
await TrophyBackfill.backfillableTournaments({
|
||||
organizationId,
|
||||
seriesId: otherSeries.id,
|
||||
}),
|
||||
).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
function daysAgoTimestamp(days: number) {
|
||||
return dateToDatabaseTimestamp(sub(new Date(), { days }));
|
||||
}
|
||||
76
app/features/trophies/core/TrophyBackfill.server.ts
Normal file
76
app/features/trophies/core/TrophyBackfill.server.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { getBracketProgressionLabel } from "~/features/tournament/tournament-utils";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
|
||||
export async function backfillableTournaments({
|
||||
organizationId,
|
||||
seriesId,
|
||||
}: {
|
||||
organizationId: number;
|
||||
seriesId: number;
|
||||
}) {
|
||||
const series = (
|
||||
await TournamentOrganizationRepository.findAllSeriesByOrganizationIds([
|
||||
organizationId,
|
||||
])
|
||||
).find((candidate) => candidate.id === seriesId);
|
||||
if (!series) return null;
|
||||
|
||||
const tournaments = await TrophyRepository.findAllBackfillableTournaments({
|
||||
organizationId,
|
||||
substringMatches: series.substringMatches,
|
||||
});
|
||||
|
||||
return tournaments.flatMap(({ settings, firstPlacers, ...tournament }) => {
|
||||
const winners = eligibleWinners({
|
||||
firstPlacers,
|
||||
bracketProgression: settings.bracketProgression,
|
||||
});
|
||||
if (winners.length === 0) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
...tournament,
|
||||
tournamentTeamId: winners[0].tournamentTeamId,
|
||||
teamName: winners[0].teamName,
|
||||
winners: winners.map(
|
||||
({
|
||||
div: _div,
|
||||
tournamentTeamId: _teamId,
|
||||
teamName: _name,
|
||||
...winner
|
||||
}) => winner,
|
||||
),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function eligibleWinners<
|
||||
T extends { div: string | null; setResults: ReadonlyArray<unknown> },
|
||||
>({
|
||||
firstPlacers,
|
||||
bracketProgression,
|
||||
}: {
|
||||
firstPlacers: T[];
|
||||
bracketProgression: Progression.ParsedBracket[];
|
||||
}) {
|
||||
const hasDivisions = firstPlacers.some((placer) => placer.div !== null);
|
||||
const topDivision = hasDivisions
|
||||
? topDivisionLabel(bracketProgression)
|
||||
: null;
|
||||
|
||||
return firstPlacers.filter(
|
||||
(placer) => placer.div === topDivision && placer.setResults.some(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function topDivisionLabel(bracketProgression: Progression.ParsedBracket[]) {
|
||||
if (Progression.hasAbDivisionsFinals(bracketProgression)) return "A";
|
||||
|
||||
return getBracketProgressionLabel(
|
||||
Progression.startingBrackets(bracketProgression)[0],
|
||||
bracketProgression,
|
||||
);
|
||||
}
|
||||
205
app/features/trophies/core/XpTrophy.test.ts
Normal file
205
app/features/trophies/core/XpTrophy.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { RawPicoCAD2File } from "picocad2-web";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { analyzeTrophyModel } from "./model-analysis";
|
||||
import * as XpTrophy from "./XpTrophy";
|
||||
|
||||
type VisibleNode = {
|
||||
readonly name: string;
|
||||
readonly visible: boolean;
|
||||
readonly children: readonly VisibleNode[];
|
||||
};
|
||||
|
||||
const masterJson = readFileSync(
|
||||
new URL("../data/xp-trophies-master.txt", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const master: RawPicoCAD2File = JSON.parse(masterJson);
|
||||
|
||||
describe("XpTrophy.VARIANTS", () => {
|
||||
test("has one trophy per weapon category and milestone", () => {
|
||||
const codes = XpTrophy.VARIANTS.map((variant) => variant.code);
|
||||
|
||||
expect(codes).toHaveLength(11 * 4);
|
||||
expect(new Set(codes).size).toBe(codes.length);
|
||||
});
|
||||
|
||||
test("has a model for every weapon category", () => {
|
||||
const modelled = new Set(master.graph.children.map((node) => node.name));
|
||||
const missing = XpTrophy.VARIANTS.filter(
|
||||
(variant) => !modelled.has(variant.category),
|
||||
);
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("XpTrophy.parseCode", () => {
|
||||
test.each([
|
||||
{
|
||||
why: "an X Power trophy",
|
||||
code: "xp-shooters-3500",
|
||||
expected: { category: "shooters", milestone: 3500 },
|
||||
},
|
||||
{ why: "a tournament trophy", code: null, expected: null },
|
||||
{ why: "an unknown milestone", code: "xp-shooters-3100", expected: null },
|
||||
{ why: "an unknown category", code: "xp-bows-3000", expected: null },
|
||||
])("parses $why", ({ code, expected }) => {
|
||||
const parsed = XpTrophy.parseCode(code);
|
||||
|
||||
expect(
|
||||
parsed
|
||||
? { category: parsed.category, milestone: parsed.milestone }
|
||||
: null,
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
test("round trips every variant's code", () => {
|
||||
for (const variant of XpTrophy.VARIANTS) {
|
||||
expect(XpTrophy.parseCode(XpTrophy.code(variant))?.code).toBe(
|
||||
variant.code,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("XpTrophy.milestoneFor", () => {
|
||||
test.each([
|
||||
{ power: 2999.9, expected: null },
|
||||
{ power: 3000, expected: 3000 },
|
||||
{ power: 3199.9, expected: 3000 },
|
||||
{ power: 3200, expected: 3200 },
|
||||
{ power: 3650.4, expected: 3500 },
|
||||
{ power: 4100, expected: 4000 },
|
||||
])("$power X Power reaches $expected", ({ power, expected }) => {
|
||||
expect(XpTrophy.milestoneFor(power)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("XpTrophy.awards", () => {
|
||||
const SPLATTERSHOT = 40;
|
||||
const SPLAT_ROLLER = 1010;
|
||||
const SPLAT_CHARGER = 2010;
|
||||
const E_LITER = 2030;
|
||||
|
||||
test("awards the highest milestone reached with each category's weapons", () => {
|
||||
expect(
|
||||
XpTrophy.awards([
|
||||
{ userId: 1, weaponSplId: SPLATTERSHOT, power: 3550 },
|
||||
{ userId: 1, weaponSplId: SPLAT_CHARGER, power: 3050 },
|
||||
{ userId: 1, weaponSplId: E_LITER, power: 3250 },
|
||||
{ userId: 1, weaponSplId: SPLAT_ROLLER, power: 2900 },
|
||||
]).toSorted((a, b) => a.code.localeCompare(b.code)),
|
||||
).toEqual([
|
||||
{ userId: 1, code: "xp-chargers-3200" },
|
||||
{ userId: 1, code: "xp-shooters-3500" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("awards every user separately", () => {
|
||||
expect(
|
||||
XpTrophy.awards([
|
||||
{ userId: 1, weaponSplId: SPLATTERSHOT, power: 3000 },
|
||||
{ userId: 2, weaponSplId: SPLATTERSHOT, power: 4000 },
|
||||
]),
|
||||
).toEqual([
|
||||
{ userId: 1, code: "xp-shooters-3000" },
|
||||
{ userId: 2, code: "xp-shooters-4000" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("XpTrophy.division", () => {
|
||||
const SPLATTERSHOT = 40;
|
||||
const SPLAT_CHARGER = 2010;
|
||||
const shooters3500 = { category: "shooters", milestone: 3500 } as const;
|
||||
|
||||
test.each([
|
||||
{
|
||||
why: "Tentatek when only reached there",
|
||||
placements: [{ weaponSplId: SPLATTERSHOT, power: 3510, region: "WEST" }],
|
||||
expected: "WEST",
|
||||
},
|
||||
{
|
||||
why: "Takoroka when only reached there",
|
||||
placements: [{ weaponSplId: SPLATTERSHOT, power: 3510, region: "JPN" }],
|
||||
expected: "JPN",
|
||||
},
|
||||
{
|
||||
why: "Takoroka when reached in both",
|
||||
placements: [
|
||||
{ weaponSplId: SPLATTERSHOT, power: 3700, region: "WEST" },
|
||||
{ weaponSplId: SPLATTERSHOT, power: 3510, region: "JPN" },
|
||||
],
|
||||
expected: "JPN",
|
||||
},
|
||||
{
|
||||
why: "the division the milestone was reached in, not any it was played in",
|
||||
placements: [
|
||||
{ weaponSplId: SPLATTERSHOT, power: 3510, region: "WEST" },
|
||||
{ weaponSplId: SPLATTERSHOT, power: 3400, region: "JPN" },
|
||||
],
|
||||
expected: "WEST",
|
||||
},
|
||||
{
|
||||
why: "none when only reached with another category",
|
||||
placements: [{ weaponSplId: SPLAT_CHARGER, power: 3600, region: "JPN" }],
|
||||
expected: null,
|
||||
},
|
||||
] as const)("is $why", ({ placements, expected }) => {
|
||||
expect(XpTrophy.division(placements, shooters3500)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("XpTrophy.variantState", () => {
|
||||
const variant = { category: "chargers", milestone: 3500 } as const;
|
||||
const state = XpTrophy.variantState(master, variant);
|
||||
const children = state.source?.graph.children ?? [];
|
||||
|
||||
test("keeps only the variant's category and the pedestal", () => {
|
||||
expect(children.map((node) => node.name).toSorted()).toEqual([
|
||||
"chargers",
|
||||
"pedestal",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps only the variant's milestone digits on the pedestal", () => {
|
||||
const pedestal = children.find((node) => node.name === "pedestal");
|
||||
const digitFolders = pedestal?.children
|
||||
.map((node) => node.name)
|
||||
.filter((name) => /^\d+$/.test(name));
|
||||
|
||||
expect(digitFolders).toEqual(["35"]);
|
||||
});
|
||||
|
||||
test("shows every part of the variant without forcing anything visible", () => {
|
||||
const hidden = (node: VisibleNode): string[] => [
|
||||
...(node.visible ? [] : [node.name]),
|
||||
...node.children.flatMap(hidden),
|
||||
];
|
||||
|
||||
expect(children.flatMap(hidden)).toEqual([]);
|
||||
});
|
||||
|
||||
test("meets the requirements of an uploaded trophy", () => {
|
||||
const analysis = analyzeTrophyModel(JSON.stringify(state));
|
||||
|
||||
expect(analysis?.cameraTargetCentered).toBe(true);
|
||||
expect(analysis?.backgroundIsAlpha).toBe(true);
|
||||
});
|
||||
|
||||
test.each([...new Set(XpTrophy.VARIANTS.map(({ category }) => category))])(
|
||||
"spins %s around its center",
|
||||
(category) => {
|
||||
const [x, , z] =
|
||||
XpTrophy.variantState(master, { category, milestone: 3000 }).model
|
||||
?.camera?.target ?? [];
|
||||
|
||||
expect([x, z]).toEqual([0, 0]);
|
||||
},
|
||||
);
|
||||
|
||||
test("leaves the master untouched", () => {
|
||||
expect(master).toEqual(JSON.parse(masterJson));
|
||||
});
|
||||
});
|
||||
335
app/features/trophies/core/XpTrophy.ts
Normal file
335
app/features/trophies/core/XpTrophy.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import type {
|
||||
CameraSettings,
|
||||
ExtrasOptions,
|
||||
PicoCAD2ViewerState,
|
||||
RawPicoCAD2File,
|
||||
} from "picocad2-web";
|
||||
import type { XRankPlacementRegion } from "~/features/top-search/top-search-types";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { weaponCategories } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { toColor3 } from "./model-analysis";
|
||||
|
||||
const CODE_PREFIX = "xp-";
|
||||
const PEDESTAL_FOLDER = "pedestal";
|
||||
|
||||
const SHARED_EFFECTS: ExtrasOptions = {
|
||||
specular: {
|
||||
enabled: true,
|
||||
environment: {
|
||||
strength: 0.2,
|
||||
skyColor: [1, 1, 1],
|
||||
groundColor: [0, 0, 0],
|
||||
horizon: 0.6,
|
||||
fresnel: 0.73,
|
||||
},
|
||||
style: "smooth",
|
||||
nodes: ["pedestal", "rod.1", "rod.2", "rod.3", "rod.4"],
|
||||
},
|
||||
gradientOutline: {
|
||||
enabled: true,
|
||||
colorFrom: [0.04, 0.04, 0.04],
|
||||
gradient: 0,
|
||||
mode: "dropShadow",
|
||||
},
|
||||
};
|
||||
|
||||
const THREE_POINT_TWO_THOUSAND_EFFECTS: ExtrasOptions = {
|
||||
meshDeform: {
|
||||
enabled: true,
|
||||
nodes: ["hundreds", "tens", "30", "32", "35", "40"],
|
||||
cycle: {
|
||||
enabled: true,
|
||||
mode: "loop",
|
||||
hold: 0,
|
||||
},
|
||||
sweep: {
|
||||
mode: "directional",
|
||||
direction: [0, 0, 1],
|
||||
softness: 0.2,
|
||||
wave: 0.4,
|
||||
},
|
||||
spherify: {
|
||||
amount: 0.15,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const THREE_POINT_FIVE_THOUSAND_EFFECTS: ExtrasOptions = {
|
||||
...THREE_POINT_TWO_THOUSAND_EFFECTS,
|
||||
triangleShatter: {
|
||||
enabled: true,
|
||||
cycle: {
|
||||
enabled: true,
|
||||
mode: "loop",
|
||||
duration: 8,
|
||||
hold: 1,
|
||||
},
|
||||
sweep: {
|
||||
mode: "directional",
|
||||
softness: 0.2,
|
||||
wave: 0.3,
|
||||
},
|
||||
distance: 2.5,
|
||||
spread: 0.75,
|
||||
rotation: 0.5,
|
||||
shrink: 1,
|
||||
nodes: [
|
||||
"HEAVY",
|
||||
"Charger.1",
|
||||
"RollerModel",
|
||||
"Shooter.1",
|
||||
"DualiesUNO",
|
||||
"DualiesDOS",
|
||||
"sword",
|
||||
"Bow",
|
||||
"deflect the rain",
|
||||
"EXPLOSION",
|
||||
"BUCKET",
|
||||
"ihatethisweapon",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const FOUR_THOUSAND_EFFECTS: ExtrasOptions = {
|
||||
...THREE_POINT_FIVE_THOUSAND_EFFECTS,
|
||||
interior: {
|
||||
enabled: true,
|
||||
pattern: "constellations",
|
||||
depth: 6,
|
||||
scale: 0.9,
|
||||
speed: 0.75,
|
||||
backgroundColor: [
|
||||
0.1411764705882353, 0.1411764705882353, 0.1411764705882353,
|
||||
],
|
||||
style: "smooth",
|
||||
maskedColors: [3, 1, 5],
|
||||
nodes: [
|
||||
"basebot",
|
||||
"chargers",
|
||||
"rollers",
|
||||
"shooters",
|
||||
"dualies",
|
||||
"splatlings",
|
||||
"splatanas",
|
||||
"stringers",
|
||||
"brellas",
|
||||
"blasters",
|
||||
"sloshers",
|
||||
"brushes",
|
||||
],
|
||||
},
|
||||
bloom: {
|
||||
enabled: true,
|
||||
threshold: 0.65,
|
||||
intensity: 1.71,
|
||||
blur: 1.77,
|
||||
maskedColors: [3, 5, 1],
|
||||
},
|
||||
};
|
||||
|
||||
const TARGET_PER_CATEGORY = {
|
||||
blasters: { distance: 16.1, position: [0, 2.1, 0] },
|
||||
brellas: { distance: 17.1, position: [0, 2.3, 0] },
|
||||
brushes: { distance: 19.1, position: [0, 3, 0] },
|
||||
chargers: { distance: 16.1, position: [0, 2.1, 0] },
|
||||
dualies: { distance: 16.1, position: [0, 2.1, 0] },
|
||||
rollers: { distance: 17.8, position: [0, 2.6, 0] },
|
||||
shooters: { distance: 16.1, position: [0, 2.1, 0] },
|
||||
sloshers: { distance: 16.1, position: [0, 2.1, 0] },
|
||||
splatanas: { distance: 18.1, position: [0, 2.5, 0] },
|
||||
splatlings: { distance: 16.1, position: [0, 2.1, 0] },
|
||||
stringers: { distance: 17.1, position: [0, 2.2, 0] },
|
||||
} satisfies Record<
|
||||
Category,
|
||||
{ distance: number; position: CameraSettings["target"] }
|
||||
>;
|
||||
|
||||
const MILESTONES = [
|
||||
{ value: 3000, folder: "30", effects: { ...SHARED_EFFECTS } },
|
||||
{
|
||||
value: 3200,
|
||||
folder: "32",
|
||||
effects: { ...SHARED_EFFECTS, ...THREE_POINT_TWO_THOUSAND_EFFECTS },
|
||||
},
|
||||
{
|
||||
value: 3500,
|
||||
folder: "35",
|
||||
effects: { ...SHARED_EFFECTS, ...THREE_POINT_FIVE_THOUSAND_EFFECTS },
|
||||
},
|
||||
{
|
||||
value: 4000,
|
||||
folder: "40",
|
||||
effects: { ...SHARED_EFFECTS, ...FOUR_THOUSAND_EFFECTS },
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
value: number;
|
||||
folder: string;
|
||||
effects: ExtrasOptions;
|
||||
}>;
|
||||
|
||||
const CAMERA: Pick<CameraSettings, "omega" | "theta"> = {
|
||||
omega: 0.02,
|
||||
theta: 0.03,
|
||||
};
|
||||
|
||||
type Milestone = (typeof MILESTONES)[number]["value"];
|
||||
type Category = Lowercase<(typeof weaponCategories)[number]["name"]>;
|
||||
|
||||
export interface Variant {
|
||||
category: Category;
|
||||
milestone: Milestone;
|
||||
}
|
||||
|
||||
// Every possible variant
|
||||
export const VARIANTS: ReadonlyArray<Variant & { code: string; name: string }> =
|
||||
weaponCategories.flatMap(({ name }) =>
|
||||
MILESTONES.map(({ value }) => {
|
||||
const variant = { category: toCategory(name), milestone: value };
|
||||
return { ...variant, code: code(variant), name: variantName(variant) };
|
||||
}),
|
||||
);
|
||||
|
||||
export function code({ category, milestone }: Variant) {
|
||||
return `${CODE_PREFIX}${category}-${milestone}`;
|
||||
}
|
||||
|
||||
// This is just for SQL "like" queries
|
||||
export const CODE_LIKE_PATTERN = `${CODE_PREFIX}%`;
|
||||
|
||||
export function parseCode(trophyCode: string | null | undefined) {
|
||||
if (!trophyCode) return null;
|
||||
|
||||
return VARIANTS.find((variant) => variant.code === trophyCode) ?? null;
|
||||
}
|
||||
|
||||
export function categoryKey(category: Category) {
|
||||
return category.toUpperCase() as Uppercase<Category>;
|
||||
}
|
||||
|
||||
export function milestoneFor(power: number) {
|
||||
return (
|
||||
MILESTONES.findLast((milestone) => power >= milestone.value)?.value ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function categoryWeaponIds(category: Category): readonly MainWeaponId[] {
|
||||
return (
|
||||
weaponCategories.find(({ name }) => toCategory(name) === category)
|
||||
?.weaponIds ?? []
|
||||
);
|
||||
}
|
||||
|
||||
export function awards(
|
||||
placements: ReadonlyArray<{
|
||||
userId: number;
|
||||
weaponSplId: MainWeaponId;
|
||||
power: number;
|
||||
}>,
|
||||
) {
|
||||
const peaks = new Map<
|
||||
string,
|
||||
{ userId: number; category: Category; power: number }
|
||||
>();
|
||||
|
||||
for (const { userId, weaponSplId, power } of placements) {
|
||||
const category = weaponCategory(weaponSplId);
|
||||
if (!category) continue;
|
||||
|
||||
const key = `${userId}-${category}`;
|
||||
const peak = peaks.get(key);
|
||||
if (!peak || power > peak.power) {
|
||||
peaks.set(key, { userId, category, power });
|
||||
}
|
||||
}
|
||||
|
||||
return [...peaks.values()].flatMap(({ userId, category, power }) => {
|
||||
const milestone = milestoneFor(power);
|
||||
return milestone ? [{ userId, code: code({ category, milestone }) }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function division(
|
||||
placements: ReadonlyArray<{
|
||||
weaponSplId: MainWeaponId;
|
||||
power: number;
|
||||
region: XRankPlacementRegion;
|
||||
}>,
|
||||
{ category, milestone }: Variant,
|
||||
): XRankPlacementRegion | null {
|
||||
const weaponIds: readonly number[] = categoryWeaponIds(category);
|
||||
const regions = new Set(
|
||||
placements
|
||||
.filter(
|
||||
(placement) =>
|
||||
weaponIds.includes(placement.weaponSplId) &&
|
||||
placement.power >= milestone,
|
||||
)
|
||||
.map((placement) => placement.region),
|
||||
);
|
||||
|
||||
if (regions.has("JPN")) return "JPN";
|
||||
if (regions.has("WEST")) return "WEST";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function variantState(
|
||||
master: RawPicoCAD2File,
|
||||
{ category, milestone }: Variant,
|
||||
): PicoCAD2ViewerState {
|
||||
const milestoneFolder = MILESTONES.find(
|
||||
(candidate) => candidate.value === milestone,
|
||||
);
|
||||
const otherMilestoneFolders = new Set<string>(
|
||||
MILESTONES.filter((candidate) => candidate !== milestoneFolder).map(
|
||||
(candidate) => candidate.folder,
|
||||
),
|
||||
);
|
||||
|
||||
const children = master.graph.children.flatMap((node) => {
|
||||
if (node.name === category) return [node];
|
||||
if (node.name !== PEDESTAL_FOLDER) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
...node,
|
||||
children: node.children.filter(
|
||||
(child) => !otherMilestoneFolders.has(child.name),
|
||||
),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const { texture } = master;
|
||||
|
||||
return {
|
||||
source: { ...master, graph: { ...master.graph, children } },
|
||||
model: {
|
||||
camera: {
|
||||
...CAMERA,
|
||||
distanceToTarget: TARGET_PER_CATEGORY[category].distance,
|
||||
target: TARGET_PER_CATEGORY[category].position,
|
||||
},
|
||||
},
|
||||
viewer: {
|
||||
backgroundColor: toColor3(texture.colors[texture.transparent_color]),
|
||||
transparency: "smooth",
|
||||
},
|
||||
extras: milestoneFolder?.effects ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function weaponCategory(weaponSplId: MainWeaponId) {
|
||||
const category = weaponCategories.find(({ weaponIds }) =>
|
||||
(weaponIds as readonly number[]).includes(weaponSplId),
|
||||
);
|
||||
|
||||
return category ? toCategory(category.name) : null;
|
||||
}
|
||||
|
||||
function toCategory(name: (typeof weaponCategories)[number]["name"]) {
|
||||
return name.toLowerCase() as Category;
|
||||
}
|
||||
|
||||
function variantName({ category, milestone }: Variant) {
|
||||
return `${milestone} X Power ${category.charAt(0).toUpperCase()}${category.slice(1)}`;
|
||||
}
|
||||
@@ -115,7 +115,7 @@ function isBackgroundAlpha(state: ModelState) {
|
||||
);
|
||||
}
|
||||
|
||||
function toColor3(color: RawColor): Color3 {
|
||||
export function toColor3(color: RawColor): Color3 {
|
||||
return Array.isArray(color)
|
||||
? [color[0], color[1], color[2]]
|
||||
: [color.r, color.g, color.b];
|
||||
|
||||
10371
app/features/trophies/data/xp-trophies-master.txt
Normal file
10371
app/features/trophies/data/xp-trophies-master.txt
Normal file
File diff suppressed because one or more lines are too long
@@ -1,15 +1,10 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { idObject } from "~/utils/schema";
|
||||
import * as XpTrophy from "../core/XpTrophy";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import { canAccessTrophies } from "../trophies-utils";
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
if (!canAccessTrophies(getUser())) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const { id } = parseParams({
|
||||
params,
|
||||
schema: idObject,
|
||||
@@ -20,8 +15,17 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
TrophyRepository.findTournamentsByTrophyId(id),
|
||||
]);
|
||||
|
||||
const xpVariant = XpTrophy.parseCode(trophy.code);
|
||||
|
||||
return {
|
||||
trophy,
|
||||
tournaments,
|
||||
xpWeapons: xpVariant
|
||||
? await TrophyRepository.findXpWeaponCountsById({
|
||||
trophyId: id,
|
||||
weaponIds: XpTrophy.categoryWeaponIds(xpVariant.category),
|
||||
milestone: xpVariant.milestone,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
69
app/features/trophies/loaders/trophies.new.server.test.ts
Normal file
69
app/features/trophies/loaders/trophies.new.server.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { DEV_TEST_ID } from "~/db/seed/constants";
|
||||
import * as TrophyFactory from "~/db/seed/factories/TrophyFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { type TestUser, wrappedLoader } from "~/utils/Test";
|
||||
import { loader, type NewTrophyLoaderData } from "./trophies.new.server";
|
||||
|
||||
const newTrophyLoader = wrappedLoader<NewTrophyLoaderData>({ loader });
|
||||
|
||||
const users = UserFactory.pool();
|
||||
const managerId = () => users.id(1);
|
||||
const otherManagerId = () => users.id(2);
|
||||
|
||||
describe("managing trophies", () => {
|
||||
let managedTrophyId: number;
|
||||
let otherTrophyId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
await UserFactory.createAdmin();
|
||||
await users.create(2);
|
||||
await UserFactory.createDev();
|
||||
|
||||
managedTrophyId = (await TrophyFactory.create({ managerId: managerId() }))
|
||||
.id;
|
||||
otherTrophyId = (
|
||||
await TrophyFactory.create({ managerId: otherManagerId() })
|
||||
).id;
|
||||
});
|
||||
|
||||
const trophyIds = async (
|
||||
user: TestUser,
|
||||
list: "editableTrophies" | "backfillTrophies",
|
||||
) =>
|
||||
(await newTrophyLoader({ user }))[list]
|
||||
.map((trophy) => trophy.id)
|
||||
.toSorted((a, b) => a - b);
|
||||
|
||||
test("a manager can edit only the trophies they manage", async () => {
|
||||
expect(await trophyIds(managerId(), "editableTrophies")).toEqual([
|
||||
managedTrophyId,
|
||||
]);
|
||||
});
|
||||
|
||||
test("a dev can edit every trophy", async () => {
|
||||
expect(await trophyIds(DEV_TEST_ID, "editableTrophies")).toEqual([
|
||||
managedTrophyId,
|
||||
otherTrophyId,
|
||||
]);
|
||||
});
|
||||
|
||||
// the admin's override of per-object permissions only applies in production
|
||||
test("doesn't offer the admin trophies the server would refuse them", async () => {
|
||||
expect(await trophyIds("admin", "editableTrophies")).toEqual([]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ why: "an admin", user: "admin" as const },
|
||||
{ why: "a dev", user: DEV_TEST_ID },
|
||||
])("$why can backfill every trophy", async ({ user }) => {
|
||||
expect(await trophyIds(user, "backfillTrophies")).toEqual([
|
||||
managedTrophyId,
|
||||
otherTrophyId,
|
||||
]);
|
||||
});
|
||||
|
||||
test("a manager can't backfill even their own trophy", async () => {
|
||||
expect(await trophyIds(managerId(), "backfillTrophies")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
|
||||
import { hasPermission } from "~/modules/permissions/utils";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import { canEditAnyTrophy, canReviewTrophies } from "../trophies-utils";
|
||||
import { canBackfillTrophies, canReviewTrophies } from "../trophies-utils";
|
||||
|
||||
export type NewTrophyLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
@@ -10,17 +13,31 @@ export const loader = async (_args: LoaderFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
|
||||
const canReview = canReviewTrophies(user);
|
||||
const canBackfill = canBackfillTrophies(user);
|
||||
|
||||
const [rawItems, ownUnreviewedCount, editableTrophies] = await Promise.all([
|
||||
const [rawItems, ownUnreviewedCount, trophies] = await Promise.all([
|
||||
canReview
|
||||
? TrophyRepository.allPending()
|
||||
: TrophyRepository.pendingBySubmitter(user.id),
|
||||
TrophyRepository.unreviewedCountBySubmitter(user.id),
|
||||
canEditAnyTrophy(user)
|
||||
? TrophyRepository.findAllForEditing()
|
||||
: TrophyRepository.findManagedBy(user.id),
|
||||
TrophyRepository.findAllForEditing(),
|
||||
]);
|
||||
|
||||
const editableTrophies = trophies.filter((trophy) =>
|
||||
hasPermission(trophy, "EDIT", user),
|
||||
);
|
||||
|
||||
const backfillTrophies = canBackfill ? trophies : [];
|
||||
const backfillSeries = (
|
||||
await TournamentOrganizationRepository.findAllSeriesByOrganizationIds(
|
||||
R.unique(
|
||||
backfillTrophies.flatMap((trophy) =>
|
||||
trophy.organizationId ? [trophy.organizationId] : [],
|
||||
),
|
||||
),
|
||||
)
|
||||
).map(({ id, name, organizationId }) => ({ id, name, organizationId }));
|
||||
|
||||
const allItems = canReview ? rawItems : rawItems.map(stripReviewerInfo);
|
||||
|
||||
const isAccepted = (item: (typeof allItems)[number]) =>
|
||||
@@ -40,6 +57,8 @@ export const loader = async (_args: LoaderFunctionArgs) => {
|
||||
pendingTrophies,
|
||||
reviewedTrophies,
|
||||
editableTrophies,
|
||||
backfillTrophies,
|
||||
backfillSeries,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import { cachified } from "@epic-web/cachified";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as R from "remeda";
|
||||
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
|
||||
import * as XpTrophy from "../core/XpTrophy";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import { canAccessTrophies } from "../trophies-utils";
|
||||
|
||||
const TROPHIES_CACHE_KEY = "trophies";
|
||||
|
||||
export const loader = async () => {
|
||||
if (!canAccessTrophies(getUser())) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
const [trophies, xpTrophies] = await Promise.all([
|
||||
cachified({
|
||||
key: TROPHIES_CACHE_KEY,
|
||||
cache,
|
||||
ttl: ttl(IN_MILLISECONDS.TWO_HOURS),
|
||||
async getFreshValue() {
|
||||
return TrophyRepository.all();
|
||||
},
|
||||
}),
|
||||
TrophyRepository.findAllXp(),
|
||||
]);
|
||||
|
||||
const trophies = await cachified({
|
||||
key: TROPHIES_CACHE_KEY,
|
||||
cache,
|
||||
ttl: ttl(IN_MILLISECONDS.TWO_HOURS),
|
||||
async getFreshValue() {
|
||||
return TrophyRepository.all();
|
||||
},
|
||||
});
|
||||
|
||||
return { trophies };
|
||||
return {
|
||||
trophies,
|
||||
xpTrophies: R.sortBy(xpTrophies, (trophy) =>
|
||||
XpTrophy.VARIANTS.findIndex((variant) => variant.code === trophy.code),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export function clearTrophiesCache() {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import * as v from "valibot";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/schema";
|
||||
import * as TrophyBackfill from "../core/TrophyBackfill.server";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import { canBackfillTrophies } from "../trophies-utils";
|
||||
|
||||
export type TrophyBackfillLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
if (!canBackfillTrophies(requireUser())) {
|
||||
throw new Response(null, { status: 403 });
|
||||
}
|
||||
|
||||
const { id: trophyId, seriesId } = parseParams({
|
||||
params,
|
||||
schema: v.object({ id, seriesId: id }),
|
||||
});
|
||||
|
||||
const trophy = notFoundIfNullish(await TrophyRepository.findById(trophyId));
|
||||
|
||||
const tournaments = trophy.organizationId
|
||||
? await TrophyBackfill.backfillableTournaments({
|
||||
organizationId: trophy.organizationId,
|
||||
seriesId,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
trophyId,
|
||||
seriesId,
|
||||
tournaments: notFoundIfNullish(tournaments),
|
||||
};
|
||||
};
|
||||
@@ -45,3 +45,23 @@
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
.weapons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2);
|
||||
padding: 0;
|
||||
|
||||
& > li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
.weaponCount {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as TrophyFactory from "~/db/seed/factories/TrophyFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import * as XRankPlacementFactory from "~/db/seed/factories/XRankPlacementFactory";
|
||||
import { wrappedLoader } from "~/utils/Test";
|
||||
import {
|
||||
loader,
|
||||
type TrophyPlacementsLoaderData,
|
||||
} from "./trophies.$id.placements.$userId";
|
||||
|
||||
const SPLATTERSHOT = 40;
|
||||
const SPLAT_CHARGER = 2010;
|
||||
|
||||
const placementsLoader = wrappedLoader<TrophyPlacementsLoaderData>({ loader });
|
||||
|
||||
const users = UserFactory.pool();
|
||||
const playerId = () => users.id(1);
|
||||
|
||||
describe("X Power trophy placements", () => {
|
||||
let shootersTrophyId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
await users.create(1);
|
||||
shootersTrophyId = (await TrophyFactory.createXpTrophies()).find(
|
||||
(trophy) => trophy.code === "xp-shooters-3000",
|
||||
)!.id;
|
||||
|
||||
for (const [weaponSplId, power] of [
|
||||
[SPLATTERSHOT, 3050],
|
||||
[SPLAT_CHARGER, 3400],
|
||||
[SPLATTERSHOT, 3150],
|
||||
] as const) {
|
||||
await XRankPlacementFactory.create({
|
||||
playerUserId: playerId(),
|
||||
weaponSplId,
|
||||
power,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const load = (trophyId: number) =>
|
||||
placementsLoader({
|
||||
params: { id: String(trophyId), userId: String(playerId()) },
|
||||
});
|
||||
|
||||
test("lists the placements with the trophy's weapon category, highest first", async () => {
|
||||
const { placements } = await load(shootersTrophyId);
|
||||
|
||||
expect(
|
||||
placements.map((placement) => [placement.weaponSplId, placement.power]),
|
||||
).toEqual([
|
||||
[SPLATTERSHOT, 3150],
|
||||
[SPLATTERSHOT, 3050],
|
||||
]);
|
||||
});
|
||||
|
||||
test("is not found for a tournament trophy", async () => {
|
||||
const trophy = await TrophyFactory.create();
|
||||
|
||||
await expect(load(trophy.id)).rejects.toThrow("404");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import * as v from "valibot";
|
||||
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/schema";
|
||||
import * as XpTrophy from "../core/XpTrophy";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
|
||||
export type TrophyPlacementsLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
const paramsSchema = v.object({ id, userId: id });
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const { id: trophyId, userId } = parseParams({
|
||||
params,
|
||||
schema: paramsSchema,
|
||||
});
|
||||
|
||||
const xpVariant = XpTrophy.parseCode(
|
||||
await TrophyRepository.findCodeById(trophyId),
|
||||
);
|
||||
if (!xpVariant) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const placements = await XRankPlacementRepository.findPlacementsByUserId(
|
||||
userId,
|
||||
{ weaponIds: XpTrophy.categoryWeaponIds(xpVariant.category) },
|
||||
);
|
||||
|
||||
return { placements: placements ?? [] };
|
||||
};
|
||||
@@ -1,18 +1,12 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import { idObject } from "~/utils/schema";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import { canAccessTrophies } from "../trophies-utils";
|
||||
|
||||
export type TrophyTournamentsLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
if (!canAccessTrophies(getUser())) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const { id } = parseParams({
|
||||
params,
|
||||
schema: idObject,
|
||||
|
||||
@@ -2,13 +2,14 @@ import clsx from "clsx";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link, type MetaFunction, useLoaderData } from "react-router";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { WeaponImage } from "~/components/Image";
|
||||
import { tournamentOrganizationPage } from "~/features/tournament-organization/tournament-organization-urls";
|
||||
import { metaTags, ogPageImage, type SerializeFrom } from "~/utils/remix";
|
||||
import { userPage } from "~/utils/urls";
|
||||
import { TrophyShowcase } from "../components/TrophyShowcase";
|
||||
import { TrophyTournamentHistory } from "../components/TrophyTournamentHistory";
|
||||
import * as XpTrophy from "../core/XpTrophy";
|
||||
import { loader } from "../loaders/trophies.$id.server";
|
||||
import { parseSpecialTrophyCode } from "../trophies-utils";
|
||||
import styles from "./trophies.$id.module.css";
|
||||
|
||||
export { loader };
|
||||
@@ -33,15 +34,16 @@ export const meta: MetaFunction = (args) => {
|
||||
};
|
||||
|
||||
export default function TrophyDetailsPage() {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
const { t } = useTranslation(["trophies", "common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { trophy, tournaments } = data;
|
||||
const { trophy, tournaments, xpWeapons } = data;
|
||||
|
||||
const special = parseSpecialTrophyCode(trophy.code);
|
||||
const xpVariant = XpTrophy.parseCode(trophy.code);
|
||||
|
||||
return (
|
||||
<TrophyShowcase
|
||||
model={trophy.model}
|
||||
code={trophy.code}
|
||||
className={styles.trophyDetailsContainer}
|
||||
detailsClassName={styles.trophyDetails}
|
||||
>
|
||||
@@ -93,17 +95,41 @@ export default function TrophyDetailsPage() {
|
||||
</Trans>
|
||||
</p>
|
||||
) : null}
|
||||
{special ? (
|
||||
{xpVariant ? (
|
||||
<p className={styles.trophyMeta}>
|
||||
{special.type === "supporter"
|
||||
? t("trophies:special.supporter.description")
|
||||
: t("trophies:special.xp.description", {
|
||||
value: special.value,
|
||||
})}
|
||||
{t("trophies:special.xp.categoryDescription", {
|
||||
value: xpVariant.milestone,
|
||||
category: t(
|
||||
`common:weapon.category.${XpTrophy.categoryKey(xpVariant.category)}`,
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{special ? null : (
|
||||
{xpWeapons ? (
|
||||
xpWeapons.length > 0 ? (
|
||||
<div className="stack xs">
|
||||
<Divider className={styles.divider} smallText>
|
||||
{t("trophies:details.weapons")}
|
||||
</Divider>
|
||||
<ul className={styles.weapons} data-testid="trophy-weapons">
|
||||
{xpWeapons.map((weapon) => (
|
||||
<li key={weapon.weaponSplId}>
|
||||
<WeaponImage
|
||||
weaponSplId={weapon.weaponSplId}
|
||||
variant="badge"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<span className={styles.weaponCount}>
|
||||
×{weapon.ownerCount}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<div className="stack xs">
|
||||
<Divider className={styles.divider} smallText>
|
||||
{t("trophies:details.tournamentHistory")}
|
||||
@@ -122,7 +148,7 @@ export default function TrophyDetailsPage() {
|
||||
{t("trophies:details.owners")}
|
||||
</Divider>
|
||||
{trophy.owners.length > 0 ? (
|
||||
<ul className={styles.owners}>
|
||||
<ul className={styles.owners} data-testid="trophy-owners">
|
||||
{trophy.owners.map((owner) => (
|
||||
<li key={owner.id}>
|
||||
<Link to={userPage(owner)}>{owner.username}</Link>
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import * as v from "valibot";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/schema";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import { canAccessTrophies } from "../trophies-utils";
|
||||
|
||||
export type TrophyWinsLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
const paramsSchema = v.object({ id, userId: id });
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
if (!canAccessTrophies(getUser())) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const { id: trophyId, userId } = parseParams({
|
||||
params,
|
||||
schema: paramsSchema,
|
||||
|
||||
@@ -13,4 +13,23 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-4);
|
||||
margin-bottom: var(--s-4);
|
||||
}
|
||||
|
||||
.xpTrophies {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.xpRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--trophy-grid-columns, auto-fit), 100px);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
|
||||
& > a {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
|
||||
import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
|
||||
import * as TrophyFactory from "~/db/seed/factories/TrophyFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import { decompressFromBase64 } from "~/utils/compression";
|
||||
import { wrappedAction, wrappedLoader } from "~/utils/Test";
|
||||
import { action } from "../actions/trophies.new.server";
|
||||
import {
|
||||
loader,
|
||||
type NewTrophyLoaderData,
|
||||
} from "../loaders/trophies.new.server";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import type { trophyFormSchema } from "../trophies-schemas";
|
||||
|
||||
// remove file once feature is released
|
||||
|
||||
vi.mock("~/features/trophies/trophies-constants", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("~/features/trophies/trophies-constants")
|
||||
>()),
|
||||
TROPHIES_RELEASED: false,
|
||||
}));
|
||||
|
||||
const newTrophyLoader = wrappedLoader<NewTrophyLoaderData>({ loader });
|
||||
const submitAction = wrappedAction<typeof trophyFormSchema>({
|
||||
action,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
|
||||
describe("trophy submissions before release", () => {
|
||||
beforeEach(async () => {
|
||||
await UserFactory.createAdmin();
|
||||
await UserFactory.createRegular();
|
||||
});
|
||||
|
||||
test("a regular user can open the submission page", async () => {
|
||||
const data = await newTrophyLoader({ user: "regular" });
|
||||
|
||||
expect(data.canReview).toBe(false);
|
||||
expect(data.currentUserId).toBe(REGULAR_USER_TEST_ID);
|
||||
});
|
||||
|
||||
test("a regular user can submit a trophy", async () => {
|
||||
const organization = await TournamentOrganizationFactory.create({
|
||||
ownerId: ADMIN_ID,
|
||||
});
|
||||
|
||||
const result = await submitAction(
|
||||
{
|
||||
_action: "CREATE",
|
||||
name: "Regular Trophy",
|
||||
model: decompressFromBase64(TrophyFactory.MODELS[0]) ?? "",
|
||||
organizationId: organization.id,
|
||||
description: null,
|
||||
},
|
||||
{ user: "regular" },
|
||||
);
|
||||
|
||||
expect(result).toBe(null);
|
||||
|
||||
const pending =
|
||||
await TrophyRepository.pendingBySubmitter(REGULAR_USER_TEST_ID);
|
||||
expect(pending.length).toBe(1);
|
||||
expect(pending[0].name).toBe("Regular Trophy");
|
||||
expect(pending[0].creatorId).toBe(REGULAR_USER_TEST_ID);
|
||||
});
|
||||
|
||||
test("a submission can name someone else as the creator", async () => {
|
||||
const organization = await TournamentOrganizationFactory.create({
|
||||
ownerId: ADMIN_ID,
|
||||
});
|
||||
const artist = await UserFactory.create();
|
||||
|
||||
const result = await submitAction(
|
||||
{
|
||||
_action: "CREATE",
|
||||
name: "Commissioned Trophy",
|
||||
model: decompressFromBase64(TrophyFactory.MODELS[0]) ?? "",
|
||||
organizationId: organization.id,
|
||||
creatorId: artist.id,
|
||||
description: null,
|
||||
},
|
||||
{ user: "regular" },
|
||||
);
|
||||
|
||||
expect(result).toBe(null);
|
||||
|
||||
const pending =
|
||||
await TrophyRepository.pendingBySubmitter(REGULAR_USER_TEST_ID);
|
||||
expect(pending[0].creatorId).toBe(artist.id);
|
||||
expect(pending[0].creator?.id).toBe(artist.id);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Form, Link, type MetaFunction, useLoaderData } from "react-router";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { OrganizationSearch } from "~/components/elements/OrganizationSearch";
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
} from "~/utils/urls";
|
||||
import { action } from "../actions/trophies.new.server";
|
||||
import { Trophy, TrophyContextProvider } from "../components/Trophy";
|
||||
import { TrophyBackfill } from "../components/TrophyBackfill";
|
||||
import {
|
||||
analyzeTrophyModel,
|
||||
mergePeakRenderStats,
|
||||
@@ -54,7 +56,7 @@ import {
|
||||
} from "../trophies-constants";
|
||||
import {
|
||||
createTrophyFormSchema,
|
||||
pendingTrophyActionSchema,
|
||||
trophyActionSchema,
|
||||
updateTrophyFormSchema,
|
||||
} from "../trophies-schemas";
|
||||
import {
|
||||
@@ -95,10 +97,13 @@ export default function NewTrophyPage() {
|
||||
<SendouTabList>
|
||||
<SendouTab id="upload">{t("trophies:new.tabs.upload")}</SendouTab>
|
||||
<SendouTab
|
||||
id="update"
|
||||
isDisabled={data.editableTrophies.length === 0}
|
||||
id="manage"
|
||||
isDisabled={
|
||||
data.editableTrophies.length === 0 &&
|
||||
data.backfillTrophies.length === 0
|
||||
}
|
||||
>
|
||||
{t("trophies:new.tabs.update")}
|
||||
{t("trophies:new.tabs.manage")}
|
||||
</SendouTab>
|
||||
<SendouTab id="pending" number={data.pendingTrophies.length}>
|
||||
{t("trophies:new.tabs.pending")}
|
||||
@@ -118,16 +123,32 @@ export default function NewTrophyPage() {
|
||||
</TrophyTermsGate>
|
||||
)}
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="update">
|
||||
{data.ownUnreviewedCount >= TROPHY_PENDING_PER_USER_LIMIT ? (
|
||||
<Alert variation="WARNING">
|
||||
{t("trophies:new.form.limitReached", {
|
||||
limit: TROPHY_PENDING_PER_USER_LIMIT,
|
||||
})}
|
||||
</Alert>
|
||||
) : (
|
||||
<UpdateTrophyTab key={data.ownUnreviewedCount} />
|
||||
)}
|
||||
<SendouTabPanel id="manage">
|
||||
<div className="stack lg">
|
||||
{data.editableTrophies.length > 0 ? (
|
||||
<div className="stack md">
|
||||
<Divider smallText>{t("trophies:new.manage.update")}</Divider>
|
||||
{data.ownUnreviewedCount >= TROPHY_PENDING_PER_USER_LIMIT ? (
|
||||
<Alert variation="WARNING">
|
||||
{t("trophies:new.form.limitReached", {
|
||||
limit: TROPHY_PENDING_PER_USER_LIMIT,
|
||||
})}
|
||||
</Alert>
|
||||
) : (
|
||||
<UpdateTrophySection key={data.ownUnreviewedCount} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{data.backfillTrophies.length > 0 ? (
|
||||
<div className="stack md">
|
||||
<Divider smallText>{t("trophies:new.manage.backfill")}</Divider>
|
||||
<TrophyBackfill
|
||||
trophies={data.backfillTrophies}
|
||||
series={data.backfillSeries}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id="pending">
|
||||
<TrophyList items={data.pendingTrophies} listKind="pending" />
|
||||
@@ -244,7 +265,7 @@ function NewTrophyForm() {
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateTrophyTab() {
|
||||
function UpdateTrophySection() {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [selectedId, setSelectedId] = React.useState<number | null>(null);
|
||||
@@ -429,30 +450,10 @@ function ModelField({
|
||||
{error ? <FormMessage type="error">{error}</FormMessage> : null}
|
||||
{preview.compressedModel ? (
|
||||
<TrophyContextProvider>
|
||||
<div className={styles.previewThemes}>
|
||||
{(["light", "dark"] as const).map((theme) => (
|
||||
<div
|
||||
key={theme}
|
||||
className={styles.previewTheme}
|
||||
data-theme={theme}
|
||||
>
|
||||
<span className={styles.previewThemeLabel}>
|
||||
{t(`trophies:new.form.preview.${theme}`)}
|
||||
</span>
|
||||
<Trophy
|
||||
model={preview.compressedModel}
|
||||
preview
|
||||
tier={1}
|
||||
colorScheme={theme}
|
||||
/>
|
||||
<Trophy
|
||||
model={preview.compressedModel}
|
||||
onRenderStats={reportRenderStats}
|
||||
colorScheme={theme}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<TrophyThemePreviews
|
||||
model={preview.compressedModel}
|
||||
onRenderStats={reportRenderStats}
|
||||
/>
|
||||
</TrophyContextProvider>
|
||||
) : null}
|
||||
<ModelSpecs analysis={preview.analysis} peakStats={peakStats} />
|
||||
@@ -460,6 +461,35 @@ function ModelField({
|
||||
);
|
||||
}
|
||||
|
||||
function TrophyThemePreviews({
|
||||
model,
|
||||
onRenderStats,
|
||||
}: {
|
||||
model: string;
|
||||
onRenderStats: (stats: RenderStats) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
|
||||
return (
|
||||
<div className={styles.previewThemes}>
|
||||
{(["light", "dark"] as const).map((theme) => (
|
||||
<div key={theme} className={styles.previewTheme} data-theme={theme}>
|
||||
<span className={styles.previewThemeLabel}>
|
||||
{t(`trophies:new.form.preview.${theme}`)}
|
||||
</span>
|
||||
<Trophy model={model} preview tier={1} colorScheme={theme} />
|
||||
<Trophy
|
||||
model={model}
|
||||
onRenderStats={onRenderStats}
|
||||
colorScheme={theme}
|
||||
showAnimationProgress
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildModelPreview(model: string) {
|
||||
if (!model) return { compressedModel: "", analysis: null };
|
||||
|
||||
@@ -667,7 +697,7 @@ function TrophyListRow({
|
||||
deferred: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["trophies", "common"]);
|
||||
const { submit, state } = useActionSubmit(pendingTrophyActionSchema);
|
||||
const { submit, state } = useActionSubmit(trophyActionSchema);
|
||||
|
||||
const isOwner = pending.submitterUserId === currentUserId;
|
||||
const isDeclined = pending.declinedAt !== null;
|
||||
@@ -713,7 +743,18 @@ function TrophyListRow({
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
showCloseButton
|
||||
>
|
||||
<Trophy model={pending.model} onRenderStats={reportRenderStats} />
|
||||
<TrophyThemePreviews
|
||||
model={pending.model}
|
||||
onRenderStats={reportRenderStats}
|
||||
/>
|
||||
{analysis ? (
|
||||
<TrophyRenderStats
|
||||
analysis={analysis}
|
||||
drawCalls={drawCalls}
|
||||
polyCount={polyCount}
|
||||
className="mt-4"
|
||||
/>
|
||||
) : null}
|
||||
</SendouDialog>
|
||||
<div className={styles.pendingMain}>
|
||||
<div className={styles.pendingHeader}>
|
||||
@@ -778,48 +819,11 @@ function TrophyListRow({
|
||||
</span>
|
||||
</div>
|
||||
{analysis ? (
|
||||
<div className={styles.pendingSpecs}>
|
||||
<span
|
||||
className={clsx({
|
||||
[styles.pendingSpecsWarn]:
|
||||
drawCalls > TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
|
||||
})}
|
||||
>
|
||||
{t("trophies:new.specs.stats.drawCalls", {
|
||||
value: drawCalls,
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={clsx({
|
||||
[styles.pendingSpecsWarn]:
|
||||
polyCount > TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
|
||||
})}
|
||||
>
|
||||
{t("trophies:new.specs.stats.polys", {
|
||||
value: polyCount,
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={clsx({
|
||||
[styles.pendingSpecsWarn]:
|
||||
analysis.effectsCount > TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
|
||||
})}
|
||||
>
|
||||
{t("trophies:new.specs.stats.effects", {
|
||||
value: analysis.effectsCount,
|
||||
})}
|
||||
</span>
|
||||
{!analysis.cameraTargetCentered ? (
|
||||
<span className={styles.pendingSpecsError}>
|
||||
{t("trophies:new.specs.stats.cameraTargetOff")}
|
||||
</span>
|
||||
) : null}
|
||||
{!analysis.backgroundIsAlpha ? (
|
||||
<span className={styles.pendingSpecsError}>
|
||||
{t("trophies:new.specs.stats.backgroundNotAlpha")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<TrophyRenderStats
|
||||
analysis={analysis}
|
||||
drawCalls={drawCalls}
|
||||
polyCount={polyCount}
|
||||
/>
|
||||
) : null}
|
||||
{pending.target ? (
|
||||
<PendingTrophyDiff pending={pending} target={pending.target} />
|
||||
@@ -896,11 +900,70 @@ function TrophyListRow({
|
||||
);
|
||||
}
|
||||
|
||||
function TrophyRenderStats({
|
||||
analysis,
|
||||
drawCalls,
|
||||
polyCount,
|
||||
className,
|
||||
}: {
|
||||
analysis: TrophyModelAnalysis;
|
||||
drawCalls: number;
|
||||
polyCount: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.pendingSpecs, className)}>
|
||||
<span
|
||||
className={clsx({
|
||||
[styles.pendingSpecsWarn]:
|
||||
drawCalls > TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
|
||||
})}
|
||||
>
|
||||
{t("trophies:new.specs.stats.drawCalls", {
|
||||
value: drawCalls,
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={clsx({
|
||||
[styles.pendingSpecsWarn]:
|
||||
polyCount > TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
|
||||
})}
|
||||
>
|
||||
{t("trophies:new.specs.stats.polys", {
|
||||
value: polyCount,
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={clsx({
|
||||
[styles.pendingSpecsWarn]:
|
||||
analysis.effectsCount > TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
|
||||
})}
|
||||
>
|
||||
{t("trophies:new.specs.stats.effects", {
|
||||
value: analysis.effectsCount,
|
||||
})}
|
||||
</span>
|
||||
{!analysis.cameraTargetCentered ? (
|
||||
<span className={styles.pendingSpecsError}>
|
||||
{t("trophies:new.specs.stats.cameraTargetOff")}
|
||||
</span>
|
||||
) : null}
|
||||
{!analysis.backgroundIsAlpha ? (
|
||||
<span className={styles.pendingSpecsError}>
|
||||
{t("trophies:new.specs.stats.backgroundNotAlpha")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeclineButton({ pendingTrophyId }: { pendingTrophyId: number }) {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [reason, setReason] = React.useState("");
|
||||
const { submit, fetcher } = useActionSubmit(pendingTrophyActionSchema);
|
||||
const { submit, fetcher } = useActionSubmit(trophyActionSchema);
|
||||
const id = React.useId();
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
Outlet,
|
||||
useLoaderData,
|
||||
} from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { Input } from "~/components/Input";
|
||||
import { Main } from "~/components/Main";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
TrophyContextProvider,
|
||||
TrophyGrid,
|
||||
} from "../components/Trophy";
|
||||
import * as XpTrophy from "../core/XpTrophy";
|
||||
import { loader } from "../loaders/trophies.server";
|
||||
import {
|
||||
hasUpcomingTournamentSoon,
|
||||
@@ -96,6 +99,7 @@ export default function TrophiesPage() {
|
||||
</TrophyContextProvider>
|
||||
</TrophyGrid>
|
||||
</div>
|
||||
<XpTrophiesSection searchValue={inputValueNormalized} />
|
||||
<p className={styles.badgesLink}>
|
||||
{t("trophies:lookingForBadges")}{" "}
|
||||
<Link to={BADGES_PAGE}>{t("trophies:viewBadges")}</Link>
|
||||
@@ -104,3 +108,58 @@ export default function TrophiesPage() {
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function XpTrophiesSection({ searchValue }: { searchValue: string }) {
|
||||
const { t } = useTranslation(["trophies", "common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const filteredTrophies = data.xpTrophies.filter((trophy) =>
|
||||
trophy.name.toLowerCase().includes(searchValue),
|
||||
);
|
||||
const visibleCount = useProgressiveRender(
|
||||
filteredTrophies.length,
|
||||
`xp-${searchValue}`,
|
||||
);
|
||||
|
||||
if (filteredTrophies.length === 0) return null;
|
||||
|
||||
const rows = Object.values(
|
||||
R.groupBy(
|
||||
filteredTrophies.map((trophy, i) => ({ trophy, i })),
|
||||
({ trophy }) => XpTrophy.parseCode(trophy.code)?.category ?? "",
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="stack md" data-testid="xp-trophies">
|
||||
<Divider>{t("trophies:xp.title")}</Divider>
|
||||
<TrophyContextProvider>
|
||||
<div className={styles.xpTrophies}>
|
||||
{rows.map((row) => {
|
||||
const category = XpTrophy.parseCode(row[0].trophy.code)?.category;
|
||||
if (!category) return null;
|
||||
|
||||
return (
|
||||
<div key={category} className={styles.xpRow}>
|
||||
{row.map(({ trophy, i }) => (
|
||||
<NavLink
|
||||
to={String(trophy.id)}
|
||||
key={trophy.id}
|
||||
aria-label={trophy.name}
|
||||
>
|
||||
<Trophy
|
||||
model={trophy.model}
|
||||
code={trophy.code}
|
||||
preview
|
||||
deferred={i >= visibleCount}
|
||||
/>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TrophyContextProvider>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ export const TROPHY_NAME_MAX_LENGTH = 32;
|
||||
|
||||
export const TROPHY_MODEL_MAX_LENGTH = 1024 * 1024;
|
||||
export const TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS = 15;
|
||||
export const TROPHY_MODEL_RECOMMENDED_MAX_POLYS = 3000;
|
||||
export const TROPHY_MODEL_RECOMMENDED_MAX_POLYS = 5000;
|
||||
export const TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS = 5;
|
||||
|
||||
export const TROPHY_APPROVALS_REQUIRED = 3;
|
||||
@@ -18,8 +18,3 @@ export const TROPHY_PENDING_PER_USER_LIMIT = 5;
|
||||
export const SMALL_TROPHIES_PER_DISPLAY_PAGE = 6;
|
||||
|
||||
export const TROPHY_UPCOMING_HIGHLIGHT_WEEKS = 4;
|
||||
|
||||
export const TROPHIES_RELEASED = false;
|
||||
|
||||
export const SUPPORTER_TROPHY_CODE = "supporter";
|
||||
export const XP_TROPHY_CODE_PREFIX = "xp-";
|
||||
|
||||
@@ -5,7 +5,13 @@ import {
|
||||
textAreaOptional,
|
||||
textField,
|
||||
} from "~/form/fields";
|
||||
import { _action, id, superRefine } from "~/utils/schema";
|
||||
import {
|
||||
_action,
|
||||
id,
|
||||
preprocess,
|
||||
safeJSONParse,
|
||||
superRefine,
|
||||
} from "~/utils/schema";
|
||||
import { analyzeTrophyModel } from "./core/model-analysis";
|
||||
import {
|
||||
TROPHY_DECLINE_REASON_MAX_LENGTH,
|
||||
@@ -86,7 +92,22 @@ export const trophyFormSchema = v.variant("_action", [
|
||||
updateTrophyFormSchema,
|
||||
]);
|
||||
|
||||
export const pendingTrophyActionSchema = v.union([
|
||||
const trophyBackfillAwards = v.pipe(
|
||||
v.array(
|
||||
v.object({
|
||||
tournamentId: id,
|
||||
userIds: v.pipe(v.array(id), v.minLength(1), v.maxLength(50)),
|
||||
}),
|
||||
),
|
||||
v.minLength(1),
|
||||
v.check(
|
||||
(awards) =>
|
||||
new Set(awards.map((award) => award.tournamentId)).size === awards.length,
|
||||
"Duplicate tournament",
|
||||
),
|
||||
);
|
||||
|
||||
export const trophyActionSchema = v.union([
|
||||
v.object({
|
||||
_action: _action("DELETE"),
|
||||
pendingTrophyId: id,
|
||||
@@ -105,4 +126,10 @@ export const pendingTrophyActionSchema = v.union([
|
||||
_action: _action("APPROVE"),
|
||||
pendingTrophyId: id,
|
||||
}),
|
||||
v.object({
|
||||
_action: _action("BACKFILL"),
|
||||
trophyId: id,
|
||||
seriesId: id,
|
||||
awards: preprocess(safeJSONParse, trophyBackfillAwards),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { decompressFromBase64 } from "~/utils/compression";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import {
|
||||
canAccessTrophies,
|
||||
decompressTrophyModel,
|
||||
hasUpcomingTournamentSoon,
|
||||
} from "./trophies-utils";
|
||||
@@ -13,12 +12,6 @@ vi.mock("~/utils/compression", () => ({
|
||||
decompressFromBase64: vi.fn((_compressed: string): string | null => null),
|
||||
}));
|
||||
|
||||
// pinned to unreleased so the role gating stays covered whatever the real flag says
|
||||
vi.mock("./trophies-constants", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./trophies-constants")>()),
|
||||
TROPHIES_RELEASED: false,
|
||||
}));
|
||||
|
||||
const decompressMock = vi.mocked(decompressFromBase64);
|
||||
|
||||
const ENTRY_CHARS = 3_500_000;
|
||||
@@ -68,25 +61,6 @@ describe("decompressTrophyModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAccessTrophies (before release)", () => {
|
||||
test("true for admin", () => {
|
||||
expect(canAccessTrophies({ roles: ["ADMIN"] })).toBe(true);
|
||||
});
|
||||
|
||||
test("true for QA", () => {
|
||||
expect(canAccessTrophies({ roles: ["QA"] })).toBe(true);
|
||||
});
|
||||
|
||||
test("false for staff", () => {
|
||||
expect(canAccessTrophies({ roles: ["STAFF"] })).toBe(false);
|
||||
});
|
||||
|
||||
test("false for regular and logged out users", () => {
|
||||
expect(canAccessTrophies({ roles: [] })).toBe(false);
|
||||
expect(canAccessTrophies(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasUpcomingTournamentSoon", () => {
|
||||
test("false for a trophy without an upcoming tournament", () => {
|
||||
expect(hasUpcomingTournamentSoon(null)).toBe(false);
|
||||
|
||||
@@ -1,41 +1,67 @@
|
||||
import { addWeeks } from "date-fns";
|
||||
import type { RawPicoCAD2File } from "picocad2-web";
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type { Role } from "~/modules/permissions/types";
|
||||
import { compressToBase64, decompressFromBase64 } from "~/utils/compression";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import {
|
||||
SUPPORTER_TROPHY_CODE,
|
||||
TROPHIES_RELEASED,
|
||||
TROPHY_UPCOMING_HIGHLIGHT_WEEKS,
|
||||
XP_TROPHY_CODE_PREFIX,
|
||||
} from "./trophies-constants";
|
||||
import * as XpTrophy from "./core/XpTrophy";
|
||||
import { TROPHY_UPCOMING_HIGHLIGHT_WEEKS } from "./trophies-constants";
|
||||
|
||||
const TERMS_AGREED_SESSION_STORAGE_KEY = "trophyTermsAgreed";
|
||||
|
||||
const DECOMPRESSED_MODEL_CACHE_MAX_CHARS = 16 * 1024 * 1024;
|
||||
|
||||
type SpecialTrophyKind = { type: "supporter" } | { type: "xp"; value: number };
|
||||
let xpTrophyMaster: RawPicoCAD2File | null = null;
|
||||
let xpTrophyMasterLoading: Promise<void> | null = null;
|
||||
const xpTrophyMasterListeners = new Set<() => void>();
|
||||
const xpTrophyStates = new Map<string, string>();
|
||||
|
||||
export function parseSpecialTrophyCode(
|
||||
code: string | null | undefined,
|
||||
): SpecialTrophyKind | null {
|
||||
if (!code) return null;
|
||||
if (code === SUPPORTER_TROPHY_CODE) return { type: "supporter" };
|
||||
// Downloads and parses the master file containing ALL XP trophies to create a model file for the requested variant.
|
||||
// We get an overall smaller file size and better performance with this.
|
||||
export function useXpTrophyState(variant: XpTrophy.Variant | null) {
|
||||
const master = useSyncExternalStore(
|
||||
variant ? subscribeToXpTrophyMaster : subscribeToNothing,
|
||||
() => xpTrophyMaster,
|
||||
() => null,
|
||||
);
|
||||
if (!variant || !master) return null;
|
||||
|
||||
if (code.startsWith(XP_TROPHY_CODE_PREFIX)) {
|
||||
const value = Number(code.slice(XP_TROPHY_CODE_PREFIX.length));
|
||||
if (Number.isFinite(value)) return { type: "xp", value };
|
||||
}
|
||||
const code = XpTrophy.code(variant);
|
||||
const cached = xpTrophyStates.get(code);
|
||||
if (cached) return cached;
|
||||
|
||||
return null;
|
||||
const state = JSON.stringify(XpTrophy.variantState(master, variant));
|
||||
xpTrophyStates.set(code, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Feature flag */
|
||||
export function canAccessTrophies(user?: { roles: Array<Role> } | null) {
|
||||
if (TROPHIES_RELEASED) return true;
|
||||
function subscribeToXpTrophyMaster(listener: () => void) {
|
||||
xpTrophyMasterListeners.add(listener);
|
||||
|
||||
if (!xpTrophyMasterLoading) {
|
||||
xpTrophyMasterLoading = import("./data/xp-trophies-master.txt?raw")
|
||||
.then(({ default: raw }) => {
|
||||
xpTrophyMaster = JSON.parse(raw);
|
||||
for (const notify of xpTrophyMasterListeners) notify();
|
||||
})
|
||||
.catch(() => {
|
||||
xpTrophyMasterLoading = null;
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
xpTrophyMasterListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function subscribeToNothing() {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function canBackfillTrophies(user?: { roles: Array<Role> } | null) {
|
||||
if (!user) return false;
|
||||
|
||||
return user.roles.includes("ADMIN") || user.roles.includes("QA");
|
||||
return user.roles.includes("ADMIN") || user.roles.includes("DEV");
|
||||
}
|
||||
|
||||
export function canReviewTrophies(user?: { roles: Array<Role> } | null) {
|
||||
@@ -44,12 +70,6 @@ export function canReviewTrophies(user?: { roles: Array<Role> } | null) {
|
||||
return user.roles.includes("STAFF") || user.roles.includes("QA");
|
||||
}
|
||||
|
||||
export function canEditAnyTrophy(user?: { roles: Array<Role> } | null) {
|
||||
if (!user) return false;
|
||||
|
||||
return user.roles.includes("ADMIN");
|
||||
}
|
||||
|
||||
export function hasUpcomingTournamentSoon(
|
||||
upcomingTournamentAt: number | null | undefined,
|
||||
) {
|
||||
|
||||
@@ -15,7 +15,6 @@ import * as TeamRepository from "~/features/team/TeamRepository.server";
|
||||
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
|
||||
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import * as VodRepository from "~/features/vods/VodRepository.server";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
@@ -25,8 +24,6 @@ import { cachedUserSQLeaderboardTopData } from "./utils.server";
|
||||
|
||||
export const WIDGET_LOADERS = {
|
||||
"trophies-owned": async (userId: number) => {
|
||||
if (!canAccessTrophies(getUser())) return [];
|
||||
|
||||
return TrophyRepository.findByOwnerUserId(userId);
|
||||
},
|
||||
"badges-owned": async (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { userPageUser } from "~/features/user-page/user-page-context.server";
|
||||
import { userPage } from "~/utils/urls";
|
||||
@@ -17,9 +16,9 @@ export const loader = async () => {
|
||||
const friendCodeResult = await UserRepository.findCurrentFriendCodeByUserId(
|
||||
user.id,
|
||||
);
|
||||
const ownedTrophies = canAccessTrophies(user)
|
||||
? await TrophyRepository.findByOwnerUserIdIncludingHidden(user.id)
|
||||
: [];
|
||||
const ownedTrophies = await TrophyRepository.findByOwnerUserIdIncludingHidden(
|
||||
user.id,
|
||||
);
|
||||
|
||||
return {
|
||||
user: userProfile,
|
||||
|
||||
@@ -43,7 +43,12 @@ function setUpScrollableElement({ scrollSnap = false } = {}) {
|
||||
|
||||
function mouseDownOn(element: HTMLElement, clientX: number, clientY: number) {
|
||||
element.dispatchEvent(
|
||||
new MouseEvent("mousedown", { buttons: 1, clientX, clientY }),
|
||||
new MouseEvent("mousedown", {
|
||||
buttons: 1,
|
||||
clientX,
|
||||
clientY,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,6 +141,43 @@ describe("dragToScroll", () => {
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ why: "popover", overlayTag: "div", overlayAttribute: "popover" },
|
||||
{ why: "dialog", overlayTag: "dialog", overlayAttribute: null },
|
||||
])(
|
||||
"ignores drags that start inside a nested $why",
|
||||
({ overlayTag, overlayAttribute }) => {
|
||||
const { element, child } = setUpScrollableElement();
|
||||
const overlay = document.createElement(overlayTag);
|
||||
if (overlayAttribute) overlay.setAttribute(overlayAttribute, "auto");
|
||||
const overlayContent = document.createElement("canvas");
|
||||
overlay.appendChild(overlayContent);
|
||||
child.appendChild(overlay);
|
||||
|
||||
mouseDownOn(overlayContent, 50, 50);
|
||||
mouseMoveTo(20, 40);
|
||||
mouseUp();
|
||||
|
||||
expect(element.scrollLeft).toBe(0);
|
||||
expect(element.scrollTop).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
test("scrolls when the element itself sits inside a dialog", () => {
|
||||
const dialog = document.createElement("dialog");
|
||||
document.body.appendChild(dialog);
|
||||
cleanupFns.push(() => dialog.remove());
|
||||
const { element } = setUpScrollableElement();
|
||||
dialog.appendChild(element);
|
||||
dialog.show();
|
||||
|
||||
mouseDownOn(element, 50, 50);
|
||||
mouseMoveTo(20, 40);
|
||||
mouseUp();
|
||||
|
||||
expect(element.scrollLeft).toBe(30);
|
||||
});
|
||||
|
||||
test("stops reacting to the mouse after cleanup", () => {
|
||||
const { element, detach } = setUpScrollableElement();
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export function dragToScroll(element: HTMLElement): () => void {
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
suppressNextClick = false;
|
||||
if (event.buttons !== 1) return;
|
||||
if (isInsideNestedOverlay(element, event.target)) return;
|
||||
|
||||
cancelAnimationFrame(momentumFrame);
|
||||
isMouseDown = true;
|
||||
@@ -181,6 +182,16 @@ export function dragToScroll(element: HTMLElement): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
function isInsideNestedOverlay(
|
||||
element: HTMLElement,
|
||||
target: EventTarget | null,
|
||||
) {
|
||||
if (!(target instanceof Element)) return false;
|
||||
|
||||
const overlay = target.closest("[popover], dialog");
|
||||
return overlay !== null && overlay !== element && element.contains(overlay);
|
||||
}
|
||||
|
||||
function createGrabbingCursorStyle() {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = "* { cursor: grabbing !important; }";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as v from "valibot";
|
||||
import { ServerConfig } from "~/config.server";
|
||||
import { STAFF_DISCORD_IDS } from "~/features/admin/admin-constants";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { fetchWithTimeout } from "~/utils/fetch";
|
||||
@@ -47,7 +46,6 @@ export async function updatePatreonData(): Promise<void> {
|
||||
];
|
||||
|
||||
await UserRepository.updatePatronData(patronsWithMods);
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 10;
|
||||
|
||||
@@ -139,6 +139,14 @@ export default [
|
||||
"/trophies/:id/tournaments",
|
||||
"features/trophies/routes/trophies.$id.tournaments.ts",
|
||||
),
|
||||
route(
|
||||
"/trophies/:id/placements/:userId",
|
||||
"features/trophies/routes/trophies.$id.placements.$userId.ts",
|
||||
),
|
||||
route(
|
||||
"/trophies/:id/backfill/:seriesId",
|
||||
"features/trophies/routes/trophies.$id.backfill.$seriesId.ts",
|
||||
),
|
||||
route("/trophies", "features/trophies/routes/trophies.tsx", [
|
||||
route(":id", "features/trophies/routes/trophies.$id.tsx"),
|
||||
]),
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
margin-block-start: -6px;
|
||||
margin-inline-start: -6px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.tournamentCardsViewAllCard {
|
||||
|
||||
@@ -266,6 +266,15 @@ export function calendarEventStartTime(
|
||||
.whereRef("CalendarEventDate.eventId", "=", "CalendarEvent.id");
|
||||
}
|
||||
|
||||
export function calendarEventNameMatchesSeries(substringMatches: string[]) {
|
||||
return (eb: ExpressionBuilder<Tables, "CalendarEvent">) =>
|
||||
eb.or(
|
||||
substringMatches.map((match) =>
|
||||
eb("CalendarEvent.name", "like", `%${match}%`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Subquery counting a tournament's non-placeholder teams. Correlates on `"Tournament"."id"`. */
|
||||
export function tournamentTeamCount(
|
||||
eb: ExpressionBuilder<Tables, "Tournament">,
|
||||
|
||||
@@ -241,8 +241,16 @@ export const trophyPage = (trophyId: number) => `${TROPHIES_PAGE}/${trophyId}`;
|
||||
export const trophyWinsPage = (args: { trophyId: number; userId: number }) =>
|
||||
`${TROPHIES_PAGE}/${args.trophyId}/wins/${args.userId}`;
|
||||
|
||||
export const trophyPlacementsPage = (args: {
|
||||
trophyId: number;
|
||||
userId: number;
|
||||
}) => `${TROPHIES_PAGE}/${args.trophyId}/placements/${args.userId}`;
|
||||
export const trophyTournamentsPage = (trophyId: number) =>
|
||||
`${TROPHIES_PAGE}/${trophyId}/tournaments`;
|
||||
export const trophyBackfillPage = (args: {
|
||||
trophyId: number;
|
||||
seriesId: number;
|
||||
}) => `${TROPHIES_PAGE}/${args.trophyId}/backfill/${args.seriesId}`;
|
||||
|
||||
export interface UserLinkArgs {
|
||||
discordId: Tables["User"]["discordId"];
|
||||
|
||||
@@ -19,6 +19,7 @@ export class NewTrophyPage {
|
||||
agreeToTermsButton: page.getByRole("button", {
|
||||
name: "I have read and agree",
|
||||
}),
|
||||
manageTab: page.getByRole("tab", { name: "Manage" }),
|
||||
pendingTab: page.getByRole("tab", { name: "Pending" }),
|
||||
reviewedTab: page.getByRole("tab", { name: "Reviewed" }),
|
||||
};
|
||||
@@ -61,6 +62,11 @@ export class NewTrophyPage {
|
||||
return submit(this.page);
|
||||
}
|
||||
|
||||
async openBackfill() {
|
||||
await this.locators.manageTab.click();
|
||||
return new TrophyBackfillSection(this.page);
|
||||
}
|
||||
|
||||
async openPending() {
|
||||
await this.locators.pendingTab.click();
|
||||
return new PendingTrophyList(this.page);
|
||||
@@ -103,3 +109,58 @@ class PendingTrophyList {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TrophyBackfillSection {
|
||||
private readonly page: Page;
|
||||
readonly locators;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.locators = {
|
||||
trophySelect: page.getByTestId("backfill-trophy-select"),
|
||||
seriesSelect: page.getByTestId("backfill-series-select"),
|
||||
awardButton: page.getByTestId("backfill-submit-button"),
|
||||
};
|
||||
}
|
||||
|
||||
async selectTrophy(name: string) {
|
||||
await this.locators.trophySelect.click();
|
||||
await this.page.getByRole("option", { name, exact: true }).click();
|
||||
}
|
||||
|
||||
async selectSeries(name: string) {
|
||||
await this.locators.seriesSelect.click();
|
||||
await this.page.getByRole("option", { name, exact: true }).click();
|
||||
}
|
||||
|
||||
tournament(name: string) {
|
||||
return this.page
|
||||
.getByTestId("backfill-tournament")
|
||||
.filter({ hasText: name });
|
||||
}
|
||||
|
||||
toggleTournament(name: string) {
|
||||
return this.tournament(name)
|
||||
.getByRole("switch", { name, exact: true })
|
||||
.click({ force: true });
|
||||
}
|
||||
|
||||
toggleWinner({
|
||||
tournamentName,
|
||||
username,
|
||||
}: {
|
||||
tournamentName: string;
|
||||
username: string;
|
||||
}) {
|
||||
return this.tournament(tournamentName)
|
||||
.getByRole("switch", { name: username, exact: true })
|
||||
.click({ force: true });
|
||||
}
|
||||
|
||||
async award() {
|
||||
await this.locators.awardButton.click();
|
||||
await waitForPOSTResponse(this.page, () =>
|
||||
this.page.getByTestId("confirm-button").click(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export class TrophiesPage {
|
||||
searchInput: page.getByRole("textbox"),
|
||||
// the "Add new" menu links to /trophies/new from every page
|
||||
trophyLinks: page.locator("main").locator("a[href^='/trophies/']"),
|
||||
xpSection: page.getByTestId("xp-trophies"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,10 +39,22 @@ export class TrophiesPage {
|
||||
return this.tile(trophyId).getByTestId("trophy-corner-pill");
|
||||
}
|
||||
|
||||
async openXpTrophy(name: string) {
|
||||
await this.locators.xpSection
|
||||
.getByRole("link", { name, exact: true })
|
||||
.click();
|
||||
return new TrophyDetailsPage(this.page);
|
||||
}
|
||||
|
||||
async openFirst() {
|
||||
await this.locators.trophyLinks.first().click();
|
||||
return new TrophyDetailsPage(this.page);
|
||||
}
|
||||
|
||||
async gotoTrophy(trophyId: number) {
|
||||
await navigate({ page: this.page, url: trophyPage(trophyId) });
|
||||
return new TrophyDetailsPage(this.page);
|
||||
}
|
||||
}
|
||||
|
||||
/** `/trophies/:id` */
|
||||
@@ -54,9 +67,16 @@ class TrophyDetailsPage {
|
||||
this.locators = {
|
||||
ownersHeading: page.getByText("Owners", { exact: true }),
|
||||
ownerLinks: page.locator("main").locator("a[href^='/u/']"),
|
||||
weaponCounts: page.getByTestId("trophy-weapons").getByRole("listitem"),
|
||||
};
|
||||
}
|
||||
|
||||
owner(username: string) {
|
||||
return this.page
|
||||
.getByTestId("trophy-owners")
|
||||
.getByRole("link", { name: username, exact: true });
|
||||
}
|
||||
|
||||
tournamentRow(tournamentId: number) {
|
||||
return this.page.locator("main").locator(`a[href='/to/${tournamentId}']`);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ export class UserPage {
|
||||
vodsTab: page.locator('[data-testid="user-vods-tab"]:visible'),
|
||||
resultsTab: page.locator('[data-testid="user-results-tab"]:visible'),
|
||||
seasonsTournamentResult: page.getByTestId("seasons-tournament-result"),
|
||||
trophyDisplay: page.getByTestId("trophy-display"),
|
||||
trophyPlacementRows: page
|
||||
.getByRole("dialog")
|
||||
.getByTestId("trophy-placement"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,6 +41,20 @@ export class UserPage {
|
||||
await navigate({ page: this.page, url: `/u/${identifier}` });
|
||||
}
|
||||
|
||||
openTrophy(name: string) {
|
||||
return this.locators.trophyDisplay
|
||||
.getByRole("button", { name, exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
trophyDivision(trophyName: string, divisionName: string) {
|
||||
return this.locators.trophyDisplay
|
||||
.getByRole("button", { name: trophyName, exact: true })
|
||||
.getByTestId("trophy-corner-pill")
|
||||
.getByAltText(divisionName)
|
||||
.filter({ visible: true });
|
||||
}
|
||||
|
||||
badgeImage(displayName: string) {
|
||||
return this.page.getByAltText(displayName, { exact: true });
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { addDays, subDays } from "date-fns";
|
||||
import { NZAP_TEST_ID } from "~/db/seed/constants";
|
||||
import { NZAP_TEST_DISCORD_ID, NZAP_TEST_ID } from "~/db/seed/constants";
|
||||
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import { TROPHY_APPROVALS_REQUIRED } from "~/features/trophies/trophies-constants";
|
||||
import { decompressFromBase64 } from "~/utils/compression";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { TROPHIES_PAGE } from "~/utils/urls";
|
||||
import type { Factories } from "./helpers/factories";
|
||||
import {
|
||||
expect,
|
||||
@@ -26,27 +25,6 @@ const ROSTER_SIZE = 4;
|
||||
const UPCOMING_IN_DAYS = 10;
|
||||
|
||||
test.describe("Trophies", () => {
|
||||
test("hides trophies from users without early access", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
await factories.TrophyFactory.create({ name: TROPHY_NAME });
|
||||
|
||||
await impersonate(page, NZAP_TEST_ID);
|
||||
|
||||
const response = await page.goto(TROPHIES_PAGE);
|
||||
expect(response?.status()).toBe(404);
|
||||
|
||||
const userPage = new UserPage(page);
|
||||
await userPage.goto(ADMIN_DISCORD_ID);
|
||||
await isNotVisible(page.getByTestId("trophy-display"));
|
||||
|
||||
// remove once feature is released
|
||||
const newTrophy = new NewTrophyPage(page);
|
||||
await newTrophy.goto();
|
||||
await expect(newTrophy.locators.agreeToTermsButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows trophy wins via user page trophy display", async ({
|
||||
page,
|
||||
factories,
|
||||
@@ -54,12 +32,14 @@ test.describe("Trophies", () => {
|
||||
test.slow();
|
||||
|
||||
const trophy = await factories.TrophyFactory.create({ name: TROPHY_NAME });
|
||||
const tournament = await playTrophyTournament(factories, trophy.id);
|
||||
const tournament = await playTrophyTournament(factories, {
|
||||
trophyId: trophy.id,
|
||||
});
|
||||
await factories.UserFactory.grant(ADMIN_ID, {
|
||||
widgets: [{ id: "trophies-owned" }],
|
||||
});
|
||||
|
||||
await impersonate(page);
|
||||
await impersonate(page, NZAP_TEST_ID);
|
||||
|
||||
const userPage = new UserPage(page);
|
||||
await userPage.goto(ADMIN_DISCORD_ID);
|
||||
@@ -95,7 +75,10 @@ test.describe("Trophies", () => {
|
||||
name: "Chris P. Bacon",
|
||||
});
|
||||
|
||||
await playTrophyTournament(factories, trophy.id, organization.id);
|
||||
await playTrophyTournament(factories, {
|
||||
trophyId: trophy.id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
const upcoming = await factories.TournamentFactory.create({
|
||||
name: `${TROPHY_NAME} 2`,
|
||||
authorId: ADMIN_ID,
|
||||
@@ -106,7 +89,7 @@ test.describe("Trophies", () => {
|
||||
trophyId: trophy.id,
|
||||
});
|
||||
|
||||
await impersonate(page);
|
||||
await impersonate(page, NZAP_TEST_ID);
|
||||
|
||||
const trophies = new TrophiesPage(page);
|
||||
await trophies.goto();
|
||||
@@ -227,13 +210,128 @@ test.describe("Trophies", () => {
|
||||
notifications.notification(`Your trophy ${declinedName} was declined`),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("backfills a trophy to the winners of a past tournament", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
test.slow();
|
||||
|
||||
const seriesName = "Wellstring";
|
||||
const organization = await factories.TournamentOrganizationFactory.create(
|
||||
{ name: ORGANIZATION_NAME, ownerId: ADMIN_ID },
|
||||
{
|
||||
series: [
|
||||
{ name: seriesName, description: null, showLeaderboard: false },
|
||||
],
|
||||
},
|
||||
);
|
||||
const trophy = await factories.TrophyFactory.create({
|
||||
name: TROPHY_NAME,
|
||||
organizationId: organization.id,
|
||||
managerId: ADMIN_ID,
|
||||
});
|
||||
|
||||
const awardedName = `${TROPHY_NAME} 1`;
|
||||
const skippedName = `${TROPHY_NAME} 2`;
|
||||
const awarded = await playTrophyTournament(factories, {
|
||||
organizationId: organization.id,
|
||||
name: awardedName,
|
||||
});
|
||||
const skipped = await playTrophyTournament(factories, {
|
||||
organizationId: organization.id,
|
||||
name: skippedName,
|
||||
});
|
||||
|
||||
await impersonate(page);
|
||||
|
||||
const newTrophy = new NewTrophyPage(page);
|
||||
await newTrophy.goto();
|
||||
|
||||
const backfill = await newTrophy.openBackfill();
|
||||
await backfill.selectTrophy(TROPHY_NAME);
|
||||
await backfill.selectSeries(seriesName);
|
||||
|
||||
await backfill.toggleTournament(awardedName);
|
||||
await backfill.toggleWinner({
|
||||
tournamentName: awardedName,
|
||||
username: "Sendou",
|
||||
});
|
||||
await backfill.award();
|
||||
|
||||
// an awarded tournament can't be backfilled again
|
||||
await isNotVisible(backfill.tournament(awardedName));
|
||||
await expect(backfill.tournament(skippedName)).toBeVisible();
|
||||
|
||||
const details = await new TrophiesPage(page).gotoTrophy(trophy.id);
|
||||
await expect(details.tournamentRow(awarded.id)).toBeVisible();
|
||||
await isNotVisible(details.tournamentRow(skipped.id));
|
||||
await expect(details.owner("N-ZAP")).toBeVisible();
|
||||
await isNotVisible(details.owner("Sendou"));
|
||||
});
|
||||
|
||||
test("awards X Power trophies per weapon category", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
const SPLATTERSHOT = 40;
|
||||
const SPLASH_O_MATIC = 20;
|
||||
const SPLAT_CHARGER = 2010;
|
||||
for (const [weaponSplId, power, region] of [
|
||||
[SPLATTERSHOT, 3550, "WEST"],
|
||||
[SPLATTERSHOT, 3510, "JPN"],
|
||||
[SPLASH_O_MATIC, 3120, "WEST"],
|
||||
[SPLAT_CHARGER, 3050, "WEST"],
|
||||
] as const) {
|
||||
await factories.XRankPlacementFactory.create({
|
||||
playerUserId: NZAP_TEST_ID,
|
||||
weaponSplId,
|
||||
power,
|
||||
region,
|
||||
});
|
||||
}
|
||||
await factories.TrophyFactory.createXpTrophies();
|
||||
await factories.UserFactory.grant(NZAP_TEST_ID, {
|
||||
widgets: [{ id: "trophies-owned" }],
|
||||
});
|
||||
|
||||
await impersonate(page, NZAP_TEST_ID);
|
||||
|
||||
const trophies = new TrophiesPage(page);
|
||||
await trophies.goto();
|
||||
const details = await trophies.openXpTrophy("3500 X Power Shooters");
|
||||
|
||||
await expect(details.owner("N-ZAP")).toBeVisible();
|
||||
|
||||
// the Splash-o-matic placement fell short of 3500
|
||||
await expect(details.locators.weaponCounts).toHaveCount(1);
|
||||
|
||||
const userPage = new UserPage(page);
|
||||
await userPage.goto(NZAP_TEST_DISCORD_ID);
|
||||
await expect(
|
||||
userPage.locators.trophyDisplay.getByRole("button"),
|
||||
).toHaveCount(2);
|
||||
|
||||
// reached in both divisions, Takoroka is the one shown
|
||||
await expect(
|
||||
userPage.trophyDivision("3500 X Power Shooters", "Takoroka"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
userPage.trophyDivision("3000 X Power Chargers", "Tentatek"),
|
||||
).toBeVisible();
|
||||
|
||||
await userPage.openTrophy("3500 X Power Shooters");
|
||||
await expect(userPage.locators.trophyPlacementRows).toHaveCount(3);
|
||||
});
|
||||
});
|
||||
|
||||
/** Plays a tournament with the trophy as its prize, awarding it to the winning team. */
|
||||
async function playTrophyTournament(
|
||||
factories: Factories,
|
||||
trophyId: number,
|
||||
organizationId?: number,
|
||||
{
|
||||
trophyId,
|
||||
organizationId,
|
||||
name = `${TROPHY_NAME} 1`,
|
||||
}: { trophyId?: number; organizationId?: number; name?: string },
|
||||
) {
|
||||
// the top seed wins, so the anchor users are on it and end up owning the trophy
|
||||
const players = await factories.UserFactory.createMany(
|
||||
@@ -246,7 +344,7 @@ async function playTrophyTournament(
|
||||
|
||||
return factories.TournamentFactory.createPlayed(
|
||||
{
|
||||
name: `${TROPHY_NAME} 1`,
|
||||
name,
|
||||
authorId: ADMIN_ID,
|
||||
organizationId,
|
||||
startTimes: [dateToDatabaseTimestamp(subDays(new Date(), 21))],
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"details.noOwners": "",
|
||||
"details.tournamentHistory": "",
|
||||
"details.noTournamentHistory": "",
|
||||
"details.weapons": "",
|
||||
"details.upcoming": "",
|
||||
"display.viewTrophyPage": "",
|
||||
"special.supporter.description": "",
|
||||
"special.xp.description": "",
|
||||
"special.xp.categoryDescription": "",
|
||||
"xp.title": "",
|
||||
"new.form.limitReached": "",
|
||||
"new.form.preview.light": "",
|
||||
"new.form.preview.dark": "",
|
||||
@@ -47,7 +48,7 @@
|
||||
"new.terms.preApproval": "",
|
||||
"new.terms.agree": "",
|
||||
"new.tabs.upload": "",
|
||||
"new.tabs.update": "",
|
||||
"new.tabs.manage": "",
|
||||
"new.tabs.pending": "",
|
||||
"new.tabs.reviewed": "",
|
||||
"new.update.selectLabel": "",
|
||||
@@ -55,6 +56,21 @@
|
||||
"new.update.editing": "",
|
||||
"new.update.before": "",
|
||||
"new.update.after": "",
|
||||
"new.manage.update": "",
|
||||
"new.manage.backfill": "",
|
||||
"new.backfill.explanation": "",
|
||||
"new.backfill.warning": "",
|
||||
"new.backfill.trophyLabel": "",
|
||||
"new.backfill.seriesLabel": "",
|
||||
"new.backfill.noTrophies": "",
|
||||
"new.backfill.noTournaments": "",
|
||||
"new.backfill.selected_one": "",
|
||||
"new.backfill.selected_other": "",
|
||||
"new.backfill.noPlayersSelected": "",
|
||||
"new.backfill.submit": "",
|
||||
"new.backfill.confirm.heading_one": "",
|
||||
"new.backfill.confirm.heading_other": "",
|
||||
"new.backfill.confirm.description": "",
|
||||
"new.pending.empty": "",
|
||||
"new.pending.notFromManager": "",
|
||||
"new.pending.approve": "",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"details.noOwners": "",
|
||||
"details.tournamentHistory": "",
|
||||
"details.noTournamentHistory": "",
|
||||
"details.weapons": "",
|
||||
"details.upcoming": "",
|
||||
"display.viewTrophyPage": "",
|
||||
"special.supporter.description": "",
|
||||
"special.xp.description": "",
|
||||
"special.xp.categoryDescription": "",
|
||||
"xp.title": "",
|
||||
"new.form.limitReached": "",
|
||||
"new.form.preview.light": "",
|
||||
"new.form.preview.dark": "",
|
||||
@@ -47,7 +48,7 @@
|
||||
"new.terms.preApproval": "",
|
||||
"new.terms.agree": "",
|
||||
"new.tabs.upload": "",
|
||||
"new.tabs.update": "",
|
||||
"new.tabs.manage": "",
|
||||
"new.tabs.pending": "",
|
||||
"new.tabs.reviewed": "",
|
||||
"new.update.selectLabel": "",
|
||||
@@ -55,6 +56,21 @@
|
||||
"new.update.editing": "",
|
||||
"new.update.before": "",
|
||||
"new.update.after": "",
|
||||
"new.manage.update": "",
|
||||
"new.manage.backfill": "",
|
||||
"new.backfill.explanation": "",
|
||||
"new.backfill.warning": "",
|
||||
"new.backfill.trophyLabel": "",
|
||||
"new.backfill.seriesLabel": "",
|
||||
"new.backfill.noTrophies": "",
|
||||
"new.backfill.noTournaments": "",
|
||||
"new.backfill.selected_one": "",
|
||||
"new.backfill.selected_other": "",
|
||||
"new.backfill.noPlayersSelected": "",
|
||||
"new.backfill.submit": "",
|
||||
"new.backfill.confirm.heading_one": "",
|
||||
"new.backfill.confirm.heading_other": "",
|
||||
"new.backfill.confirm.description": "",
|
||||
"new.pending.empty": "",
|
||||
"new.pending.notFromManager": "",
|
||||
"new.pending.approve": "",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"details.noOwners": "No owners yet",
|
||||
"details.tournamentHistory": "Tournaments",
|
||||
"details.noTournamentHistory": "No tournament history",
|
||||
"details.weapons": "Weapons",
|
||||
"details.upcoming": "Upcoming",
|
||||
"display.viewTrophyPage": "View trophy page",
|
||||
"special.supporter.description": "Awarded for supporting sendou.ink",
|
||||
"special.xp.description": "Awarded for reaching {{value}} X Power",
|
||||
"special.xp.categoryDescription": "Awarded for reaching {{value}} X Power with {{category}}",
|
||||
"xp.title": "X Power trophies",
|
||||
"new.form.limitReached": "You have reached the limit of {{limit}} pending trophies. Delete or wait for an existing submission to be reviewed before submitting another.",
|
||||
"new.form.preview.light": "Light mode",
|
||||
"new.form.preview.dark": "Dark mode",
|
||||
@@ -47,7 +48,7 @@
|
||||
"new.terms.preApproval": "If you are unsure if your tournament is eligible, feel free to acquire a pre-approval by creating a pre-approval request in the <1>Discord server</1>.",
|
||||
"new.terms.agree": "I have read and agree",
|
||||
"new.tabs.upload": "Upload",
|
||||
"new.tabs.update": "Update",
|
||||
"new.tabs.manage": "Manage",
|
||||
"new.tabs.pending": "Pending",
|
||||
"new.tabs.reviewed": "Reviewed",
|
||||
"new.update.selectLabel": "Trophy to update",
|
||||
@@ -55,6 +56,21 @@
|
||||
"new.update.editing": "Editing existing trophy",
|
||||
"new.update.before": "Before",
|
||||
"new.update.after": "After",
|
||||
"new.manage.update": "Update",
|
||||
"new.manage.backfill": "Backfill",
|
||||
"new.backfill.explanation": "Award a trophy to the winners of past tournaments in its series that were held without a trophy. Only the winning team of the top division is shown. Players who did not play a single set are hidden.",
|
||||
"new.backfill.warning": "Awarding a trophy can not be undone. Trophies can not be taken away once awarded, so double check the tournaments and players you select.",
|
||||
"new.backfill.trophyLabel": "Trophy to award",
|
||||
"new.backfill.seriesLabel": "Tournament series",
|
||||
"new.backfill.noTrophies": "None of your trophies belong to an organization with a tournament series",
|
||||
"new.backfill.noTournaments": "Every past tournament of this series already has a trophy",
|
||||
"new.backfill.selected_one": "{{count}} tournament selected ({{playerCount}} players)",
|
||||
"new.backfill.selected_other": "{{count}} tournaments selected ({{playerCount}} players)",
|
||||
"new.backfill.noPlayersSelected": "Select at least one player for every selected tournament",
|
||||
"new.backfill.submit": "Award trophy",
|
||||
"new.backfill.confirm.heading_one": "Award {{trophyName}} to {{playerCount}} players for {{count}} tournament?",
|
||||
"new.backfill.confirm.heading_other": "Award {{trophyName}} to {{playerCount}} players for {{count}} tournaments?",
|
||||
"new.backfill.confirm.description": "This can not be undone. Trophies can not be taken away once awarded.",
|
||||
"new.pending.empty": "No pending trophies",
|
||||
"new.pending.notFromManager": "Not submitted by the trophy's manager",
|
||||
"new.pending.approve": "Approve",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"details.noOwners": "Sin propietarios todavía",
|
||||
"details.tournamentHistory": "Torneos",
|
||||
"details.noTournamentHistory": "Sin historial de torneos",
|
||||
"details.weapons": "",
|
||||
"details.upcoming": "Próximos",
|
||||
"display.viewTrophyPage": "Ver página del trofeo",
|
||||
"special.supporter.description": "Otorgado por apoyar a sendou.ink",
|
||||
"special.xp.description": "Otorgado por alcanzar {{value}} de Energía X",
|
||||
"special.xp.categoryDescription": "",
|
||||
"xp.title": "",
|
||||
"new.form.limitReached": "Has alcanzado el límite de {{limit}} trofeos pendientes. Borra o espera a que se revise un envío existente antes de enviar otro.",
|
||||
"new.form.preview.light": "Modo claro",
|
||||
"new.form.preview.dark": "Modo oscuro",
|
||||
@@ -47,7 +48,7 @@
|
||||
"new.terms.preApproval": "Si no tienes claro si tu torneo cumple los requisitos, no dudes en conseguir una preaprobación creando una solicitud en el <1>servidor de Discord</1>.",
|
||||
"new.terms.agree": "He leído y acepto",
|
||||
"new.tabs.upload": "Subir",
|
||||
"new.tabs.update": "Actualizar",
|
||||
"new.tabs.manage": "",
|
||||
"new.tabs.pending": "Pendientes",
|
||||
"new.tabs.reviewed": "Revisados",
|
||||
"new.update.selectLabel": "Trofeo a actualizar",
|
||||
@@ -55,6 +56,23 @@
|
||||
"new.update.editing": "Editando trofeo existente",
|
||||
"new.update.before": "Antes",
|
||||
"new.update.after": "Después",
|
||||
"new.manage.update": "Actualizar",
|
||||
"new.manage.backfill": "",
|
||||
"new.backfill.explanation": "",
|
||||
"new.backfill.warning": "",
|
||||
"new.backfill.trophyLabel": "",
|
||||
"new.backfill.seriesLabel": "",
|
||||
"new.backfill.noTrophies": "",
|
||||
"new.backfill.noTournaments": "",
|
||||
"new.backfill.selected_one": "",
|
||||
"new.backfill.selected_many": "",
|
||||
"new.backfill.selected_other": "",
|
||||
"new.backfill.noPlayersSelected": "",
|
||||
"new.backfill.submit": "",
|
||||
"new.backfill.confirm.heading_one": "",
|
||||
"new.backfill.confirm.heading_many": "",
|
||||
"new.backfill.confirm.heading_other": "",
|
||||
"new.backfill.confirm.description": "",
|
||||
"new.pending.empty": "No hay trofeos pendientes",
|
||||
"new.pending.notFromManager": "No ha sido enviado por el gestor del trofeo",
|
||||
"new.pending.approve": "Aprobar",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"details.noOwners": "Sin propietarios todavía",
|
||||
"details.tournamentHistory": "Torneos",
|
||||
"details.noTournamentHistory": "Sin historial de torneos",
|
||||
"details.weapons": "",
|
||||
"details.upcoming": "Próximos",
|
||||
"display.viewTrophyPage": "Ver página del trofeo",
|
||||
"special.supporter.description": "Otorgado por apoyar a sendou.ink",
|
||||
"special.xp.description": "Otorgado por alcanzar {{value}} de Energía X",
|
||||
"special.xp.categoryDescription": "",
|
||||
"xp.title": "",
|
||||
"new.form.limitReached": "Has alcanzado el límite de {{limit}} trofeos pendientes. Borra o espera a que se revise un envío existente antes de enviar otro.",
|
||||
"new.form.preview.light": "Modo claro",
|
||||
"new.form.preview.dark": "Modo oscuro",
|
||||
@@ -47,7 +48,7 @@
|
||||
"new.terms.preApproval": "Si no tienes claro si tu torneo cumple los requisitos, no dudes en conseguir una preaprobación creando una solicitud en el <1>servidor de Discord</1>.",
|
||||
"new.terms.agree": "He leído y acepto",
|
||||
"new.tabs.upload": "Subir",
|
||||
"new.tabs.update": "Actualizar",
|
||||
"new.tabs.manage": "",
|
||||
"new.tabs.pending": "Pendientes",
|
||||
"new.tabs.reviewed": "Revisados",
|
||||
"new.update.selectLabel": "Trofeo a actualizar",
|
||||
@@ -55,6 +56,23 @@
|
||||
"new.update.editing": "Editando trofeo existente",
|
||||
"new.update.before": "Antes",
|
||||
"new.update.after": "Después",
|
||||
"new.manage.update": "Actualizar",
|
||||
"new.manage.backfill": "",
|
||||
"new.backfill.explanation": "",
|
||||
"new.backfill.warning": "",
|
||||
"new.backfill.trophyLabel": "",
|
||||
"new.backfill.seriesLabel": "",
|
||||
"new.backfill.noTrophies": "",
|
||||
"new.backfill.noTournaments": "",
|
||||
"new.backfill.selected_one": "",
|
||||
"new.backfill.selected_many": "",
|
||||
"new.backfill.selected_other": "",
|
||||
"new.backfill.noPlayersSelected": "",
|
||||
"new.backfill.submit": "",
|
||||
"new.backfill.confirm.heading_one": "",
|
||||
"new.backfill.confirm.heading_many": "",
|
||||
"new.backfill.confirm.heading_other": "",
|
||||
"new.backfill.confirm.description": "",
|
||||
"new.pending.empty": "No hay trofeos pendientes",
|
||||
"new.pending.notFromManager": "No ha sido enviado por el gestor del trofeo",
|
||||
"new.pending.approve": "Aprobar",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"details.noOwners": "",
|
||||
"details.tournamentHistory": "",
|
||||
"details.noTournamentHistory": "",
|
||||
"details.weapons": "",
|
||||
"details.upcoming": "",
|
||||
"display.viewTrophyPage": "",
|
||||
"special.supporter.description": "",
|
||||
"special.xp.description": "",
|
||||
"special.xp.categoryDescription": "",
|
||||
"xp.title": "",
|
||||
"new.form.limitReached": "",
|
||||
"new.form.preview.light": "",
|
||||
"new.form.preview.dark": "",
|
||||
@@ -47,7 +48,7 @@
|
||||
"new.terms.preApproval": "",
|
||||
"new.terms.agree": "",
|
||||
"new.tabs.upload": "",
|
||||
"new.tabs.update": "",
|
||||
"new.tabs.manage": "",
|
||||
"new.tabs.pending": "",
|
||||
"new.tabs.reviewed": "",
|
||||
"new.update.selectLabel": "",
|
||||
@@ -55,6 +56,23 @@
|
||||
"new.update.editing": "",
|
||||
"new.update.before": "",
|
||||
"new.update.after": "",
|
||||
"new.manage.update": "",
|
||||
"new.manage.backfill": "",
|
||||
"new.backfill.explanation": "",
|
||||
"new.backfill.warning": "",
|
||||
"new.backfill.trophyLabel": "",
|
||||
"new.backfill.seriesLabel": "",
|
||||
"new.backfill.noTrophies": "",
|
||||
"new.backfill.noTournaments": "",
|
||||
"new.backfill.selected_one": "",
|
||||
"new.backfill.selected_many": "",
|
||||
"new.backfill.selected_other": "",
|
||||
"new.backfill.noPlayersSelected": "",
|
||||
"new.backfill.submit": "",
|
||||
"new.backfill.confirm.heading_one": "",
|
||||
"new.backfill.confirm.heading_many": "",
|
||||
"new.backfill.confirm.heading_other": "",
|
||||
"new.backfill.confirm.description": "",
|
||||
"new.pending.empty": "",
|
||||
"new.pending.notFromManager": "",
|
||||
"new.pending.approve": "",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"details.noOwners": "",
|
||||
"details.tournamentHistory": "",
|
||||
"details.noTournamentHistory": "",
|
||||
"details.weapons": "",
|
||||
"details.upcoming": "",
|
||||
"display.viewTrophyPage": "",
|
||||
"special.supporter.description": "",
|
||||
"special.xp.description": "",
|
||||
"special.xp.categoryDescription": "",
|
||||
"xp.title": "",
|
||||
"new.form.limitReached": "",
|
||||
"new.form.preview.light": "",
|
||||
"new.form.preview.dark": "",
|
||||
@@ -47,7 +48,7 @@
|
||||
"new.terms.preApproval": "",
|
||||
"new.terms.agree": "",
|
||||
"new.tabs.upload": "",
|
||||
"new.tabs.update": "",
|
||||
"new.tabs.manage": "",
|
||||
"new.tabs.pending": "",
|
||||
"new.tabs.reviewed": "",
|
||||
"new.update.selectLabel": "",
|
||||
@@ -55,6 +56,23 @@
|
||||
"new.update.editing": "",
|
||||
"new.update.before": "",
|
||||
"new.update.after": "",
|
||||
"new.manage.update": "",
|
||||
"new.manage.backfill": "",
|
||||
"new.backfill.explanation": "",
|
||||
"new.backfill.warning": "",
|
||||
"new.backfill.trophyLabel": "",
|
||||
"new.backfill.seriesLabel": "",
|
||||
"new.backfill.noTrophies": "",
|
||||
"new.backfill.noTournaments": "",
|
||||
"new.backfill.selected_one": "",
|
||||
"new.backfill.selected_many": "",
|
||||
"new.backfill.selected_other": "",
|
||||
"new.backfill.noPlayersSelected": "",
|
||||
"new.backfill.submit": "",
|
||||
"new.backfill.confirm.heading_one": "",
|
||||
"new.backfill.confirm.heading_many": "",
|
||||
"new.backfill.confirm.heading_other": "",
|
||||
"new.backfill.confirm.description": "",
|
||||
"new.pending.empty": "",
|
||||
"new.pending.notFromManager": "",
|
||||
"new.pending.approve": "",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"details.noOwners": "",
|
||||
"details.tournamentHistory": "",
|
||||
"details.noTournamentHistory": "",
|
||||
"details.weapons": "",
|
||||
"details.upcoming": "",
|
||||
"display.viewTrophyPage": "",
|
||||
"special.supporter.description": "",
|
||||
"special.xp.description": "",
|
||||
"special.xp.categoryDescription": "",
|
||||
"xp.title": "",
|
||||
"new.form.limitReached": "",
|
||||
"new.form.preview.light": "",
|
||||
"new.form.preview.dark": "",
|
||||
@@ -47,7 +48,7 @@
|
||||
"new.terms.preApproval": "",
|
||||
"new.terms.agree": "",
|
||||
"new.tabs.upload": "",
|
||||
"new.tabs.update": "",
|
||||
"new.tabs.manage": "",
|
||||
"new.tabs.pending": "",
|
||||
"new.tabs.reviewed": "",
|
||||
"new.update.selectLabel": "",
|
||||
@@ -55,6 +56,23 @@
|
||||
"new.update.editing": "",
|
||||
"new.update.before": "",
|
||||
"new.update.after": "",
|
||||
"new.manage.update": "",
|
||||
"new.manage.backfill": "",
|
||||
"new.backfill.explanation": "",
|
||||
"new.backfill.warning": "",
|
||||
"new.backfill.trophyLabel": "",
|
||||
"new.backfill.seriesLabel": "",
|
||||
"new.backfill.noTrophies": "",
|
||||
"new.backfill.noTournaments": "",
|
||||
"new.backfill.selected_one": "",
|
||||
"new.backfill.selected_two": "",
|
||||
"new.backfill.selected_other": "",
|
||||
"new.backfill.noPlayersSelected": "",
|
||||
"new.backfill.submit": "",
|
||||
"new.backfill.confirm.heading_one": "",
|
||||
"new.backfill.confirm.heading_two": "",
|
||||
"new.backfill.confirm.heading_other": "",
|
||||
"new.backfill.confirm.description": "",
|
||||
"new.pending.empty": "",
|
||||
"new.pending.notFromManager": "",
|
||||
"new.pending.approve": "",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"details.noOwners": "",
|
||||
"details.tournamentHistory": "",
|
||||
"details.noTournamentHistory": "",
|
||||
"details.weapons": "",
|
||||
"details.upcoming": "",
|
||||
"display.viewTrophyPage": "",
|
||||
"special.supporter.description": "",
|
||||
"special.xp.description": "",
|
||||
"special.xp.categoryDescription": "",
|
||||
"xp.title": "",
|
||||
"new.form.limitReached": "",
|
||||
"new.form.preview.light": "",
|
||||
"new.form.preview.dark": "",
|
||||
@@ -47,7 +48,7 @@
|
||||
"new.terms.preApproval": "",
|
||||
"new.terms.agree": "",
|
||||
"new.tabs.upload": "",
|
||||
"new.tabs.update": "",
|
||||
"new.tabs.manage": "",
|
||||
"new.tabs.pending": "",
|
||||
"new.tabs.reviewed": "",
|
||||
"new.update.selectLabel": "",
|
||||
@@ -55,6 +56,23 @@
|
||||
"new.update.editing": "",
|
||||
"new.update.before": "",
|
||||
"new.update.after": "",
|
||||
"new.manage.update": "",
|
||||
"new.manage.backfill": "",
|
||||
"new.backfill.explanation": "",
|
||||
"new.backfill.warning": "",
|
||||
"new.backfill.trophyLabel": "",
|
||||
"new.backfill.seriesLabel": "",
|
||||
"new.backfill.noTrophies": "",
|
||||
"new.backfill.noTournaments": "",
|
||||
"new.backfill.selected_one": "",
|
||||
"new.backfill.selected_many": "",
|
||||
"new.backfill.selected_other": "",
|
||||
"new.backfill.noPlayersSelected": "",
|
||||
"new.backfill.submit": "",
|
||||
"new.backfill.confirm.heading_one": "",
|
||||
"new.backfill.confirm.heading_many": "",
|
||||
"new.backfill.confirm.heading_other": "",
|
||||
"new.backfill.confirm.description": "",
|
||||
"new.pending.empty": "",
|
||||
"new.pending.notFromManager": "",
|
||||
"new.pending.approve": "",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"details.noOwners": "",
|
||||
"details.tournamentHistory": "",
|
||||
"details.noTournamentHistory": "",
|
||||
"details.weapons": "",
|
||||
"details.upcoming": "",
|
||||
"display.viewTrophyPage": "",
|
||||
"special.supporter.description": "",
|
||||
"special.xp.description": "",
|
||||
"special.xp.categoryDescription": "",
|
||||
"xp.title": "",
|
||||
"new.form.limitReached": "",
|
||||
"new.form.preview.light": "",
|
||||
"new.form.preview.dark": "",
|
||||
@@ -47,7 +48,7 @@
|
||||
"new.terms.preApproval": "",
|
||||
"new.terms.agree": "",
|
||||
"new.tabs.upload": "",
|
||||
"new.tabs.update": "",
|
||||
"new.tabs.manage": "",
|
||||
"new.tabs.pending": "",
|
||||
"new.tabs.reviewed": "",
|
||||
"new.update.selectLabel": "",
|
||||
@@ -55,6 +56,17 @@
|
||||
"new.update.editing": "",
|
||||
"new.update.before": "",
|
||||
"new.update.after": "",
|
||||
"new.manage.update": "",
|
||||
"new.manage.backfill": "",
|
||||
"new.backfill.explanation": "",
|
||||
"new.backfill.warning": "",
|
||||
"new.backfill.trophyLabel": "",
|
||||
"new.backfill.seriesLabel": "",
|
||||
"new.backfill.noTrophies": "",
|
||||
"new.backfill.noTournaments": "",
|
||||
"new.backfill.noPlayersSelected": "",
|
||||
"new.backfill.submit": "",
|
||||
"new.backfill.confirm.description": "",
|
||||
"new.pending.empty": "",
|
||||
"new.pending.notFromManager": "",
|
||||
"new.pending.approve": "",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"details.noOwners": "",
|
||||
"details.tournamentHistory": "",
|
||||
"details.noTournamentHistory": "",
|
||||
"details.weapons": "",
|
||||
"details.upcoming": "",
|
||||
"display.viewTrophyPage": "",
|
||||
"special.supporter.description": "",
|
||||
"special.xp.description": "",
|
||||
"special.xp.categoryDescription": "",
|
||||
"xp.title": "",
|
||||
"new.form.limitReached": "",
|
||||
"new.form.preview.light": "",
|
||||
"new.form.preview.dark": "",
|
||||
@@ -47,7 +48,7 @@
|
||||
"new.terms.preApproval": "",
|
||||
"new.terms.agree": "",
|
||||
"new.tabs.upload": "",
|
||||
"new.tabs.update": "",
|
||||
"new.tabs.manage": "",
|
||||
"new.tabs.pending": "",
|
||||
"new.tabs.reviewed": "",
|
||||
"new.update.selectLabel": "",
|
||||
@@ -55,6 +56,17 @@
|
||||
"new.update.editing": "",
|
||||
"new.update.before": "",
|
||||
"new.update.after": "",
|
||||
"new.manage.update": "",
|
||||
"new.manage.backfill": "",
|
||||
"new.backfill.explanation": "",
|
||||
"new.backfill.warning": "",
|
||||
"new.backfill.trophyLabel": "",
|
||||
"new.backfill.seriesLabel": "",
|
||||
"new.backfill.noTrophies": "",
|
||||
"new.backfill.noTournaments": "",
|
||||
"new.backfill.noPlayersSelected": "",
|
||||
"new.backfill.submit": "",
|
||||
"new.backfill.confirm.description": "",
|
||||
"new.pending.empty": "",
|
||||
"new.pending.notFromManager": "",
|
||||
"new.pending.approve": "",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"details.noOwners": "",
|
||||
"details.tournamentHistory": "",
|
||||
"details.noTournamentHistory": "",
|
||||
"details.weapons": "",
|
||||
"details.upcoming": "",
|
||||
"display.viewTrophyPage": "",
|
||||
"special.supporter.description": "",
|
||||
"special.xp.description": "",
|
||||
"special.xp.categoryDescription": "",
|
||||
"xp.title": "",
|
||||
"new.form.limitReached": "",
|
||||
"new.form.preview.light": "",
|
||||
"new.form.preview.dark": "",
|
||||
@@ -47,7 +48,7 @@
|
||||
"new.terms.preApproval": "",
|
||||
"new.terms.agree": "",
|
||||
"new.tabs.upload": "",
|
||||
"new.tabs.update": "",
|
||||
"new.tabs.manage": "",
|
||||
"new.tabs.pending": "",
|
||||
"new.tabs.reviewed": "",
|
||||
"new.update.selectLabel": "",
|
||||
@@ -55,6 +56,21 @@
|
||||
"new.update.editing": "",
|
||||
"new.update.before": "",
|
||||
"new.update.after": "",
|
||||
"new.manage.update": "",
|
||||
"new.manage.backfill": "",
|
||||
"new.backfill.explanation": "",
|
||||
"new.backfill.warning": "",
|
||||
"new.backfill.trophyLabel": "",
|
||||
"new.backfill.seriesLabel": "",
|
||||
"new.backfill.noTrophies": "",
|
||||
"new.backfill.noTournaments": "",
|
||||
"new.backfill.selected_one": "",
|
||||
"new.backfill.selected_other": "",
|
||||
"new.backfill.noPlayersSelected": "",
|
||||
"new.backfill.submit": "",
|
||||
"new.backfill.confirm.heading_one": "",
|
||||
"new.backfill.confirm.heading_other": "",
|
||||
"new.backfill.confirm.description": "",
|
||||
"new.pending.empty": "",
|
||||
"new.pending.notFromManager": "",
|
||||
"new.pending.approve": "",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"details.noOwners": "",
|
||||
"details.tournamentHistory": "",
|
||||
"details.noTournamentHistory": "",
|
||||
"details.weapons": "",
|
||||
"details.upcoming": "",
|
||||
"display.viewTrophyPage": "",
|
||||
"special.supporter.description": "",
|
||||
"special.xp.description": "",
|
||||
"special.xp.categoryDescription": "",
|
||||
"xp.title": "",
|
||||
"new.form.limitReached": "",
|
||||
"new.form.preview.light": "",
|
||||
"new.form.preview.dark": "",
|
||||
@@ -47,7 +48,7 @@
|
||||
"new.terms.preApproval": "",
|
||||
"new.terms.agree": "",
|
||||
"new.tabs.upload": "",
|
||||
"new.tabs.update": "",
|
||||
"new.tabs.manage": "",
|
||||
"new.tabs.pending": "",
|
||||
"new.tabs.reviewed": "",
|
||||
"new.update.selectLabel": "",
|
||||
@@ -55,6 +56,25 @@
|
||||
"new.update.editing": "",
|
||||
"new.update.before": "",
|
||||
"new.update.after": "",
|
||||
"new.manage.update": "",
|
||||
"new.manage.backfill": "",
|
||||
"new.backfill.explanation": "",
|
||||
"new.backfill.warning": "",
|
||||
"new.backfill.trophyLabel": "",
|
||||
"new.backfill.seriesLabel": "",
|
||||
"new.backfill.noTrophies": "",
|
||||
"new.backfill.noTournaments": "",
|
||||
"new.backfill.selected_one": "",
|
||||
"new.backfill.selected_few": "",
|
||||
"new.backfill.selected_many": "",
|
||||
"new.backfill.selected_other": "",
|
||||
"new.backfill.noPlayersSelected": "",
|
||||
"new.backfill.submit": "",
|
||||
"new.backfill.confirm.heading_one": "",
|
||||
"new.backfill.confirm.heading_few": "",
|
||||
"new.backfill.confirm.heading_many": "",
|
||||
"new.backfill.confirm.heading_other": "",
|
||||
"new.backfill.confirm.description": "",
|
||||
"new.pending.empty": "",
|
||||
"new.pending.notFromManager": "",
|
||||
"new.pending.approve": "",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"details.noOwners": "",
|
||||
"details.tournamentHistory": "",
|
||||
"details.noTournamentHistory": "",
|
||||
"details.weapons": "",
|
||||
"details.upcoming": "",
|
||||
"display.viewTrophyPage": "",
|
||||
"special.supporter.description": "",
|
||||
"special.xp.description": "",
|
||||
"special.xp.categoryDescription": "",
|
||||
"xp.title": "",
|
||||
"new.form.limitReached": "",
|
||||
"new.form.preview.light": "",
|
||||
"new.form.preview.dark": "",
|
||||
@@ -47,7 +48,7 @@
|
||||
"new.terms.preApproval": "",
|
||||
"new.terms.agree": "",
|
||||
"new.tabs.upload": "",
|
||||
"new.tabs.update": "",
|
||||
"new.tabs.manage": "",
|
||||
"new.tabs.pending": "",
|
||||
"new.tabs.reviewed": "",
|
||||
"new.update.selectLabel": "",
|
||||
@@ -55,6 +56,23 @@
|
||||
"new.update.editing": "",
|
||||
"new.update.before": "",
|
||||
"new.update.after": "",
|
||||
"new.manage.update": "",
|
||||
"new.manage.backfill": "",
|
||||
"new.backfill.explanation": "",
|
||||
"new.backfill.warning": "",
|
||||
"new.backfill.trophyLabel": "",
|
||||
"new.backfill.seriesLabel": "",
|
||||
"new.backfill.noTrophies": "",
|
||||
"new.backfill.noTournaments": "",
|
||||
"new.backfill.selected_one": "",
|
||||
"new.backfill.selected_many": "",
|
||||
"new.backfill.selected_other": "",
|
||||
"new.backfill.noPlayersSelected": "",
|
||||
"new.backfill.submit": "",
|
||||
"new.backfill.confirm.heading_one": "",
|
||||
"new.backfill.confirm.heading_many": "",
|
||||
"new.backfill.confirm.heading_other": "",
|
||||
"new.backfill.confirm.description": "",
|
||||
"new.pending.empty": "",
|
||||
"new.pending.notFromManager": "",
|
||||
"new.pending.approve": "",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"details.noOwners": "",
|
||||
"details.tournamentHistory": "",
|
||||
"details.noTournamentHistory": "",
|
||||
"details.weapons": "",
|
||||
"details.upcoming": "",
|
||||
"display.viewTrophyPage": "",
|
||||
"special.supporter.description": "",
|
||||
"special.xp.description": "",
|
||||
"special.xp.categoryDescription": "",
|
||||
"xp.title": "",
|
||||
"new.form.limitReached": "",
|
||||
"new.form.preview.light": "",
|
||||
"new.form.preview.dark": "",
|
||||
@@ -47,7 +48,7 @@
|
||||
"new.terms.preApproval": "",
|
||||
"new.terms.agree": "",
|
||||
"new.tabs.upload": "",
|
||||
"new.tabs.update": "",
|
||||
"new.tabs.manage": "",
|
||||
"new.tabs.pending": "",
|
||||
"new.tabs.reviewed": "",
|
||||
"new.update.selectLabel": "",
|
||||
@@ -55,6 +56,25 @@
|
||||
"new.update.editing": "",
|
||||
"new.update.before": "",
|
||||
"new.update.after": "",
|
||||
"new.manage.update": "",
|
||||
"new.manage.backfill": "",
|
||||
"new.backfill.explanation": "",
|
||||
"new.backfill.warning": "",
|
||||
"new.backfill.trophyLabel": "",
|
||||
"new.backfill.seriesLabel": "",
|
||||
"new.backfill.noTrophies": "",
|
||||
"new.backfill.noTournaments": "",
|
||||
"new.backfill.selected_one": "",
|
||||
"new.backfill.selected_few": "",
|
||||
"new.backfill.selected_many": "",
|
||||
"new.backfill.selected_other": "",
|
||||
"new.backfill.noPlayersSelected": "",
|
||||
"new.backfill.submit": "",
|
||||
"new.backfill.confirm.heading_one": "",
|
||||
"new.backfill.confirm.heading_few": "",
|
||||
"new.backfill.confirm.heading_many": "",
|
||||
"new.backfill.confirm.heading_other": "",
|
||||
"new.backfill.confirm.description": "",
|
||||
"new.pending.empty": "",
|
||||
"new.pending.notFromManager": "",
|
||||
"new.pending.approve": "",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"details.noOwners": "",
|
||||
"details.tournamentHistory": "",
|
||||
"details.noTournamentHistory": "",
|
||||
"details.weapons": "",
|
||||
"details.upcoming": "",
|
||||
"display.viewTrophyPage": "",
|
||||
"special.supporter.description": "",
|
||||
"special.xp.description": "",
|
||||
"special.xp.categoryDescription": "",
|
||||
"xp.title": "",
|
||||
"new.form.limitReached": "",
|
||||
"new.form.preview.light": "",
|
||||
"new.form.preview.dark": "",
|
||||
@@ -47,7 +48,7 @@
|
||||
"new.terms.preApproval": "",
|
||||
"new.terms.agree": "",
|
||||
"new.tabs.upload": "",
|
||||
"new.tabs.update": "",
|
||||
"new.tabs.manage": "",
|
||||
"new.tabs.pending": "",
|
||||
"new.tabs.reviewed": "",
|
||||
"new.update.selectLabel": "",
|
||||
@@ -55,6 +56,17 @@
|
||||
"new.update.editing": "",
|
||||
"new.update.before": "",
|
||||
"new.update.after": "",
|
||||
"new.manage.update": "",
|
||||
"new.manage.backfill": "",
|
||||
"new.backfill.explanation": "",
|
||||
"new.backfill.warning": "",
|
||||
"new.backfill.trophyLabel": "",
|
||||
"new.backfill.seriesLabel": "",
|
||||
"new.backfill.noTrophies": "",
|
||||
"new.backfill.noTournaments": "",
|
||||
"new.backfill.noPlayersSelected": "",
|
||||
"new.backfill.submit": "",
|
||||
"new.backfill.confirm.description": "",
|
||||
"new.pending.empty": "",
|
||||
"new.pending.notFromManager": "",
|
||||
"new.pending.approve": "",
|
||||
|
||||
135
migrations/20260930203031-xp-trophies.ts
Normal file
135
migrations/20260930203031-xp-trophies.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { Kysely } from "kysely";
|
||||
|
||||
const WEAPON_IDS_BY_CATEGORY: Record<string, number[]> = {
|
||||
shooters: [
|
||||
0, 1, 10, 11, 20, 21, 22, 30, 31, 32, 40, 41, 42, 45, 46, 47, 48, 50, 51,
|
||||
60, 61, 70, 71, 72, 80, 81, 82, 90, 91, 92, 100, 101, 300, 301, 302, 310,
|
||||
311, 312, 400, 401,
|
||||
],
|
||||
blasters: [
|
||||
200, 201, 205, 210, 211, 212, 220, 221, 230, 231, 240, 241, 250, 251, 252,
|
||||
260, 261,
|
||||
],
|
||||
rollers: [
|
||||
1000, 1001, 1002, 1010, 1011, 1015, 1020, 1021, 1022, 1030, 1031, 1040,
|
||||
1041, 1042,
|
||||
],
|
||||
brushes: [1100, 1101, 1110, 1111, 1112, 1115, 1120, 1121, 1122],
|
||||
chargers: [
|
||||
2000, 2001, 2010, 2011, 2012, 2015, 2020, 2021, 2022, 2030, 2031, 2040,
|
||||
2041, 2050, 2051, 2060, 2061, 2070, 2071,
|
||||
],
|
||||
sloshers: [
|
||||
3000, 3001, 3005, 3010, 3011, 3012, 3020, 3021, 3030, 3031, 3040, 3041,
|
||||
3050, 3051, 3052,
|
||||
],
|
||||
splatlings: [
|
||||
4000, 4001, 4002, 4010, 4011, 4015, 4020, 4021, 4022, 4030, 4031, 4040,
|
||||
4041, 4050, 4051,
|
||||
],
|
||||
dualies: [
|
||||
5000, 5001, 5002, 5010, 5015, 5011, 5012, 5020, 5021, 5030, 5031, 5032,
|
||||
5040, 5041, 5050, 5051,
|
||||
],
|
||||
brellas: [6000, 6001, 6005, 6010, 6011, 6012, 6020, 6021, 6022, 6030, 6031],
|
||||
stringers: [7010, 7011, 7012, 7015, 7020, 7021, 7022, 7030, 7031],
|
||||
splatanas: [8000, 8001, 8002, 8005, 8010, 8011, 8012, 8020, 8021],
|
||||
};
|
||||
|
||||
const MILESTONES_DESC = [4000, 3500, 3200, 3000];
|
||||
const OWNER_INSERT_BATCH_SIZE = 1000;
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const placeholderIds = (
|
||||
await trx
|
||||
.selectFrom("Trophy")
|
||||
.select("id")
|
||||
.where("code", "like", "xp-%")
|
||||
.execute()
|
||||
).map((row) => row.id);
|
||||
|
||||
if (placeholderIds.length > 0) {
|
||||
await trx
|
||||
.deleteFrom("SpecialTrophyOwner")
|
||||
.where("trophyId", "in", placeholderIds)
|
||||
.execute();
|
||||
await trx
|
||||
.deleteFrom("Trophy")
|
||||
.where("id", "in", placeholderIds)
|
||||
.execute();
|
||||
}
|
||||
|
||||
const trophies = await trx
|
||||
.insertInto("Trophy")
|
||||
.values(
|
||||
Object.keys(WEAPON_IDS_BY_CATEGORY).flatMap((category) =>
|
||||
MILESTONES_DESC.toReversed().map((milestone) => ({
|
||||
name: `${milestone} X Power ${category.charAt(0).toUpperCase()}${category.slice(1)}`,
|
||||
model: "",
|
||||
code: `xp-${category}-${milestone}`,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.returning(["id", "code"])
|
||||
.execute();
|
||||
const trophyIdByCode = new Map(
|
||||
trophies.map((trophy) => [trophy.code, trophy.id]),
|
||||
);
|
||||
|
||||
const categoryByWeaponId = new Map(
|
||||
Object.entries(WEAPON_IDS_BY_CATEGORY).flatMap(([category, weaponIds]) =>
|
||||
weaponIds.map((weaponId) => [weaponId, category] as const),
|
||||
),
|
||||
);
|
||||
|
||||
const peaks = await trx
|
||||
.selectFrom("XRankPlacement")
|
||||
.innerJoin(
|
||||
"SplatoonPlayer",
|
||||
"SplatoonPlayer.id",
|
||||
"XRankPlacement.playerId",
|
||||
)
|
||||
.select(({ fn }) => [
|
||||
"SplatoonPlayer.userId",
|
||||
"XRankPlacement.weaponSplId",
|
||||
fn.max("XRankPlacement.power").as("power"),
|
||||
])
|
||||
.where("SplatoonPlayer.userId", "is not", null)
|
||||
.groupBy(["SplatoonPlayer.userId", "XRankPlacement.weaponSplId"])
|
||||
.execute();
|
||||
|
||||
const peakByUserCategory = new Map<
|
||||
string,
|
||||
{ userId: number; category: string; power: number }
|
||||
>();
|
||||
for (const { userId, weaponSplId, power } of peaks) {
|
||||
const category = categoryByWeaponId.get(weaponSplId);
|
||||
if (!category) continue;
|
||||
|
||||
const key = `${userId}-${category}`;
|
||||
const existing = peakByUserCategory.get(key);
|
||||
if (!existing || power > existing.power) {
|
||||
peakByUserCategory.set(key, { userId, category, power });
|
||||
}
|
||||
}
|
||||
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
const owners = [...peakByUserCategory.values()].flatMap(
|
||||
({ userId, category, power }) => {
|
||||
const milestone = MILESTONES_DESC.find((value) => power >= value);
|
||||
if (!milestone) return [];
|
||||
|
||||
const trophyId = trophyIdByCode.get(`xp-${category}-${milestone}`);
|
||||
return trophyId ? [{ trophyId, userId, createdAt }] : [];
|
||||
},
|
||||
);
|
||||
|
||||
for (let i = 0; i < owners.length; i += OWNER_INSERT_BATCH_SIZE) {
|
||||
await trx
|
||||
.insertInto("SpecialTrophyOwner")
|
||||
.values(owners.slice(i, i + OWNER_INSERT_BATCH_SIZE))
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -88,7 +88,7 @@
|
||||
"nprogress": "0.2.0",
|
||||
"openskill": "5.0.1",
|
||||
"p-limit": "7.3.2",
|
||||
"picocad2-web": "2.0.3",
|
||||
"picocad2-web": "2.0.4",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -115,8 +115,8 @@ importers:
|
||||
specifier: 7.3.2
|
||||
version: 7.3.2
|
||||
picocad2-web:
|
||||
specifier: 2.0.3
|
||||
version: 2.0.3
|
||||
specifier: 2.0.4
|
||||
version: 2.0.4
|
||||
qrcode.react:
|
||||
specifier: 4.2.0
|
||||
version: 4.2.0(react@19.3.0)
|
||||
@@ -3694,8 +3694,8 @@ packages:
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
picocad2-web@2.0.3:
|
||||
resolution: {integrity: sha512-IaUTXiNwH1Ji4U5J57AiHm+unKmKEwV3UH9ab47qqlAcDRSThdt3mE7uzKnH/J0MXhuSRU9lfAdh6nuRHA+b9Q==}
|
||||
picocad2-web@2.0.4:
|
||||
resolution: {integrity: sha512-HhFMM5PLfQyDb7rSTkfNZ2/2sqS54JqhYwJFHFMnZnvkvvd2DaAeu9Xl8k5t4gH/EcU+ks6ykhFudL6jRdkpdw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
@@ -7887,7 +7887,7 @@ snapshots:
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
picocad2-web@2.0.3:
|
||||
picocad2-web@2.0.4:
|
||||
dependencies:
|
||||
gl-matrix: 3.4.4
|
||||
twgl.js: 7.0.0
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as v from "valibot";
|
||||
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
|
||||
import * as BuildRepository from "~/features/builds/BuildRepository.server";
|
||||
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { invariant } from "~/utils/invariant";
|
||||
@@ -53,6 +54,7 @@ async function main() {
|
||||
await XRankPlacementRepository.insertMany(placements);
|
||||
await XRankPlacementRepository.refreshAllPeakXp();
|
||||
await BadgeRepository.syncXPBadges();
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
await BuildRepository.recalculateAllSortValues();
|
||||
await XRankPlacementRepository.refreshTenStarWeapons();
|
||||
logger.info(`done reading in ${placements.length} placements`);
|
||||
|
||||
Reference in New Issue
Block a user