diff --git a/app/db/tables.ts b/app/db/tables.ts index 5a1982f4c..7a88254a5 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -429,6 +429,8 @@ export interface SplatoonPlayer { id: GeneratedAlways; splId: string; userId: number | null; + /** Players best XP across both divisions. Denormalized for performance. */ + peakXp: number | null; } export interface TaggedArt { diff --git a/app/features/api-public/routes/user.$identifier.ts b/app/features/api-public/routes/user.$identifier.ts index e6a29ae09..9f8d78d44 100644 --- a/app/features/api-public/routes/user.$identifier.ts +++ b/app/features/api-public/routes/user.$identifier.ts @@ -24,6 +24,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { await db .selectFrom("User") .leftJoin("PlusTier", "PlusTier.userId", "User.id") + .leftJoin("SplatoonPlayer", "SplatoonPlayer.userId", "User.id") .select(({ eb }) => [ "User.id", "User.country", @@ -55,17 +56,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { .groupBy(["BadgeOwner.badgeId", "BadgeOwner.userId"]) .whereRef("BadgeOwner.userId", "=", "User.id"), ).as("badges"), - jsonArrayFrom( - eb - .selectFrom("SplatoonPlayer") - .innerJoin( - "XRankPlacement", - "XRankPlacement.playerId", - "SplatoonPlayer.id", - ) - .select(["XRankPlacement.power"]) - .whereRef("SplatoonPlayer.userId", "=", "User.id"), - ).as("xRankPlacements"), + "SplatoonPlayer.peakXp", jsonArrayFrom( eb .selectFrom("TeamMemberWithSecondary") @@ -117,13 +108,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { tier: skill.tier, } : null, - peakXp: - user.xRankPlacements.length > 0 - ? user.xRankPlacements.reduce((acc, cur) => { - if (!cur.power) return acc; - return Math.max(acc, cur.power); - }, 0) - : null, + peakXp: user.peakXp, weaponPool: user.weapons.map((weapon) => ({ id: weapon.weaponSplId, name: t(`weapons:MAIN_${weapon.weaponSplId}`), diff --git a/app/features/badges/queries/syncXPBadges.server.ts b/app/features/badges/queries/syncXPBadges.server.ts index ac7a4f56f..502ea41dc 100644 --- a/app/features/badges/queries/syncXPBadges.server.ts +++ b/app/features/badges/queries/syncXPBadges.server.ts @@ -17,12 +17,11 @@ const deleteBadgeOwnerStm = sql.prepare(/* sql */ ` const userTopXPowersStm = sql.prepare(/* sql */ ` select "SplatoonPlayer"."userId", - max("XRankPlacement"."power") as "xPower" + "SplatoonPlayer"."peakXp" as "xPower" from "SplatoonPlayer" - left join "XRankPlacement" on "XRankPlacement"."playerId" = "SplatoonPlayer"."id" where "SplatoonPlayer"."userId" is not null - group by "SplatoonPlayer"."userId" + and "SplatoonPlayer"."peakXp" is not null `); const addXPBadgeStm = sql.prepare(/* sql */ ` diff --git a/app/features/top-search/XRankPlacementRepository.server.test.ts b/app/features/top-search/XRankPlacementRepository.server.test.ts new file mode 100644 index 000000000..cad95cd33 --- /dev/null +++ b/app/features/top-search/XRankPlacementRepository.server.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { db } from "~/db/sql"; +import { dbReset } from "~/utils/Test"; +import * as XRankPlacementRepository from "./XRankPlacementRepository.server"; + +let placementCounter = 0; + +const createSplatoonPlayer = async (splId: string) => { + const result = await db + .insertInto("SplatoonPlayer") + .values({ splId }) + .returning("id") + .executeTakeFirstOrThrow(); + + return result.id; +}; + +const createXRankPlacement = async ({ + playerId, + power, +}: { + playerId: number; + power: number; +}) => { + placementCounter++; + + await db + .insertInto("XRankPlacement") + .values({ + playerId, + power, + badges: "[]", + bannerSplId: 1, + mode: "SZ", + month: 1, + year: 2024, + name: "Test Player", + nameDiscriminator: "0000", + rank: placementCounter, + region: "WEST", + title: "Test", + weaponSplId: 0, + }) + .execute(); +}; + +describe("refreshAllPeakXp", () => { + beforeEach(() => { + placementCounter = 0; + dbReset(); + }); + + afterEach(() => { + dbReset(); + }); + + test("sets peakXp to max power for each player", async () => { + const player1Id = await createSplatoonPlayer("player1"); + const player2Id = await createSplatoonPlayer("player2"); + + await createXRankPlacement({ playerId: player1Id, power: 2500 }); + await createXRankPlacement({ playerId: player1Id, power: 2700 }); + await createXRankPlacement({ playerId: player1Id, power: 2600 }); + + await createXRankPlacement({ playerId: player2Id, power: 3000 }); + await createXRankPlacement({ playerId: player2Id, power: 2800 }); + + await XRankPlacementRepository.refreshAllPeakXp(); + + const players = await db + .selectFrom("SplatoonPlayer") + .select(["id", "peakXp"]) + .orderBy("id", "asc") + .execute(); + + expect(players[0].peakXp).toBe(2700); + expect(players[1].peakXp).toBe(3000); + }); + + test("sets peakXp to null for player with no placements", async () => { + const playerId = await createSplatoonPlayer("player1"); + + await XRankPlacementRepository.refreshAllPeakXp(); + + const player = await db + .selectFrom("SplatoonPlayer") + .select("peakXp") + .where("id", "=", playerId) + .executeTakeFirstOrThrow(); + + expect(player.peakXp).toBeNull(); + }); +}); diff --git a/app/features/top-search/XRankPlacementRepository.server.ts b/app/features/top-search/XRankPlacementRepository.server.ts index c2f171875..07f4173e9 100644 --- a/app/features/top-search/XRankPlacementRepository.server.ts +++ b/app/features/top-search/XRankPlacementRepository.server.ts @@ -67,3 +67,15 @@ export async function monthYears() { export type FindPlacement = InferResult< ReturnType >[number]; + +export async function refreshAllPeakXp() { + await db + .updateTable("SplatoonPlayer") + .set((eb) => ({ + peakXp: eb + .selectFrom("XRankPlacement") + .select((eb) => eb.fn.max("XRankPlacement.power").as("peakXp")) + .whereRef("XRankPlacement.playerId", "=", "SplatoonPlayer.id"), + })) + .execute(); +} diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 0d953ca80..dbc7a4dda 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 630e84835..36a3f93ce 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 0fccd06c2..f36a17f86 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index 0014c500e..268644ac4 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index ccc3b5135..809775ba1 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index 9e30b86c2..d03ff70b8 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index dc7c11a2c..5f574e19e 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index cbb887880..444be8105 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/migrations/115-splatoon-player-peak-xp.js b/migrations/115-splatoon-player-peak-xp.js new file mode 100644 index 000000000..1380d9d2b --- /dev/null +++ b/migrations/115-splatoon-player-peak-xp.js @@ -0,0 +1,21 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /*sql*/ ` + alter table "SplatoonPlayer" + add column "peakXp" real + `, + ).run(); + + db.prepare( + /*sql*/ ` + update "SplatoonPlayer" + set "peakXp" = ( + select max("XRankPlacement"."power") + from "XRankPlacement" + where "XRankPlacement"."playerId" = "SplatoonPlayer"."id" + ) + `, + ).run(); + })(); +} diff --git a/scripts/placements/index.ts b/scripts/placements/index.ts index 5a95bf5e2..f50ee4eca 100644 --- a/scripts/placements/index.ts +++ b/scripts/placements/index.ts @@ -4,6 +4,7 @@ import { sql } from "~/db/sql"; import type { Tables } from "~/db/tables"; import { syncXPBadges } from "~/features/badges/queries/syncXPBadges.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; +import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.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() { } addPlacements(placements); + await XRankPlacementRepository.refreshAllPeakXp(); syncXPBadges(); await BuildRepository.recalculateAllTop500(); logger.info(`done reading in ${placements.length} placements`);