(null);
@@ -429,30 +450,10 @@ function ModelField({
{error ? {error} : null}
{preview.compressedModel ? (
-
- {(["light", "dark"] as const).map((theme) => (
-
-
- {t(`trophies:new.form.preview.${theme}`)}
-
-
-
-
- ))}
-
+
) : null}
@@ -460,6 +461,35 @@ function ModelField({
);
}
+function TrophyThemePreviews({
+ model,
+ onRenderStats,
+}: {
+ model: string;
+ onRenderStats: (stats: RenderStats) => void;
+}) {
+ const { t } = useTranslation(["trophies"]);
+
+ return (
+
+ {(["light", "dark"] as const).map((theme) => (
+
+
+ {t(`trophies:new.form.preview.${theme}`)}
+
+
+
+
+ ))}
+
+ );
+}
+
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
>
-
+
+ {analysis ? (
+
+ ) : null}
@@ -778,48 +819,11 @@ function TrophyListRow({
{analysis ? (
-
- TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
- })}
- >
- {t("trophies:new.specs.stats.drawCalls", {
- value: drawCalls,
- })}
-
- TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
- })}
- >
- {t("trophies:new.specs.stats.polys", {
- value: polyCount,
- })}
-
- TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
- })}
- >
- {t("trophies:new.specs.stats.effects", {
- value: analysis.effectsCount,
- })}
-
- {!analysis.cameraTargetCentered ? (
-
- {t("trophies:new.specs.stats.cameraTargetOff")}
-
- ) : null}
- {!analysis.backgroundIsAlpha ? (
-
- {t("trophies:new.specs.stats.backgroundNotAlpha")}
-
- ) : null}
-
+
) : null}
{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 (
+
+ TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
+ })}
+ >
+ {t("trophies:new.specs.stats.drawCalls", {
+ value: drawCalls,
+ })}
+
+ TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
+ })}
+ >
+ {t("trophies:new.specs.stats.polys", {
+ value: polyCount,
+ })}
+
+ TROPHY_MODEL_RECOMMENDED_MAX_EFFECTS,
+ })}
+ >
+ {t("trophies:new.specs.stats.effects", {
+ value: analysis.effectsCount,
+ })}
+
+ {!analysis.cameraTargetCentered ? (
+
+ {t("trophies:new.specs.stats.cameraTargetOff")}
+
+ ) : null}
+ {!analysis.backgroundIsAlpha ? (
+
+ {t("trophies:new.specs.stats.backgroundNotAlpha")}
+
+ ) : null}
+
+ );
+}
+
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(() => {
diff --git a/app/features/trophies/routes/trophies.tsx b/app/features/trophies/routes/trophies.tsx
index ffad6da75..c15bb907b 100644
--- a/app/features/trophies/routes/trophies.tsx
+++ b/app/features/trophies/routes/trophies.tsx
@@ -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() {
+
{t("trophies:lookingForBadges")}{" "}
{t("trophies:viewBadges")}
@@ -104,3 +108,58 @@ export default function TrophiesPage() {
);
}
+
+function XpTrophiesSection({ searchValue }: { searchValue: string }) {
+ const { t } = useTranslation(["trophies", "common"]);
+ const data = useLoaderData();
+
+ 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 (
+
+ {t("trophies:xp.title")}
+
+
+ {rows.map((row) => {
+ const category = XpTrophy.parseCode(row[0].trophy.code)?.category;
+ if (!category) return null;
+
+ return (
+
+ {row.map(({ trophy, i }) => (
+
+ = visibleCount}
+ />
+
+ ))}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/app/features/trophies/trophies-constants.ts b/app/features/trophies/trophies-constants.ts
index 3bbc7652c..b38f0b018 100644
--- a/app/features/trophies/trophies-constants.ts
+++ b/app/features/trophies/trophies-constants.ts
@@ -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-";
diff --git a/app/features/trophies/trophies-schemas.ts b/app/features/trophies/trophies-schemas.ts
index a23c19af3..9168db4c7 100644
--- a/app/features/trophies/trophies-schemas.ts
+++ b/app/features/trophies/trophies-schemas.ts
@@ -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),
+ }),
]);
diff --git a/app/features/trophies/trophies-utils.test.ts b/app/features/trophies/trophies-utils.test.ts
index 36539f5ba..65fa1b1b0 100644
--- a/app/features/trophies/trophies-utils.test.ts
+++ b/app/features/trophies/trophies-utils.test.ts
@@ -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()),
- 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);
diff --git a/app/features/trophies/trophies-utils.ts b/app/features/trophies/trophies-utils.ts
index 6aa867e64..243bd5187 100644
--- a/app/features/trophies/trophies-utils.ts
+++ b/app/features/trophies/trophies-utils.ts
@@ -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 | null = null;
+const xpTrophyMasterListeners = new Set<() => void>();
+const xpTrophyStates = new Map();
-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 } | 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 } | 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 } | null) {
@@ -44,12 +70,6 @@ export function canReviewTrophies(user?: { roles: Array } | null) {
return user.roles.includes("STAFF") || user.roles.includes("QA");
}
-export function canEditAnyTrophy(user?: { roles: Array } | null) {
- if (!user) return false;
-
- return user.roles.includes("ADMIN");
-}
-
export function hasUpcomingTournamentSoon(
upcomingTournamentAt: number | null | undefined,
) {
diff --git a/app/features/user-page/core/widgets/portfolio-loaders.server.ts b/app/features/user-page/core/widgets/portfolio-loaders.server.ts
index b08044747..5bbefa89b 100644
--- a/app/features/user-page/core/widgets/portfolio-loaders.server.ts
+++ b/app/features/user-page/core/widgets/portfolio-loaders.server.ts
@@ -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 (
diff --git a/app/features/user-page/loaders/u.$identifier.edit.server.ts b/app/features/user-page/loaders/u.$identifier.edit.server.ts
index 5ecdcab9a..4eb19f9ea 100644
--- a/app/features/user-page/loaders/u.$identifier.edit.server.ts
+++ b/app/features/user-page/loaders/u.$identifier.edit.server.ts
@@ -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,
diff --git a/app/hooks/useDragToScroll.browser.test.ts b/app/hooks/useDragToScroll.browser.test.ts
index 1152bf3ff..5d255f7b5 100644
--- a/app/hooks/useDragToScroll.browser.test.ts
+++ b/app/hooks/useDragToScroll.browser.test.ts
@@ -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();
diff --git a/app/hooks/useDragToScroll.ts b/app/hooks/useDragToScroll.ts
index 1f1ee9ff1..ff61a27a0 100644
--- a/app/hooks/useDragToScroll.ts
+++ b/app/hooks/useDragToScroll.ts
@@ -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; }";
diff --git a/app/modules/patreon/updater.ts b/app/modules/patreon/updater.ts
index 5508b5a14..7408931dc 100644
--- a/app/modules/patreon/updater.ts
+++ b/app/modules/patreon/updater.ts
@@ -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 {
];
await UserRepository.updatePatronData(patronsWithMods);
- await TrophyRepository.syncSpecialTrophies();
}
const MAX_RETRIES = 10;
diff --git a/app/routes.ts b/app/routes.ts
index a5182df00..630fced1c 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -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"),
]),
diff --git a/app/styles/front.module.css b/app/styles/front.module.css
index 351a7d9f7..1dca88e47 100644
--- a/app/styles/front.module.css
+++ b/app/styles/front.module.css
@@ -140,6 +140,7 @@
margin-block-start: -6px;
margin-inline-start: -6px;
overflow-x: auto;
+ overflow-y: hidden;
}
.tournamentCardsViewAllCard {
diff --git a/app/utils/kysely.server.ts b/app/utils/kysely.server.ts
index 589b97550..6d1b4e856 100644
--- a/app/utils/kysely.server.ts
+++ b/app/utils/kysely.server.ts
@@ -266,6 +266,15 @@ export function calendarEventStartTime(
.whereRef("CalendarEventDate.eventId", "=", "CalendarEvent.id");
}
+export function calendarEventNameMatchesSeries(substringMatches: string[]) {
+ return (eb: ExpressionBuilder) =>
+ 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,
diff --git a/app/utils/urls.ts b/app/utils/urls.ts
index a32fffe42..d80804c60 100644
--- a/app/utils/urls.ts
+++ b/app/utils/urls.ts
@@ -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"];
diff --git a/e2e/pages/trophies/new-trophy-page.ts b/e2e/pages/trophies/new-trophy-page.ts
index 965de65c6..1f47bcfd5 100644
--- a/e2e/pages/trophies/new-trophy-page.ts
+++ b/e2e/pages/trophies/new-trophy-page.ts
@@ -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(),
+ );
+ }
+}
diff --git a/e2e/pages/trophies/trophies-page.ts b/e2e/pages/trophies/trophies-page.ts
index 04352fac4..6e77f7ae8 100644
--- a/e2e/pages/trophies/trophies-page.ts
+++ b/e2e/pages/trophies/trophies-page.ts
@@ -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}']`);
}
diff --git a/e2e/pages/user/user-page.ts b/e2e/pages/user/user-page.ts
index 736d13ebc..fc5a1d1e5 100644
--- a/e2e/pages/user/user-page.ts
+++ b/e2e/pages/user/user-page.ts
@@ -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 });
}
diff --git a/e2e/trophies.spec.ts b/e2e/trophies.spec.ts
index 6ddd88c49..9e6848d26 100644
--- a/e2e/trophies.spec.ts
+++ b/e2e/trophies.spec.ts
@@ -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))],
diff --git a/locales/da/trophies.json b/locales/da/trophies.json
index eb513dd24..eb90708cb 100644
--- a/locales/da/trophies.json
+++ b/locales/da/trophies.json
@@ -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": "",
diff --git a/locales/de/trophies.json b/locales/de/trophies.json
index eb513dd24..eb90708cb 100644
--- a/locales/de/trophies.json
+++ b/locales/de/trophies.json
@@ -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": "",
diff --git a/locales/en/trophies.json b/locales/en/trophies.json
index 26005b172..a3f2079a3 100644
--- a/locales/en/trophies.json
+++ b/locales/en/trophies.json
@@ -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 server1>.",
"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",
diff --git a/locales/es-ES/trophies.json b/locales/es-ES/trophies.json
index 974c95279..f110ffc2f 100644
--- a/locales/es-ES/trophies.json
+++ b/locales/es-ES/trophies.json
@@ -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 Discord1>.",
"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",
diff --git a/locales/es-US/trophies.json b/locales/es-US/trophies.json
index e114eb62e..db2fd9e89 100644
--- a/locales/es-US/trophies.json
+++ b/locales/es-US/trophies.json
@@ -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 Discord1>.",
"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",
diff --git a/locales/fr-CA/trophies.json b/locales/fr-CA/trophies.json
index eb513dd24..41a33b90c 100644
--- a/locales/fr-CA/trophies.json
+++ b/locales/fr-CA/trophies.json
@@ -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": "",
diff --git a/locales/fr-EU/trophies.json b/locales/fr-EU/trophies.json
index eb513dd24..41a33b90c 100644
--- a/locales/fr-EU/trophies.json
+++ b/locales/fr-EU/trophies.json
@@ -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": "",
diff --git a/locales/he/trophies.json b/locales/he/trophies.json
index eb513dd24..1ec91f766 100644
--- a/locales/he/trophies.json
+++ b/locales/he/trophies.json
@@ -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": "",
diff --git a/locales/it/trophies.json b/locales/it/trophies.json
index eb513dd24..41a33b90c 100644
--- a/locales/it/trophies.json
+++ b/locales/it/trophies.json
@@ -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": "",
diff --git a/locales/ja/trophies.json b/locales/ja/trophies.json
index eb513dd24..d2d09003b 100644
--- a/locales/ja/trophies.json
+++ b/locales/ja/trophies.json
@@ -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": "",
diff --git a/locales/ko/trophies.json b/locales/ko/trophies.json
index eb513dd24..d2d09003b 100644
--- a/locales/ko/trophies.json
+++ b/locales/ko/trophies.json
@@ -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": "",
diff --git a/locales/nl/trophies.json b/locales/nl/trophies.json
index eb513dd24..eb90708cb 100644
--- a/locales/nl/trophies.json
+++ b/locales/nl/trophies.json
@@ -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": "",
diff --git a/locales/pl/trophies.json b/locales/pl/trophies.json
index eb513dd24..4d598bbc1 100644
--- a/locales/pl/trophies.json
+++ b/locales/pl/trophies.json
@@ -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": "",
diff --git a/locales/pt-BR/trophies.json b/locales/pt-BR/trophies.json
index eb513dd24..41a33b90c 100644
--- a/locales/pt-BR/trophies.json
+++ b/locales/pt-BR/trophies.json
@@ -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": "",
diff --git a/locales/ru/trophies.json b/locales/ru/trophies.json
index eb513dd24..4d598bbc1 100644
--- a/locales/ru/trophies.json
+++ b/locales/ru/trophies.json
@@ -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": "",
diff --git a/locales/zh/trophies.json b/locales/zh/trophies.json
index eb513dd24..d2d09003b 100644
--- a/locales/zh/trophies.json
+++ b/locales/zh/trophies.json
@@ -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": "",
diff --git a/migrations/20260930203031-xp-trophies.ts b/migrations/20260930203031-xp-trophies.ts
new file mode 100644
index 000000000..527bf4eba
--- /dev/null
+++ b/migrations/20260930203031-xp-trophies.ts
@@ -0,0 +1,135 @@
+import type { Kysely } from "kysely";
+
+const WEAPON_IDS_BY_CATEGORY: Record = {
+ 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): Promise {
+ 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();
+ }
+ });
+}
diff --git a/package.json b/package.json
index c1e72aa7c..6f91bbb28 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f0c74cacc..ddd61595a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/scripts/placements/index.ts b/scripts/placements/index.ts
index a9b5ea408..cc03643d2 100644
--- a/scripts/placements/index.ts
+++ b/scripts/placements/index.ts
@@ -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`);