Trophy release (#3426)
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
hfcRed
2026-10-01 11:45:34 -04:00
committed by GitHub
parent b2057d2c97
commit 390db064a3
93 changed files with 14254 additions and 700 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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;
});

View File

@@ -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;

View File

@@ -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:

View File

@@ -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>,

View File

@@ -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({

View File

@@ -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[];

View File

@@ -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,

View File

@@ -476,8 +476,6 @@ export async function forcePatron(args: {
})
.where("User.id", "=", args.id)
.execute();
await TrophyRepository.syncSpecialTrophies();
}
export async function findAllBannedUsers() {

View File

@@ -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,
);

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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
? {

View File

@@ -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,

View File

@@ -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,
};
}

View File

@@ -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 (

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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(

View File

@@ -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<

View File

@@ -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 },
]);
});
});
});

View File

@@ -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]),
};
}

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

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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} />

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

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

View File

@@ -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;
}

View File

@@ -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}>

View File

@@ -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}>

View File

@@ -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);
}

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

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

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

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

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

View File

@@ -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];

File diff suppressed because one or more lines are too long

View File

@@ -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,
};
};

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

View File

@@ -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,
};
};

View File

@@ -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() {

View File

@@ -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),
};
};

View File

@@ -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);
}

View File

@@ -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");
});
});

View File

@@ -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 ?? [] };
};

View File

@@ -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,

View File

@@ -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>

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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);
});
});

View File

@@ -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(() => {

View File

@@ -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>
);
}

View File

@@ -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-";

View File

@@ -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),
}),
]);

View File

@@ -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);

View File

@@ -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,
) {

View File

@@ -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 (

View File

@@ -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,

View File

@@ -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();

View File

@@ -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; }";

View File

@@ -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;

View File

@@ -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"),
]),

View File

@@ -140,6 +140,7 @@
margin-block-start: -6px;
margin-inline-start: -6px;
overflow-x: auto;
overflow-y: hidden;
}
.tournamentCardsViewAllCard {

View File

@@ -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">,

View File

@@ -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"];

View File

@@ -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(),
);
}
}

View File

@@ -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}']`);
}

View File

@@ -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 });
}

View File

@@ -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))],

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

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

View File

@@ -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
View File

@@ -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

View File

@@ -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`);