diff --git a/app/components/BuildCard.tsx b/app/components/BuildCard.tsx index 30fda608e..fe38bb391 100644 --- a/app/components/BuildCard.tsx +++ b/app/components/BuildCard.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import type { GearType, Tables, UserWithPlusTier } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; -import type { BuildWeaponWithTop500Info } from "~/features/builds/queries/buildsBy.server"; +import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types"; import { useIsMounted } from "~/hooks/useIsMounted"; import type { Ability as AbilityType, @@ -47,25 +47,14 @@ interface BuildProps { | "private" > & { abilities: BuildAbilitiesTuple; - unsortedAbilities: BuildAbilitiesTuple; modes: ModeShort[] | null; - weapons: Array<{ - weaponSplId: Tables["BuildWeapon"]["weaponSplId"]; - minRank: number | null; - maxPower: number | null; - }>; + weapons: Array; }; owner?: Pick; canEdit?: boolean; - withAbilitySorting?: boolean; } -export function BuildCard({ - build, - owner, - canEdit = false, - withAbilitySorting = true, -}: BuildProps) { +export function BuildCard({ build, owner, canEdit = false }: BuildProps) { const user = useUser(); const { t } = useTranslation(["weapons", "builds", "common", "game-misc"]); const { i18n } = useTranslation(); @@ -81,12 +70,9 @@ export function BuildCard({ updatedAt, modes, weapons, + abilities, } = build; - const abilities = withAbilitySorting - ? build.abilities - : build.unsortedAbilities; - const isNoGear = [headGearSplId, clothesGearSplId, shoesGearSplId].some( (id) => id === -1, ); @@ -246,24 +232,21 @@ export function BuildCard({ } function RoundWeaponImage({ weapon }: { weapon: BuildWeaponWithTop500Info }) { - const { weaponSplId, maxPower, minRank } = weapon; - const normalizedWeaponSplId = altWeaponIdToId.get(weaponSplId) ?? weaponSplId; + const normalizedWeaponSplId = + altWeaponIdToId.get(weapon.weaponSplId) ?? weapon.weaponSplId; const { t } = useTranslation(["weapons"]); const slug = mySlugify( t(`weapons:MAIN_${normalizedWeaponSplId}`, { lng: "en" }), ); - const isTop500 = typeof maxPower === "number" && typeof minRank === "number"; - return ( -
- {isTop500 ? ( +
+ {weapon.isTop500 ? ( {t(`weapons:MAIN_${weaponSplId}` diff --git a/app/db/tables.ts b/app/db/tables.ts index 3b41cc7a4..c25e94342 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -124,11 +124,19 @@ export interface BuildAbility { buildId: number; gearType: GearType; slotIndex: number; + /** 10 if main ability, 3 if sub */ + abilityPoints: GeneratedAlways; } export interface BuildWeapon { buildId: number; weaponSplId: MainWeaponId; + /** Has the owner of this build reached top 500 of X Rank with this weapon? Denormalized for performance reasons. */ + isTop500: Generated; + /** Plus tier or 4 if none. Denormalized for performance reasons. */ + tier: Generated; + /** Last time the build was updated. Denormalized for performance reasons. */ + updatedAt: Generated; } export type CalendarEventTag = keyof typeof tags; diff --git a/app/features/admin/AdminRepository.server.ts b/app/features/admin/AdminRepository.server.ts index c08bed3c8..a3e9016ef 100644 --- a/app/features/admin/AdminRepository.server.ts +++ b/app/features/admin/AdminRepository.server.ts @@ -1,6 +1,7 @@ import type { Transaction } from "kysely"; import { db, sql } from "~/db/sql"; import type { DB, Tables, TablesInsertable } from "~/db/tables"; +import * as BuildRepository from "~/features/builds/BuildRepository.server"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { syncXPBadges } from "../badges/queries/syncXPBadges.server"; @@ -218,6 +219,7 @@ export async function linkUserAndPlayer({ .execute(); syncXPBadges(); + await BuildRepository.recalculateAllTop500(); } export function forcePatron(args: { diff --git a/app/features/admin/actions/admin.server.ts b/app/features/admin/actions/admin.server.ts index e181245ad..4bc4887ad 100644 --- a/app/features/admin/actions/admin.server.ts +++ b/app/features/admin/actions/admin.server.ts @@ -4,6 +4,7 @@ import * as AdminRepository from "~/features/admin/AdminRepository.server"; import { makeArtist } from "~/features/art/queries/makeArtist.server"; import { requireUser } from "~/features/auth/core/user.server"; import { refreshBannedCache } from "~/features/ban/core/banned.server"; +import * as BuildRepository from "~/features/builds/BuildRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { requireRole } from "~/modules/permissions/guards.server"; import { @@ -57,6 +58,8 @@ export const action = async ({ request }: ActionFunctionArgs) => { await plusTiersFromVotingAndLeaderboard(), ); + await BuildRepository.recalculateAllTiers(); + message = "Plus tiers refreshed"; break; } diff --git a/app/features/admin/routes/admin.test.ts b/app/features/admin/routes/admin.test.ts index 513eb465f..9baf31897 100644 --- a/app/features/admin/routes/admin.test.ts +++ b/app/features/admin/routes/admin.test.ts @@ -417,10 +417,7 @@ describe("Account migration", () => { private: 0, }); - const buildsBefore = await BuildRepository.allByUserId({ - userId: 2, - showPrivate: false, - }); + const buildsBefore = await BuildRepository.allByUserId(2); expect(buildsBefore.length).toBe(1); @@ -430,10 +427,7 @@ describe("Account migration", () => { expect(oldUser).toBeNull(); for (const userId of [1, 2]) { - const buildsAfter = await BuildRepository.allByUserId({ - userId, - showPrivate: false, - }); + const buildsAfter = await BuildRepository.allByUserId(userId); expect(buildsAfter.length).toBe(0); } }); diff --git a/app/features/build-stats/build-stats-utils.ts b/app/features/build-stats/build-stats-utils.ts index 20d22af05..89c8e9c37 100644 --- a/app/features/build-stats/build-stats-utils.ts +++ b/app/features/build-stats/build-stats-utils.ts @@ -4,8 +4,10 @@ import invariant from "~/utils/invariant"; import { roundToNDecimalPlaces } from "~/utils/number"; import { MAX_AP } from "../build-analyzer/analyzer-constants"; import { isStackableAbility } from "../build-analyzer/core/utils"; -import type { AbilitiesByWeapon } from "./queries/abilitiesByWeaponId.server"; -import type { AverageAbilityPointsResult } from "./queries/averageAbilityPoints.server"; +import type { + AbilitiesByWeapon, + AverageAbilityPointsResult, +} from "../builds/BuildRepository.server"; const toBuildsCount = (counts: AverageAbilityPointsResult[]) => counts.reduce((acc, cur) => acc + cur.abilityPointsSum, 0) / MAX_AP; diff --git a/app/features/build-stats/loaders/builds.$slug.popular.server.ts b/app/features/build-stats/loaders/builds.$slug.popular.server.ts index 8f1bf9bf2..8be4b385a 100644 --- a/app/features/build-stats/loaders/builds.$slug.popular.server.ts +++ b/app/features/build-stats/loaders/builds.$slug.popular.server.ts @@ -1,11 +1,11 @@ import { cachified } from "@epic-web/cachified"; import type { LoaderFunctionArgs } from "@remix-run/node"; +import * as BuildRepository from "~/features/builds/BuildRepository.server"; import { i18next } from "~/modules/i18n/i18next.server"; import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server"; import { notFoundIfNullLike } from "~/utils/remix.server"; import { weaponNameSlugToId } from "~/utils/unslugify.server"; import { popularBuilds } from "../build-stats-utils"; -import { abilitiesByWeaponId } from "../queries/abilitiesByWeaponId.server"; export const loader = async ({ params, request }: LoaderFunctionArgs) => { const t = await i18next.getFixedT(request, ["builds", "weapons"]); @@ -19,7 +19,9 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { cache, ttl: ttl(IN_MILLISECONDS.ONE_HOUR), async getFreshValue() { - return popularBuilds(abilitiesByWeaponId(weaponId)); + return popularBuilds( + await BuildRepository.popularAbilitiesByWeaponId(weaponId), + ); }, }); diff --git a/app/features/build-stats/loaders/builds.$slug.stats.server.ts b/app/features/build-stats/loaders/builds.$slug.stats.server.ts index f74429f65..b228c9e6b 100644 --- a/app/features/build-stats/loaders/builds.$slug.stats.server.ts +++ b/app/features/build-stats/loaders/builds.$slug.stats.server.ts @@ -1,11 +1,11 @@ import { cachified } from "@epic-web/cachified"; import type { LoaderFunctionArgs } from "@remix-run/node"; +import * as BuildRepository from "~/features/builds/BuildRepository.server"; import { i18next } from "~/modules/i18n/i18next.server"; -import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server"; +import { cache } from "~/utils/cache.server"; import { notFoundIfNullLike } from "~/utils/remix.server"; import { weaponNameSlugToId } from "~/utils/unslugify.server"; import { abilityPointCountsToAverages } from "../build-stats-utils"; -import { averageAbilityPoints } from "../queries/averageAbilityPoints.server"; export const loader = async ({ params, request }: LoaderFunctionArgs) => { const t = await i18next.getFixedT(request, ["builds", "weapons"]); @@ -13,14 +13,21 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { const weaponName = t(`weapons:MAIN_${weaponId}`); + const allAbilities = await cachified({ + key: "all-ability-point-counts", + cache, + async getFreshValue() { + return BuildRepository.abilityPointAverages(); + }, + }); + const cachedStats = await cachified({ key: `build-stats-${weaponId}`, cache, - ttl: ttl(IN_MILLISECONDS.ONE_HOUR), async getFreshValue() { return abilityPointCountsToAverages({ - allAbilities: averageAbilityPoints(), - weaponAbilities: averageAbilityPoints(weaponId), + allAbilities, + weaponAbilities: await BuildRepository.abilityPointAverages(weaponId), }); }, }); diff --git a/app/features/build-stats/queries/abilitiesByWeaponId.server.ts b/app/features/build-stats/queries/abilitiesByWeaponId.server.ts deleted file mode 100644 index 132c3c2d6..000000000 --- a/app/features/build-stats/queries/abilitiesByWeaponId.server.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { sql } from "~/db/sql"; -import type { Ability, MainWeaponId } from "~/modules/in-game-lists/types"; - -// TODO: could consider removing private builds from this -const stm = sql.prepare(/* sql */ ` - with "GroupedAbilities" as ( - select - json_group_array( - json_object( - 'ability', - "BuildAbility"."ability", - 'abilityPoints', - "BuildAbility"."abilityPoints" - ) - ) as "abilities", - "Build"."ownerId" - from "BuildAbility" - left join "BuildWeapon" on "BuildWeapon"."buildId" = "BuildAbility"."buildId" - left join "Build" on "Build"."id" = "BuildWeapon"."buildId" - where "BuildWeapon"."weaponSplId" = @weaponSplId - group by "BuildAbility"."buildId" - ) - -- group by owner id so every user gets one build considered - select "abilities" - from "GroupedAbilities" - group by "ownerId" -`); - -export interface AbilitiesByWeapon { - abilities: Array<{ - ability: Ability; - abilityPoints: number; - }>; -} - -export function abilitiesByWeaponId( - weaponSplId: MainWeaponId, -): Array { - return (stm.all({ weaponSplId }) as any[]).map((row) => ({ - abilities: JSON.parse(row.abilities), - })); -} diff --git a/app/features/build-stats/queries/averageAbilityPoints.server.ts b/app/features/build-stats/queries/averageAbilityPoints.server.ts deleted file mode 100644 index 38b8d4246..000000000 --- a/app/features/build-stats/queries/averageAbilityPoints.server.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { sql } from "~/db/sql"; -import type { Ability, MainWeaponId } from "~/modules/in-game-lists/types"; - -// TODO: could consider removing private builds from this -const query = (includeWeaponId: boolean) => /* sql */ ` - select "BuildAbility"."ability", sum("BuildAbility"."abilityPoints") as "abilityPointsSum" - from "BuildAbility" - left join "BuildWeapon" on "BuildAbility"."buildId" = "BuildWeapon"."buildId" - ${ - includeWeaponId - ? /* sql */ `where "BuildWeapon"."weaponSplId" = @weaponSplId` - : "" - } - group by "BuildAbility"."ability" -`; - -const findByWeaponIdStm = sql.prepare(query(true)); -const findAllStm = sql.prepare(query(false)); - -export interface AverageAbilityPointsResult { - ability: Ability; - abilityPointsSum: number; -} - -export function averageAbilityPoints(weaponSplId?: MainWeaponId | null) { - const stm = typeof weaponSplId === "number" ? findByWeaponIdStm : findAllStm; - - return stm.all({ - weaponSplId: weaponSplId ?? null, - }) as Array; -} diff --git a/app/features/builds/BuildRepository.server.ts b/app/features/builds/BuildRepository.server.ts index 8ad494457..3b24b9fc5 100644 --- a/app/features/builds/BuildRepository.server.ts +++ b/app/features/builds/BuildRepository.server.ts @@ -1,47 +1,32 @@ -import type { Transaction } from "kysely"; -import { jsonArrayFrom } from "kysely/helpers/sqlite"; +import type { ExpressionBuilder, Transaction } from "kysely"; +import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import type { BuildWeapon, DB, Tables, TablesInsertable } from "~/db/tables"; import { modesShort } from "~/modules/in-game-lists/modes"; import type { + Ability, BuildAbilitiesTuple, + MainWeaponId, ModeShort, } from "~/modules/in-game-lists/types"; +import { + weaponIdHasAlts, + weaponIdToArrayWithAlts, +} from "~/modules/in-game-lists/weapon-ids"; import invariant from "~/utils/invariant"; +import { COMMON_USER_FIELDS } from "~/utils/kysely.server"; +import { sortAbilities } from "./core/ability-sorting.server"; -export async function allByUserId({ - userId, - showPrivate, -}: { - userId: number; - showPrivate: boolean; -}) { +export async function allByUserId( + userId: number, + options: { + showPrivate?: boolean; + sortAbilities?: boolean; + } = {}, +) { + const { showPrivate = false, sortAbilities: shouldSortAbilities = false } = + options; const rows = await db - .with("Top500Weapon", (db) => - db - .selectFrom("Build") - .innerJoin("BuildWeapon", "Build.id", "BuildWeapon.buildId") - .leftJoin("SplatoonPlayer", (join) => - join.on("SplatoonPlayer.userId", "=", userId), - ) - .leftJoin("XRankPlacement", (join) => - join - .onRef("XRankPlacement.playerId", "=", "SplatoonPlayer.id") - .onRef( - "XRankPlacement.weaponSplId", - "=", - "BuildWeapon.weaponSplId", - ), - ) - .select(({ fn }) => [ - "BuildWeapon.buildId", - "BuildWeapon.weaponSplId", - fn.min("XRankPlacement.rank").as("minRank"), - fn.max("XRankPlacement.power").as("maxPower"), - ]) - .where("Build.ownerId", "=", userId) - .groupBy(["BuildWeapon.buildId", "BuildWeapon.weaponSplId"]), - ) .selectFrom("Build") .select(({ eb }) => [ "Build.id", @@ -55,34 +40,25 @@ export async function allByUserId({ "Build.private", jsonArrayFrom( eb - .selectFrom("Top500Weapon") - .select([ - "Top500Weapon.weaponSplId", - "Top500Weapon.maxPower", - "Top500Weapon.minRank", - ]) - .orderBy("Top500Weapon.weaponSplId", "asc") - .whereRef("Top500Weapon.buildId", "=", "Build.id"), + .selectFrom("BuildWeapon") + .select(["BuildWeapon.weaponSplId", "BuildWeapon.isTop500"]) + .orderBy("BuildWeapon.weaponSplId", "asc") + .whereRef("BuildWeapon.buildId", "=", "Build.id"), ).as("weapons"), - jsonArrayFrom( - eb - .selectFrom("BuildAbility") - .select([ - "BuildAbility.gearType", - "BuildAbility.ability", - "BuildAbility.slotIndex", - ]) - .whereRef("BuildAbility.buildId", "=", "Build.id"), - ).as("abilities"), + withAbilities(eb), ]) .where("Build.ownerId", "=", userId) .$if(!showPrivate, (qb) => qb.where("Build.private", "=", 0)) .execute(); - return rows.map((row) => ({ - ...row, - abilities: dbAbilitiesToArrayOfArrays(row.abilities), - })); + return rows.map((row) => { + const abilities = dbAbilitiesToArrayOfArrays(row.abilities); + + return { + ...row, + abilities: shouldSortAbilities ? sortAbilities(abilities) : abilities, + }; + }); } const gearOrder: Array = [ @@ -150,7 +126,7 @@ export async function createInTrx({ args: CreateArgs; trx: Transaction; }) { - const { id: buildId } = await trx + const { id: buildId, updatedAt } = await trx .insertInto("Build") .values({ ownerId: args.ownerId, @@ -169,7 +145,7 @@ export async function createInTrx({ shoesGearSplId: args.shoesGearSplId, private: args.private, }) - .returning("id") + .returningAll() .executeTakeFirstOrThrow(); await trx @@ -182,6 +158,31 @@ export async function createInTrx({ ) .execute(); + await trx + .updateTable("BuildWeapon") + .set({ isTop500: 1 }) + .where("buildId", "=", buildId) + .where(hasXRankPlacement) + .execute(); + + const tier = + ( + await trx + .selectFrom("PlusTier") + .select("tier") + .where("userId", "=", args.ownerId) + .executeTakeFirst() + )?.tier ?? 4; + + await trx + .updateTable("BuildWeapon") + .set({ + tier, + updatedAt, + }) + .where("buildId", "=", buildId) + .execute(); + await trx .insertInto("BuildAbility") .values( @@ -211,3 +212,187 @@ export async function update(args: CreateArgs & { id: number }) { export function deleteById(id: number) { return db.deleteFrom("Build").where("id", "=", id).execute(); } + +export async function abilityPointAverages(weaponSplId?: MainWeaponId | null) { + return db + .selectFrom("BuildAbility") + .select(({ fn }) => [ + "BuildAbility.ability", + fn.sum("BuildAbility.abilityPoints").as("abilityPointsSum"), + ]) + .innerJoin("Build", "Build.id", "BuildAbility.buildId") + .$if(typeof weaponSplId === "number", (qb) => + qb + .innerJoin("BuildWeapon", "BuildAbility.buildId", "BuildWeapon.buildId") + .where("BuildWeapon.weaponSplId", "=", weaponSplId!), + ) + .groupBy("BuildAbility.ability") + .where("Build.private", "=", 0) + .execute(); +} + +export async function popularAbilitiesByWeaponId(weaponSplId: MainWeaponId) { + const result = await db + .selectFrom("BuildWeapon") + .innerJoin("Build", "Build.id", "BuildWeapon.buildId") + .select((eb) => [ + jsonArrayFrom( + eb + .selectFrom("BuildAbility") + .select(["BuildAbility.ability", "BuildAbility.abilityPoints"]) + .whereRef("BuildAbility.buildId", "=", "BuildWeapon.buildId"), + ).as("abilities"), + ]) + .where("BuildWeapon.weaponSplId", "=", weaponSplId) + .where("Build.private", "=", 0) + .groupBy("Build.ownerId") // consider only one build per user + .execute(); + + return result as Array<{ + abilities: Array<{ + ability: Ability; + abilityPoints: number; + }>; + }>; +} + +export type AverageAbilityPointsResult = Awaited< + ReturnType +>[number]; + +export type AbilitiesByWeapon = Awaited< + ReturnType +>[number]; + +export async function allByWeaponId( + weaponId: MainWeaponId, + options: { limit: number; sortAbilities?: boolean }, +) { + const { limit, sortAbilities: shouldSortAbilities = false } = options; + + let query = db + .selectFrom("BuildWeapon") + .innerJoin("Build", "Build.id", "BuildWeapon.buildId") + .leftJoin("PlusTier", "PlusTier.userId", "Build.ownerId") + .select(({ eb }) => [ + "Build.id", + "Build.title", + "Build.description", + "Build.modes", + "Build.headGearSplId", + "Build.clothesGearSplId", + "Build.shoesGearSplId", + "Build.updatedAt", + "Build.private", + "PlusTier.tier as plusTier", + withAbilities(eb), + jsonArrayFrom( + eb + .selectFrom("BuildWeapon as BuildWeaponInner") + .select(["BuildWeaponInner.weaponSplId", "BuildWeaponInner.isTop500"]) + .orderBy("BuildWeaponInner.weaponSplId", "asc") + .whereRef("BuildWeaponInner.buildId", "=", "Build.id"), + ).as("weapons"), + jsonObjectFrom( + eb + .selectFrom("User") + .select([...COMMON_USER_FIELDS]) + .whereRef("User.id", "=", "Build.ownerId"), + ).as("owner"), + ]) + .where("Build.private", "=", 0) + .where("BuildWeapon.weaponSplId", "in", weaponIdToArrayWithAlts(weaponId)) + .orderBy("BuildWeapon.tier", "asc") + .orderBy("BuildWeapon.isTop500", "desc") + .orderBy("BuildWeapon.updatedAt", "desc") + .limit(limit); + + if (weaponIdHasAlts(weaponId)) { + query = query.groupBy("BuildWeapon.buildId"); + } + + const rows = await query.execute(); + + return rows.map((row) => { + const abilities = dbAbilitiesToArrayOfArrays(row.abilities); + + return { + ...row, + abilities: shouldSortAbilities ? sortAbilities(abilities) : abilities, + }; + }); +} + +function withAbilities(eb: ExpressionBuilder) { + return jsonArrayFrom( + eb + .selectFrom("BuildAbility") + .select([ + "BuildAbility.gearType", + "BuildAbility.ability", + "BuildAbility.slotIndex", + ]) + .whereRef("BuildAbility.buildId", "=", "Build.id"), + ).as("abilities"); +} + +function hasXRankPlacement(eb: ExpressionBuilder) { + return eb.exists( + eb + .selectFrom("Build") + .select("BuildWeapon.buildId") + .leftJoin("SplatoonPlayer", "SplatoonPlayer.userId", "Build.ownerId") + .leftJoin( + "XRankPlacement", + "XRankPlacement.playerId", + "SplatoonPlayer.id", + ) + .whereRef("Build.id", "=", "BuildWeapon.buildId") + .whereRef("XRankPlacement.weaponSplId", "=", "BuildWeapon.weaponSplId"), + ); +} + +/** Recalculates which build weapons are top 500 based on latest X Rank placements data. */ +export async function recalculateAllTop500() { + await db.transaction().execute(async (trx) => { + await trx.updateTable("BuildWeapon").set({ isTop500: 0 }).execute(); + + await trx + .updateTable("BuildWeapon") + .set({ isTop500: 1 }) + .where(hasXRankPlacement) + .execute(); + }); +} + +export async function recalculateAllTiers() { + await db.transaction().execute(async (trx) => { + await trx + .updateTable("BuildWeapon") + .set({ + tier: 4, + }) + .execute(); + + for (const tier of [3, 2, 1]) { + const tierMembers = ( + await trx + .selectFrom("PlusTier") + .select("userId") + .where("tier", "=", tier) + .execute() + ).map((r) => r.userId); + + await trx + .updateTable("BuildWeapon") + .set({ tier }) + .where("BuildWeapon.buildId", "in", (eb) => + eb + .selectFrom("Build") + .select("Build.id") + .where("Build.ownerId", "in", tierMembers), + ) + .execute(); + } + }); +} diff --git a/app/features/builds/builds-types.ts b/app/features/builds/builds-types.ts index 07e18216c..3e32599da 100644 --- a/app/features/builds/builds-types.ts +++ b/app/features/builds/builds-types.ts @@ -1,4 +1,13 @@ -import type { Ability, ModeShort } from "~/modules/in-game-lists/types"; +import type { + Ability, + MainWeaponId, + ModeShort, +} from "~/modules/in-game-lists/types"; + +export interface BuildWeaponWithTop500Info { + weaponSplId: MainWeaponId; + isTop500: number; +} type WithId = T & { id: string }; diff --git a/app/features/builds/core/cached-builds.server.ts b/app/features/builds/core/cached-builds.server.ts deleted file mode 100644 index 000b7a89e..000000000 --- a/app/features/builds/core/cached-builds.server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import { cache, syncCached } from "~/utils/cache.server"; -import { BUILDS_PAGE_MAX_BUILDS } from "../builds-constants"; -import { buildsByWeaponId } from "../queries/buildsBy.server"; - -const buildsCacheKey = (weaponSplId: MainWeaponId) => `builds-${weaponSplId}`; - -export function cachedBuildsByWeaponId(weaponSplId: MainWeaponId) { - return syncCached(buildsCacheKey(weaponSplId), () => - buildsByWeaponId({ - weaponId: weaponSplId, - limit: BUILDS_PAGE_MAX_BUILDS, - }), - ); -} - -export function refreshBuildsCacheByWeaponSplIds(weaponSplIds: MainWeaponId[]) { - for (const weaponSplId of weaponSplIds) { - cache.delete(buildsCacheKey(weaponSplId)); - cachedBuildsByWeaponId(weaponSplId); - } -} diff --git a/app/features/builds/loaders/builds.$slug.server.ts b/app/features/builds/loaders/builds.$slug.server.ts index 54aa23422..44f977514 100644 --- a/app/features/builds/loaders/builds.$slug.server.ts +++ b/app/features/builds/loaders/builds.$slug.server.ts @@ -1,19 +1,21 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; +import { getUser } from "~/features/auth/core/user.server"; import { i18next } from "~/modules/i18n/i18next.server"; import { weaponIdIsNotAlt } from "~/modules/in-game-lists/weapon-ids"; import { logger } from "~/utils/logger"; import { weaponNameSlugToId } from "~/utils/unslugify.server"; import { mySlugify } from "~/utils/urls"; +import * as BuildRepository from "../BuildRepository.server"; import { BUILDS_PAGE_BATCH_SIZE, BUILDS_PAGE_MAX_BUILDS, FILTER_SEARCH_PARAM_KEY, } from "../builds-constants"; import { buildFiltersSearchParams } from "../builds-schemas.server"; -import { cachedBuildsByWeaponId } from "../core/cached-builds.server"; import { filterBuilds } from "../core/filter.server"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = await getUser(request); const t = await i18next.getFixedT(request, ["weapons", "common"], { lng: "en", }); @@ -33,10 +35,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const slug = mySlugify(t(`weapons:MAIN_${weaponId}`, { lng: "en" })); - const cachedBuilds = cachedBuildsByWeaponId(weaponId); - const rawFilters = url.searchParams.get(FILTER_SEARCH_PARAM_KEY); const filters = buildFiltersSearchParams.safeParse(rawFilters ?? "[]"); + const hasActiveFilters = + filters.success && filters.data && filters.data.length > 0; + + const builds = await BuildRepository.allByWeaponId(weaponId, { + limit: hasActiveFilters ? BUILDS_PAGE_MAX_BUILDS : limit, + sortAbilities: !user?.preferences?.disableBuildAbilitySorting, + }); if (!filters.success) { logger.error( @@ -45,14 +52,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { ); } - const filteredBuilds = - filters.success && filters.data && filters.data.length > 0 - ? filterBuilds({ - builds: cachedBuilds, - filters: filters.data, - count: limit, - }) - : cachedBuilds.slice(0, limit); + const filteredBuilds = hasActiveFilters + ? filterBuilds({ + builds, + filters: filters.data!, + count: limit, + }) + : builds; return { weaponId, diff --git a/app/features/builds/queries/buildsBy.server.ts b/app/features/builds/queries/buildsBy.server.ts deleted file mode 100644 index 3a7bb3730..000000000 --- a/app/features/builds/queries/buildsBy.server.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { sql } from "~/db/sql"; -import type { Tables, UserWithPlusTier } from "~/db/tables"; -import type { - BuildAbilitiesTuple, - MainWeaponId, - ModeShort, -} from "~/modules/in-game-lists/types"; -import { weaponIdToAltId } from "~/modules/in-game-lists/weapon-ids"; -import invariant from "~/utils/invariant"; -import { sortAbilities } from "../core/ability-sorting.server"; - -const buildsByWeaponIdStm = sql.prepare(/* sql */ ` -with "Top500Weapon" as ( - select - "BuildWeapon".*, - min("XRankPlacement"."rank") as "minRank", - max("XRankPlacement"."power") as "maxPower", - ( - ( - "BuildWeapon"."weaponSplId" = @weaponId - or "BuildWeapon"."weaponSplId" = @altWeaponId - or "BuildWeapon"."weaponSplId" = @altWeaponIdTwo - ) - and "XRankPlacement"."rank" is not null - ) as "relevant" - from - "BuildWeapon" - left join "Build" on "Build"."id" = "BuildWeapon"."buildId" - left join "SplatoonPlayer" on "SplatoonPlayer"."userId" = "Build"."ownerId" - left join "XRankPlacement" on "XRankPlacement"."playerId" = "SplatoonPlayer"."id" - and "XRankPlacement"."weaponSplId" = "BuildWeapon"."weaponSplId" - group by - "BuildWeapon"."buildId", - "BuildWeapon"."weaponSplId" -), -"BuildFiltered" as ( - select - "id", - "title", - "description", - "modes", - "headGearSplId", - "clothesGearSplId", - "shoesGearSplId", - "updatedAt", - "ownerId", - max("Top500Weapon"."relevant") as "isTop500" - from - "Build" - left join "Top500Weapon" on "Top500Weapon"."buildId" = "Build"."id" - where - ( - "Top500Weapon"."weaponSplId" = @weaponId - or "Top500Weapon"."weaponSplId" = @altWeaponId - ) - and "Build"."private" = 0 - group by - "Build"."id" -), -"BuildWithWeapon" as ( - select - "BuildFiltered".*, - json_group_array( - json_object( - 'weaponSplId', - "Top500Weapon"."weaponSplId", - 'maxPower', - "Top500Weapon"."maxPower", - 'minRank', - "Top500Weapon"."minRank" - ) - ) as "weapons" - from - "BuildFiltered" - left join "Top500Weapon" on "Top500Weapon"."buildId" = "BuildFiltered"."id" - group by - "BuildFiltered"."id" -) -select - "BuildWithWeapon".*, - "User"."discordId", - "User"."username", - "PlusTier"."tier" as "plusTier", - json_group_array( - json_object( - 'ability', - "BuildAbility"."ability", - 'gearType', - "BuildAbility"."gearType", - 'slotIndex', - "BuildAbility"."slotIndex" - ) - ) as "abilities" -from - "BuildWithWeapon" - left join "BuildAbility" on "BuildAbility"."buildId" = "BuildWithWeapon"."id" - left join "PlusTier" on "PlusTier"."userId" = "BuildWithWeapon"."ownerId" - left join "User" on "User"."id" = "BuildWithWeapon"."ownerId" -group by - "BuildWithWeapon"."id" -order by - case - when "PlusTier"."tier" is null then 4 - else "PlusTier"."tier" - end asc, - "BuildWithWeapon"."isTop500" desc, - "BuildWithWeapon"."updatedAt" desc -limit - @limit -`); - -type BuildsByWeaponIdRow = BuildsByUserRow & - Pick; - -export function buildsByWeaponId({ - weaponId, - limit, -}: { - weaponId: Tables["BuildWeapon"]["weaponSplId"]; - limit: number; -}) { - const [altWeaponId, altWeaponIdTwo] = (() => { - const alts = weaponIdToAltId.get(weaponId); - // default to impossible weapon id so we can always have same amount of placeholder values - if (!alts) return [-1, -1]; - if (typeof alts === "number") return [alts, -1]; - - invariant(alts.length === 2, "expected 2 alts"); - return alts; - })(); - - const rows = buildsByWeaponIdStm.all({ - weaponId, - altWeaponId, - altWeaponIdTwo, - limit, - }) as Array; - - return rows.map(augmentBuild); -} - -type BuildsByUserRow = Pick< - Tables["Build"], - | "id" - | "title" - | "description" - | "headGearSplId" - | "clothesGearSplId" - | "shoesGearSplId" - | "updatedAt" - | "private" -> & { - modes: string; - weapons: string; - abilities: string; -}; - -export interface BuildWeaponWithTop500Info { - weaponSplId: MainWeaponId; - minRank: number | null; - maxPower: number | null; -} - -function augmentBuild({ - weapons: rawWeapons, - modes: rawModes, - abilities: rawAbilities, - ...row -}: T & { modes: string; weapons: string; abilities: string }) { - const modes = rawModes ? (JSON.parse(rawModes) as ModeShort[]) : null; - const weapons = ( - JSON.parse(rawWeapons) as Array - ).sort((a, b) => a.weaponSplId - b.weaponSplId); - const abilities = dbAbilitiesToArrayOfArrays( - JSON.parse(rawAbilities) as Array< - Pick - >, - ); - - return { - ...row, - modes, - weapons, - abilities: sortAbilities(abilities), - unsortedAbilities: abilities, - }; -} - -const gearOrder: Array = [ - "HEAD", - "CLOTHES", - "SHOES", -]; -function dbAbilitiesToArrayOfArrays( - abilities: Array< - Pick - >, -): BuildAbilitiesTuple { - const sorted = abilities - .slice() - .sort((a, b) => { - if (a.gearType === b.gearType) return a.slotIndex - b.slotIndex; - - return gearOrder.indexOf(a.gearType) - gearOrder.indexOf(b.gearType); - }) - .map((a) => a.ability); - - invariant(sorted.length === 12, "expected 12 abilities"); - - return [ - [sorted[0], sorted[1], sorted[2], sorted[3]], - [sorted[4], sorted[5], sorted[6], sorted[7]], - [sorted[8], sorted[9], sorted[10], sorted[11]], - ]; -} diff --git a/app/features/builds/routes/builds.$slug.tsx b/app/features/builds/routes/builds.$slug.tsx index 2b8a0ffcc..c7c2d05db 100644 --- a/app/features/builds/routes/builds.$slug.tsx +++ b/app/features/builds/routes/builds.$slug.tsx @@ -18,7 +18,6 @@ import { FilterIcon } from "~/components/icons/Filter"; import { FireIcon } from "~/components/icons/Fire"; import { MapIcon } from "~/components/icons/Map"; import { Main } from "~/components/Main"; -import { useUser } from "~/features/auth/core/user"; import { safeJSONParse } from "~/utils/json"; import { isRevalidation, metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; @@ -160,8 +159,6 @@ export const handle: SendouRouteHandle = { }; export function BuildCards({ data }: { data: SerializeFrom }) { - const user = useUser(); - return (
{data.builds.map((build) => { @@ -169,9 +166,12 @@ export function BuildCards({ data }: { data: SerializeFrom }) { ); })} diff --git a/app/features/user-page/actions/u.$identifier.builds.new.server.ts b/app/features/user-page/actions/u.$identifier.builds.new.server.ts index 7f81d2f4a..a77fee2f3 100644 --- a/app/features/user-page/actions/u.$identifier.builds.new.server.ts +++ b/app/features/user-page/actions/u.$identifier.builds.new.server.ts @@ -1,25 +1,17 @@ import { type ActionFunction, redirect } from "@remix-run/node"; -import * as R from "remeda"; import { z } from "zod/v4"; import { requireUser } from "~/features/auth/core/user.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; import { BUILD } from "~/features/builds/builds-constants"; -import { refreshBuildsCacheByWeaponSplIds } from "~/features/builds/core/cached-builds.server"; -import type { BuildWeaponWithTop500Info } from "~/features/builds/queries/buildsBy.server"; import { clothesGearIds, headGearIds, shoesGearIds, } from "~/modules/in-game-lists/gear-ids"; import { modesShort } from "~/modules/in-game-lists/modes"; -import type { - BuildAbilitiesTuple, - MainWeaponId, -} from "~/modules/in-game-lists/types"; +import type { BuildAbilitiesTuple } from "~/modules/in-game-lists/types"; import { unJsonify } from "~/utils/kysely.server"; -import { logger } from "~/utils/logger"; import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; -import type { Nullish } from "~/utils/types"; import { userBuildsPage } from "~/utils/urls"; import { actualNumber, @@ -46,8 +38,7 @@ export const action: ActionFunction = async ({ request }) => { schema: newBuildActionSchema, }); - const usersBuilds = await BuildRepository.allByUserId({ - userId: user.id, + const usersBuilds = await BuildRepository.allByUserId(user.id, { showPrivate: true, }); @@ -80,16 +71,6 @@ export const action: ActionFunction = async ({ request }) => { await BuildRepository.create(commonArgs); } - try { - refreshCache({ - newWeaponSplIds: commonArgs.weaponSplIds, - oldBuilds: usersBuilds, - buildToEditId: data.buildToEditId, - }); - } catch (error) { - logger.warn("Error refreshing builds cache", error); - } - return redirect(userBuildsPage(user)); }; @@ -175,25 +156,3 @@ const newBuildActionSchema = z.object({ ]), ), }); - -function refreshCache({ - newWeaponSplIds, - oldBuilds, - buildToEditId, -}: { - newWeaponSplIds: Array; - buildToEditId: Nullish; - oldBuilds: Array<{ id: number; weapons: BuildWeaponWithTop500Info[] }>; -}) { - const oldBuildWeapons = - oldBuilds.find((build) => build.id === buildToEditId)?.weapons ?? []; - - const allWeaponSplIds = [ - ...newWeaponSplIds, - ...oldBuildWeapons.map(({ weaponSplId }) => weaponSplId), - ]; - - const dedupedWeaponSplIds = R.unique(allWeaponSplIds); - - refreshBuildsCacheByWeaponSplIds(dedupedWeaponSplIds); -} diff --git a/app/features/user-page/actions/u.$identifier.builds.server.ts b/app/features/user-page/actions/u.$identifier.builds.server.ts index a8dcbded0..168fd3c0e 100644 --- a/app/features/user-page/actions/u.$identifier.builds.server.ts +++ b/app/features/user-page/actions/u.$identifier.builds.server.ts @@ -3,9 +3,7 @@ import { z } from "zod/v4"; import { BUILD_SORT_IDENTIFIERS } from "~/db/tables"; import { requireUser } from "~/features/auth/core/user.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; -import { refreshBuildsCacheByWeaponSplIds } from "~/features/builds/core/cached-builds.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { logger } from "~/utils/logger"; import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import { userBuildsPage } from "~/utils/urls"; @@ -28,8 +26,7 @@ export const action: ActionFunction = async ({ request }) => { switch (data._action) { case "DELETE_BUILD": { - const usersBuilds = await BuildRepository.allByUserId({ - userId: user.id, + const usersBuilds = await BuildRepository.allByUserId(user.id, { showPrivate: true, }); @@ -41,14 +38,6 @@ export const action: ActionFunction = async ({ request }) => { await BuildRepository.deleteById(data.buildToDeleteId); - try { - refreshBuildsCacheByWeaponSplIds( - buildToDelete.weapons.map((weapon) => weapon.weaponSplId), - ); - } catch (error) { - logger.warn("Error refreshing builds cache", error); - } - break; } case "UPDATE_SORTING": { diff --git a/app/features/user-page/core/build-sorting.server.test.ts b/app/features/user-page/core/build-sorting.server.test.ts index 70b7c9206..12156fae6 100644 --- a/app/features/user-page/core/build-sorting.server.test.ts +++ b/app/features/user-page/core/build-sorting.server.test.ts @@ -25,7 +25,7 @@ const mockBuild = ( private: 0, title: "", updatedAt: databaseTimestampNow(), - weapons: [{ weaponSplId: 0, maxPower: null, minRank: null }], + weapons: [{ weaponSplId: 0, isTop500: 0 }], ...partialBuild, }; }; @@ -53,13 +53,13 @@ describe("sortBuilds()", () => { mockBuild({ id: 1 }), mockBuild({ id: 2, - weapons: [{ weaponSplId: 1, maxPower: 3000, minRank: 1 }], + weapons: [{ weaponSplId: 1, isTop500: 1 }], }), mockBuild({ id: 3, weapons: [ - { weaponSplId: 0, maxPower: null, minRank: null }, - { weaponSplId: 1, maxPower: 2900, minRank: 1 }, + { weaponSplId: 0, isTop500: 0 }, + { weaponSplId: 1, isTop500: 1 }, ], }), ]; @@ -111,15 +111,15 @@ describe("sortBuilds()", () => { const builds = [ mockBuild({ id: 1, - weapons: [{ weaponSplId: 1000, maxPower: null, minRank: null }], + weapons: [{ weaponSplId: 1000, isTop500: 0 }], }), mockBuild({ id: 2, - weapons: [{ weaponSplId: 10, maxPower: null, minRank: null }], + weapons: [{ weaponSplId: 10, isTop500: 0 }], }), mockBuild({ id: 3, - weapons: [{ weaponSplId: 1, maxPower: null, minRank: null }], + weapons: [{ weaponSplId: 1, isTop500: 0 }], }), ]; @@ -142,8 +142,7 @@ describe("sortBuilds()", () => { id, weapons: weaponIds.map((wepId) => ({ weaponSplId: wepId, - maxPower: null, - minRank: null, + isTop500: 0, })), }); }; @@ -223,17 +222,17 @@ describe("sortBuilds()", () => { const builds = [ mockBuild({ id: 1, - weapons: [{ weaponSplId: 1, maxPower: null, minRank: null }], + weapons: [{ weaponSplId: 1, isTop500: 0 }], }), mockBuild({ id: 2, - weapons: [{ weaponSplId: 10, maxPower: null, minRank: null }], + weapons: [{ weaponSplId: 10, isTop500: 0 }], }), mockBuild({ id: 3, weapons: [ - { weaponSplId: 1000, maxPower: null, minRank: null }, - { weaponSplId: 1, maxPower: null, minRank: null }, + { weaponSplId: 1000, isTop500: 0 }, + { weaponSplId: 1, isTop500: 0 }, ], }), ]; diff --git a/app/features/user-page/core/build-sorting.server.ts b/app/features/user-page/core/build-sorting.server.ts index b836aa739..b2ee5b8d6 100644 --- a/app/features/user-page/core/build-sorting.server.ts +++ b/app/features/user-page/core/build-sorting.server.ts @@ -45,11 +45,11 @@ export function sortBuilds({ return aLowestModeIdx - bLowestModeIdx; }, TOP_500: (a, b) => { - const aHas = a.weapons.some((wpn) => wpn.maxPower !== null); - const bHas = b.weapons.some((wpn) => wpn.maxPower !== null); + const aHasTop500 = a.weapons.some((wpn) => wpn.isTop500 === 1); + const bHasTop500 = b.weapons.some((wpn) => wpn.isTop500 === 1); - if (aHas && !bHas) return -1; - if (!aHas && bHas) return 1; + if (aHasTop500 && !bHasTop500) return -1; + if (!aHasTop500 && bHasTop500) return 1; return 0; }, diff --git a/app/features/user-page/loaders/u.$identifier.builds.new.server.ts b/app/features/user-page/loaders/u.$identifier.builds.new.server.ts index acf3ebe23..01e4aa7b6 100644 --- a/app/features/user-page/loaders/u.$identifier.builds.new.server.ts +++ b/app/features/user-page/loaders/u.$identifier.builds.new.server.ts @@ -17,8 +17,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { Object.fromEntries(url.searchParams), ); - const usersBuilds = await BuildRepository.allByUserId({ - userId: user.id, + const usersBuilds = await BuildRepository.allByUserId(user.id, { showPrivate: true, }); const buildToEdit = usersBuilds.find( diff --git a/app/features/user-page/loaders/u.$identifier.builds.server.ts b/app/features/user-page/loaders/u.$identifier.builds.server.ts index 453757017..ddd63511a 100644 --- a/app/features/user-page/loaders/u.$identifier.builds.server.ts +++ b/app/features/user-page/loaders/u.$identifier.builds.server.ts @@ -1,7 +1,6 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; -import { getUserId } from "~/features/auth/core/user.server"; +import { getUser } from "~/features/auth/core/user.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; -import { sortAbilities } from "~/features/builds/core/ability-sorting.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import type { SerializeFrom } from "~/utils/remix"; @@ -12,15 +11,17 @@ import { userParamsSchema } from "../user-page-schemas"; export type UserBuildsPageData = SerializeFrom; export const loader = async ({ params, request }: LoaderFunctionArgs) => { - const loggedInUser = await getUserId(request); + const loggedInUser = await getUser(request); const { identifier } = userParamsSchema.parse(params); const user = notFoundIfFalsy( await UserRepository.identifierToBuildFields(identifier), ); - const builds = await BuildRepository.allByUserId({ - userId: user.id, + const builds = await BuildRepository.allByUserId(user.id, { showPrivate: loggedInUser?.id === user.id, + sortAbilities: + loggedInUser?.id !== user.id && + !loggedInUser?.preferences?.disableBuildAbilitySorting, }); if (builds.length === 0 && loggedInUser?.id !== user.id) { @@ -31,11 +32,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { builds, buildSorting: user.buildSorting, weaponPool: user.weapons, - }).map((build) => ({ - ...build, - abilities: sortAbilities(build.abilities), - unsortedAbilities: build.abilities, - })); + }); return privatelyCachedJson({ buildSorting: user.buildSorting, diff --git a/app/features/user-page/routes/u.$identifier.builds.tsx b/app/features/user-page/routes/u.$identifier.builds.tsx index 6ea33590e..3718b948e 100644 --- a/app/features/user-page/routes/u.$identifier.builds.tsx +++ b/app/features/user-page/routes/u.$identifier.builds.tsx @@ -103,14 +103,7 @@ export default function UserBuildsPage() { {builds.length > 0 ? (
{builds.map((build) => ( - + ))}
) : ( diff --git a/app/modules/in-game-lists/weapon-ids.ts b/app/modules/in-game-lists/weapon-ids.ts index 9068cd6a1..45f2a6724 100644 --- a/app/modules/in-game-lists/weapon-ids.ts +++ b/app/modules/in-game-lists/weapon-ids.ts @@ -133,6 +133,19 @@ const altWeaponIds = new Set(altWeaponIdToId.keys()); export const weaponIdIsNotAlt = (weaponId: MainWeaponId) => !altWeaponIds.has(weaponId); +/** Returns true if the weapon ID has alternate skins + * + * * @example + * // Splattershot, Hero Shot, Order Shot... + * weaponIdHasAlts(40); // -> true + * weaponIdHasAlts(41); // -> true + * + * // Sploosh-o-matic has no alt skins + * weaponIdHasAlts(0); // -> false + */ +export const weaponIdHasAlts = (weaponId: MainWeaponId) => + weaponIdToAltId.has(weaponId) || altWeaponIdToId.has(weaponId); + export const SPLAT_BOMB_ID = 0; export const SUCTION_BOMB_ID = 1; export const BURST_BOMB_ID = 2; diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 6f9dcc5f4..c7296a873 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/migrations/100-build-indexes.js b/migrations/100-build-indexes.js new file mode 100644 index 000000000..ecd25ed71 --- /dev/null +++ b/migrations/100-build-indexes.js @@ -0,0 +1,25 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ `alter table "BuildWeapon" add column "isTop500" integer not null default 0`, + ).run(); + + db.prepare( + /* sql */ `alter table "BuildWeapon" add column "tier" integer not null default 4`, + ).run(); + + db.prepare( + /* sql */ `alter table "BuildWeapon" add column "updatedAt" integer default 1760608251`, + ).run(); + + // speeds up resolving /popular builds + db.prepare( + /* sql */ `create index build_weapon_weapon_spl_id_build_id on "BuildWeapon"("weaponSplId", "buildId")`, + ).run(); + + // speeds up weapon builds list page + db.prepare( + /* sql */ `create index idx_buildweapon_lookup on "BuildWeapon"("weaponSplId", "tier" asc, "isTop500" desc, "updatedAt" desc)`, + ).run(); + })(); +} diff --git a/scripts/placements/index.ts b/scripts/placements/index.ts index e81be33fd..5a95bf5e2 100644 --- a/scripts/placements/index.ts +++ b/scripts/placements/index.ts @@ -3,6 +3,7 @@ import "dotenv/config"; 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 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); syncXPBadges(); + await BuildRepository.recalculateAllTop500(); logger.info(`done reading in ${placements.length} placements`); } diff --git a/scripts/recalculate-build-data.ts b/scripts/recalculate-build-data.ts new file mode 100644 index 000000000..d3e2fd245 --- /dev/null +++ b/scripts/recalculate-build-data.ts @@ -0,0 +1,26 @@ +import "dotenv/config"; +import { db } from "~/db/sql"; +import * as BuildRepository from "~/features/builds/BuildRepository.server"; +import { logger } from "~/utils/logger"; + +void main(); + +async function main() { + await BuildRepository.recalculateAllTiers(); + logger.info("Recalculated all tiers"); + + await BuildRepository.recalculateAllTop500(); + logger.info("Recalculated all top 500"); + + await db + .updateTable("BuildWeapon") + .set({ + updatedAt: (eb) => + eb + .selectFrom("Build") + .select("Build.updatedAt") + .whereRef("Build.id", "=", "BuildWeapon.buildId"), + }) + .execute(); + logger.info("Recalculated BuildWeapon updatedAt"); +}