mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-22 02:14:41 -05:00
466 lines
12 KiB
TypeScript
466 lines
12 KiB
TypeScript
import type { ExpressionBuilder, Transaction } from "kysely";
|
|
import { jsonArrayFrom } 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 { weaponIdToArrayWithAlts } from "~/modules/in-game-lists/weapon-ids";
|
|
import { LimitReachedError } from "~/utils/errors";
|
|
import invariant from "~/utils/invariant";
|
|
import { commonUserJsonObject } from "~/utils/kysely.server";
|
|
import { BUILD } from "./builds-constants";
|
|
import { sortAbilities } from "./core/ability-sorting.server";
|
|
|
|
export async function allByUserId(
|
|
userId: number,
|
|
options: {
|
|
showPrivate?: boolean;
|
|
sortAbilities?: boolean;
|
|
limit?: number;
|
|
} = {},
|
|
) {
|
|
const {
|
|
showPrivate = false,
|
|
sortAbilities: shouldSortAbilities = false,
|
|
limit,
|
|
} = options;
|
|
const rows = await db
|
|
.selectFrom("Build")
|
|
.select(({ eb }) => [
|
|
"Build.id",
|
|
"Build.title",
|
|
"Build.description",
|
|
"Build.modes",
|
|
"Build.headGearSplId",
|
|
"Build.clothesGearSplId",
|
|
"Build.shoesGearSplId",
|
|
"Build.updatedAt",
|
|
"Build.private",
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("BuildWeapon")
|
|
.select(["BuildWeapon.weaponSplId", "BuildWeapon.isTop500"])
|
|
.orderBy("BuildWeapon.weaponSplId", "asc")
|
|
.whereRef("BuildWeapon.buildId", "=", "Build.id"),
|
|
).as("weapons"),
|
|
withAbilities(eb),
|
|
])
|
|
.where("Build.ownerId", "=", userId)
|
|
.$if(!showPrivate, (qb) => qb.where("Build.private", "=", 0))
|
|
.$if(typeof limit === "number", (qb) => qb.limit(limit!))
|
|
.orderBy("Build.updatedAt", "desc")
|
|
.execute();
|
|
|
|
return rows.map((row) => {
|
|
const abilities = dbAbilitiesToArrayOfArrays(row.abilities);
|
|
|
|
return {
|
|
...row,
|
|
abilities: shouldSortAbilities ? sortAbilities(abilities) : 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]],
|
|
];
|
|
}
|
|
|
|
interface CreateArgs {
|
|
ownerId: TablesInsertable["Build"]["ownerId"];
|
|
title: TablesInsertable["Build"]["title"];
|
|
description: TablesInsertable["Build"]["description"];
|
|
modes: Array<ModeShort> | null;
|
|
headGearSplId: number | null;
|
|
clothesGearSplId: number | null;
|
|
shoesGearSplId: number | null;
|
|
weaponSplIds: Array<BuildWeapon["weaponSplId"]>;
|
|
abilities: BuildAbilitiesTuple;
|
|
private: TablesInsertable["Build"]["private"];
|
|
}
|
|
|
|
async function createInTrx({
|
|
args,
|
|
trx,
|
|
}: {
|
|
args: CreateArgs;
|
|
trx: Transaction<DB>;
|
|
}) {
|
|
const { id: buildId, updatedAt } = await trx
|
|
.insertInto("Build")
|
|
.values({
|
|
ownerId: args.ownerId,
|
|
title: args.title,
|
|
description: args.description,
|
|
modes:
|
|
args.modes && args.modes.length > 0
|
|
? JSON.stringify(
|
|
args.modes
|
|
.slice()
|
|
.sort((a, b) => modesShort.indexOf(a) - modesShort.indexOf(b)),
|
|
)
|
|
: null,
|
|
headGearSplId: args.headGearSplId ?? -1,
|
|
clothesGearSplId: args.clothesGearSplId ?? -1,
|
|
shoesGearSplId: args.shoesGearSplId ?? -1,
|
|
private: args.private,
|
|
})
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
|
|
await trx
|
|
.insertInto("BuildWeapon")
|
|
.values(
|
|
args.weaponSplIds.map((weaponSplId) => ({
|
|
buildId,
|
|
weaponSplId,
|
|
})),
|
|
)
|
|
.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(
|
|
args.abilities.flatMap((row, rowI) =>
|
|
row.map((ability, abilityI) => ({
|
|
buildId,
|
|
gearType: rowI === 0 ? "HEAD" : rowI === 1 ? "CLOTHES" : "SHOES",
|
|
ability,
|
|
slotIndex: abilityI,
|
|
})),
|
|
),
|
|
)
|
|
.execute();
|
|
}
|
|
|
|
export async function create(args: CreateArgs) {
|
|
return db.transaction().execute(async (trx) => {
|
|
await createInTrx({ args, trx });
|
|
|
|
const { count } = await trx
|
|
.selectFrom("Build")
|
|
.select((eb) => eb.fn.countAll<number>().as("count"))
|
|
.where("ownerId", "=", args.ownerId)
|
|
.executeTakeFirstOrThrow();
|
|
|
|
if (count > BUILD.MAX_COUNT) {
|
|
throw new LimitReachedError("Max amount of builds reached");
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function update(args: CreateArgs & { id: number }) {
|
|
return db.transaction().execute(async (trx) => {
|
|
await trx.deleteFrom("Build").where("id", "=", args.id).execute();
|
|
await createInTrx({ args, trx });
|
|
});
|
|
}
|
|
|
|
export function deleteById(id: number) {
|
|
return db.deleteFrom("Build").where("id", "=", id).execute();
|
|
}
|
|
|
|
export async function ownerIdById(buildId: number) {
|
|
const result = await db
|
|
.selectFrom("Build")
|
|
.select("ownerId")
|
|
.where("id", "=", buildId)
|
|
.executeTakeFirstOrThrow();
|
|
|
|
return result.ownerId;
|
|
}
|
|
|
|
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;
|
|
const weaponIds = weaponIdToArrayWithAlts(weaponId);
|
|
|
|
let rows: Awaited<ReturnType<typeof buildsByWeaponIdQuery>>;
|
|
|
|
if (weaponIds.length === 1) {
|
|
rows = await buildsByWeaponIdQuery(weaponIds[0], limit);
|
|
} else {
|
|
// For weapons with alts, run separate queries and merge.
|
|
// This allows each query to use the covering index for ordering,
|
|
// which is ~6x faster than using IN with multiple values.
|
|
const allResults = await Promise.all(
|
|
weaponIds.map((id) => buildsByWeaponIdQuery(id, limit)),
|
|
);
|
|
|
|
const seenBuildIds = new Set<number>();
|
|
type BuildRow = Awaited<ReturnType<typeof buildsByWeaponIdQuery>>[number];
|
|
const merged: BuildRow[] = [];
|
|
|
|
// Merge results maintaining sort order (tier asc, isTop500 desc, updatedAt desc)
|
|
// Since each query returns sorted results, we can interleave them
|
|
const pointers = allResults.map(() => 0);
|
|
|
|
while (merged.length < limit) {
|
|
let bestIdx = -1;
|
|
let bestRow: BuildRow | null = null;
|
|
|
|
for (let i = 0; i < allResults.length; i++) {
|
|
while (
|
|
pointers[i] < allResults[i].length &&
|
|
seenBuildIds.has(allResults[i][pointers[i]].id)
|
|
) {
|
|
pointers[i]++;
|
|
}
|
|
|
|
if (pointers[i] >= allResults[i].length) continue;
|
|
|
|
const row = allResults[i][pointers[i]];
|
|
|
|
if (
|
|
!bestRow ||
|
|
row.bwTier < bestRow.bwTier ||
|
|
(row.bwTier === bestRow.bwTier &&
|
|
row.bwIsTop500 > bestRow.bwIsTop500) ||
|
|
(row.bwTier === bestRow.bwTier &&
|
|
row.bwIsTop500 === bestRow.bwIsTop500 &&
|
|
row.bwUpdatedAt > bestRow.bwUpdatedAt)
|
|
) {
|
|
bestIdx = i;
|
|
bestRow = row;
|
|
}
|
|
}
|
|
|
|
if (bestIdx === -1 || !bestRow) break;
|
|
|
|
seenBuildIds.add(bestRow.id);
|
|
merged.push(bestRow);
|
|
pointers[bestIdx]++;
|
|
}
|
|
|
|
rows = merged;
|
|
}
|
|
|
|
return rows.map((row) => {
|
|
const abilities = dbAbilitiesToArrayOfArrays(row.abilities);
|
|
|
|
return {
|
|
...row,
|
|
abilities: shouldSortAbilities ? sortAbilities(abilities) : abilities,
|
|
};
|
|
});
|
|
}
|
|
|
|
function buildsByWeaponIdQuery(weaponSplId: MainWeaponId, limit: number) {
|
|
return db
|
|
.selectFrom("BuildWeapon")
|
|
.innerJoin("Build", "Build.id", "BuildWeapon.buildId")
|
|
.innerJoin("User", "User.id", "Build.ownerId")
|
|
.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",
|
|
"BuildWeapon.tier as bwTier",
|
|
"BuildWeapon.isTop500 as bwIsTop500",
|
|
"BuildWeapon.updatedAt as bwUpdatedAt",
|
|
withAbilities(eb),
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("BuildWeapon as BuildWeaponInner")
|
|
.select(["BuildWeaponInner.weaponSplId", "BuildWeaponInner.isTop500"])
|
|
.orderBy("BuildWeaponInner.weaponSplId", "asc")
|
|
.whereRef("BuildWeaponInner.buildId", "=", "Build.id"),
|
|
).as("weapons"),
|
|
commonUserJsonObject(eb).as("owner"),
|
|
])
|
|
.where("Build.private", "=", 0)
|
|
.where("BuildWeapon.weaponSplId", "=", weaponSplId)
|
|
.orderBy("BuildWeapon.tier", "asc")
|
|
.orderBy("BuildWeapon.isTop500", "desc")
|
|
.orderBy("BuildWeapon.updatedAt", "desc")
|
|
.limit(limit)
|
|
.execute();
|
|
}
|
|
|
|
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();
|
|
}
|
|
});
|
|
}
|