Kysely for builds queries (#2578)

This commit is contained in:
Kalle 2025-10-19 11:57:53 +03:00 committed by GitHub
parent 90fd7a22a7
commit d6cf687a7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 415 additions and 522 deletions

View File

@ -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<BuildWeaponWithTop500Info>;
};
owner?: Pick<UserWithPlusTier, "discordId" | "username" | "plusTier">;
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 (
<div key={weaponSplId} className={styles.weapon}>
{isTop500 ? (
<div key={weapon.weaponSplId} className={styles.weapon}>
{weapon.isTop500 ? (
<Image
className={styles.top500}
path={navIconUrl("xsearch")}
alt=""
title={`Max X Power: ${maxPower} | Best Rank: ${minRank}`}
height={24}
width={24}
testId="top500-crown"
@ -271,9 +254,9 @@ function RoundWeaponImage({ weapon }: { weapon: BuildWeaponWithTop500Info }) {
) : null}
<Link to={weaponBuildPage(slug)}>
<Image
path={mainWeaponImageUrl(weaponSplId)}
alt={t(`weapons:MAIN_${weaponSplId}` as any)}
title={t(`weapons:MAIN_${weaponSplId}` as any)}
path={mainWeaponImageUrl(weapon.weaponSplId)}
alt={t(`weapons:MAIN_${weapon.weaponSplId}`)}
title={t(`weapons:MAIN_${weapon.weaponSplId}`)}
height={36}
width={36}
/>

View File

@ -124,11 +124,19 @@ export interface BuildAbility {
buildId: number;
gearType: GearType;
slotIndex: number;
/** 10 if main ability, 3 if sub */
abilityPoints: GeneratedAlways<number>;
}
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<DBBoolean>;
/** Plus tier or 4 if none. Denormalized for performance reasons. */
tier: Generated<number>;
/** Last time the build was updated. Denormalized for performance reasons. */
updatedAt: Generated<number>;
}
export type CalendarEventTag = keyof typeof tags;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<AbilitiesByWeapon> {
return (stm.all({ weaponSplId }) as any[]).map((row) => ({
abilities: JSON.parse(row.abilities),
}));
}

View File

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

View File

@ -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<Tables["BuildAbility"]["gearType"]> = [
@ -150,7 +126,7 @@ export async function createInTrx({
args: CreateArgs;
trx: Transaction<DB>;
}) {
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<number>("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<typeof abilityPointAverages>
>[number];
export type AbilitiesByWeapon = Awaited<
ReturnType<typeof popularAbilitiesByWeaponId>
>[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<DB, "Build">) {
return jsonArrayFrom(
eb
.selectFrom("BuildAbility")
.select([
"BuildAbility.gearType",
"BuildAbility.ability",
"BuildAbility.slotIndex",
])
.whereRef("BuildAbility.buildId", "=", "Build.id"),
).as("abilities");
}
function hasXRankPlacement(eb: ExpressionBuilder<DB, "BuildWeapon">) {
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();
}
});
}

View File

@ -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> = T & { id: string };

View File

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

View File

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

View File

@ -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<UserWithPlusTier, "discordId" | "username" | "plusTier">;
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<BuildsByWeaponIdRow>;
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<T>({
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<BuildWeaponWithTop500Info>
).sort((a, b) => a.weaponSplId - b.weaponSplId);
const abilities = dbAbilitiesToArrayOfArrays(
JSON.parse(rawAbilities) as Array<
Pick<Tables["BuildAbility"], "ability" | "gearType" | "slotIndex">
>,
);
return {
...row,
modes,
weapons,
abilities: sortAbilities(abilities),
unsortedAbilities: abilities,
};
}
const gearOrder: Array<Tables["BuildAbility"]["gearType"]> = [
"HEAD",
"CLOTHES",
"SHOES",
];
function dbAbilitiesToArrayOfArrays(
abilities: Array<
Pick<Tables["BuildAbility"], "ability" | "gearType" | "slotIndex">
>,
): 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]],
];
}

View File

@ -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<typeof loader> }) {
const user = useUser();
return (
<div className={styles.buildsContainer}>
{data.builds.map((build) => {
@ -169,9 +166,12 @@ export function BuildCards({ data }: { data: SerializeFrom<typeof loader> }) {
<BuildCard
key={build.id}
build={build}
owner={build}
owner={
build.owner
? { ...build.owner, plusTier: build.plusTier }
: undefined
}
canEdit={false}
withAbilitySorting={!user?.preferences.disableBuildAbilitySorting}
/>
);
})}

View File

@ -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<MainWeaponId>;
buildToEditId: Nullish<number>;
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);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<typeof loader>;
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,

View File

@ -103,14 +103,7 @@ export default function UserBuildsPage() {
{builds.length > 0 ? (
<div className={styles.buildsContainer}>
{builds.map((build) => (
<BuildCard
key={build.id}
build={build}
canEdit={isOwnPage}
withAbilitySorting={
!isOwnPage && !user?.preferences.disableBuildAbilitySorting
}
/>
<BuildCard key={build.id} build={build} canEdit={isOwnPage} />
))}
</div>
) : (

View File

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

Binary file not shown.

View File

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

View File

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

View File

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