mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Kysely for builds queries (#2578)
This commit is contained in:
parent
90fd7a22a7
commit
d6cf687a7e
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]],
|
||||
];
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
25
migrations/100-build-indexes.js
Normal file
25
migrations/100-build-indexes.js
Normal 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();
|
||||
})();
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
|
||||
|
|
|
|||
26
scripts/recalculate-build-data.ts
Normal file
26
scripts/recalculate-build-data.ts
Normal 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");
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user