mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-23 11:57:50 -05:00
545 lines
15 KiB
TypeScript
545 lines
15 KiB
TypeScript
import { type NotNull, sql, type Transaction } from "kysely";
|
|
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
|
import { db } from "~/db/sql";
|
|
import type { BuildWeapon, DB, 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 { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids";
|
|
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
|
import { LimitReachedError } from "~/utils/errors";
|
|
import invariant from "~/utils/invariant";
|
|
import { commonUserJsonObject } from "~/utils/kysely.server";
|
|
import {
|
|
MAIN_SLOT_AP,
|
|
SUB_SLOT_AP,
|
|
} from "../build-analyzer/analyzer-constants";
|
|
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",
|
|
"Build.abilities",
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("BuildWeapon")
|
|
.select(["BuildWeapon.weaponSplId", "BuildWeapon.sortValue"])
|
|
.orderBy("BuildWeapon.weaponSplId", "asc")
|
|
.whereRef("BuildWeapon.buildId", "=", "Build.id"),
|
|
).as("weapons"),
|
|
])
|
|
.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) => buildRowToResult(row, shouldSortAbilities));
|
|
}
|
|
|
|
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"];
|
|
}
|
|
|
|
export async function create(args: CreateArgs) {
|
|
return db.transaction().execute(async (trx) => {
|
|
const computed = await computeBuildData(trx, args);
|
|
const updatedAt = dateToDatabaseTimestamp(new Date());
|
|
|
|
const { id: buildId } = await trx
|
|
.insertInto("Build")
|
|
.values({
|
|
ownerId: args.ownerId,
|
|
title: args.title,
|
|
description: args.description,
|
|
modes: serializeModes(args.modes),
|
|
headGearSplId: args.headGearSplId,
|
|
clothesGearSplId: args.clothesGearSplId,
|
|
shoesGearSplId: args.shoesGearSplId,
|
|
private: args.private,
|
|
abilities: JSON.stringify(args.abilities),
|
|
abilitiesSignature: computed.abilitiesSignature,
|
|
updatedAt,
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
|
|
await insertBuildChildrenInTrx({
|
|
trx,
|
|
buildId,
|
|
args,
|
|
computed,
|
|
updatedAt,
|
|
});
|
|
|
|
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) => {
|
|
const computed = await computeBuildData(trx, args);
|
|
const updatedAt = dateToDatabaseTimestamp(new Date());
|
|
|
|
await trx
|
|
.updateTable("Build")
|
|
.set({
|
|
title: args.title,
|
|
description: args.description,
|
|
modes: serializeModes(args.modes),
|
|
headGearSplId: args.headGearSplId,
|
|
clothesGearSplId: args.clothesGearSplId,
|
|
shoesGearSplId: args.shoesGearSplId,
|
|
private: args.private,
|
|
abilities: JSON.stringify(args.abilities),
|
|
abilitiesSignature: computed.abilitiesSignature,
|
|
updatedAt,
|
|
})
|
|
.where("id", "=", args.id)
|
|
.execute();
|
|
|
|
await trx
|
|
.deleteFrom("BuildWeapon")
|
|
.where("buildId", "=", args.id)
|
|
.execute();
|
|
await trx
|
|
.deleteFrom("BuildAbilitySum")
|
|
.where("buildId", "=", args.id)
|
|
.execute();
|
|
await trx
|
|
.deleteFrom("BuildWeaponAbility")
|
|
.where("buildId", "=", args.id)
|
|
.execute();
|
|
|
|
await insertBuildChildrenInTrx({
|
|
trx,
|
|
buildId: args.id,
|
|
args,
|
|
computed,
|
|
updatedAt,
|
|
});
|
|
});
|
|
}
|
|
|
|
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)
|
|
.executeTakeFirst();
|
|
|
|
return result?.ownerId ?? null;
|
|
}
|
|
|
|
export async function abilityPointAverages(weaponSplId?: MainWeaponId | null) {
|
|
// Sum tables only contain rows for public builds,
|
|
// so the queries below need no private filter and no `Build` join.
|
|
if (typeof weaponSplId === "number") {
|
|
return db
|
|
.selectFrom("BuildWeaponAbility")
|
|
.select(({ fn }) => [
|
|
"BuildWeaponAbility.ability",
|
|
fn
|
|
.sum<number>("BuildWeaponAbility.abilityPoints")
|
|
.as("abilityPointsSum"),
|
|
])
|
|
.where(
|
|
"BuildWeaponAbility.canonicalWeaponSplId",
|
|
"=",
|
|
canonicalWeaponSplId(weaponSplId),
|
|
)
|
|
.groupBy("BuildWeaponAbility.ability")
|
|
.execute();
|
|
}
|
|
|
|
return db
|
|
.selectFrom("BuildAbilitySum")
|
|
.select(({ fn }) => [
|
|
"BuildAbilitySum.ability",
|
|
fn.sum<number>("BuildAbilitySum.abilityPoints").as("abilityPointsSum"),
|
|
])
|
|
.groupBy("BuildAbilitySum.ability")
|
|
.execute();
|
|
}
|
|
|
|
export async function popularAbilitiesByWeaponId(weaponSplId: MainWeaponId) {
|
|
// One signature per user — otherwise a user with several builds for the
|
|
// same weapon (e.g. three different Slosher loadouts) would inflate three
|
|
// different signature buckets. The CTE picks each user's most recently
|
|
// updated public build for the weapon via SQLite's MAX() + bare columns
|
|
// rule (https://www.sqlite.org/lang_select.html#bareagg).
|
|
return db
|
|
.with("UserSignature", (cte) =>
|
|
cte
|
|
.selectFrom("Build")
|
|
.innerJoin("BuildWeapon", "BuildWeapon.buildId", "Build.id")
|
|
.select(({ fn }) => [
|
|
"Build.abilitiesSignature",
|
|
fn.max("Build.updatedAt").as("latestUpdatedAt"),
|
|
])
|
|
.where(
|
|
"BuildWeapon.canonicalWeaponSplId",
|
|
"=",
|
|
canonicalWeaponSplId(weaponSplId),
|
|
)
|
|
.where("Build.private", "=", 0)
|
|
.where("Build.abilitiesSignature", "is not", null)
|
|
.groupBy("Build.ownerId"),
|
|
)
|
|
.selectFrom("UserSignature")
|
|
.select(({ fn }) => [
|
|
"UserSignature.abilitiesSignature",
|
|
fn.count<number>("UserSignature.abilitiesSignature").as("count"),
|
|
])
|
|
.groupBy("UserSignature.abilitiesSignature")
|
|
.having((eb) => eb(eb.fn.count("UserSignature.abilitiesSignature"), ">", 1))
|
|
.orderBy("count", "desc")
|
|
.orderBy("UserSignature.abilitiesSignature", "asc")
|
|
.limit(25)
|
|
.$narrowType<{ abilitiesSignature: NotNull }>()
|
|
.execute();
|
|
}
|
|
|
|
export type AverageAbilityPointsResult = Awaited<
|
|
ReturnType<typeof abilityPointAverages>
|
|
>[number];
|
|
|
|
export type PopularBuildsRow = Awaited<
|
|
ReturnType<typeof popularAbilitiesByWeaponId>
|
|
>[number];
|
|
|
|
export async function allByWeaponId(
|
|
weaponId: MainWeaponId,
|
|
options: { limit: number; sortAbilities?: boolean },
|
|
) {
|
|
const { limit, sortAbilities: shouldSortAbilities = false } = options;
|
|
|
|
const rows = await 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",
|
|
"Build.abilities",
|
|
"PlusTier.tier as plusTier",
|
|
commonUserJsonObject(eb).as("owner"),
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("BuildWeapon as bw_inner")
|
|
.select(["bw_inner.weaponSplId", "bw_inner.sortValue"])
|
|
.orderBy("bw_inner.weaponSplId", "asc")
|
|
.whereRef("bw_inner.buildId", "=", "Build.id"),
|
|
).as("weapons"),
|
|
])
|
|
.where(
|
|
"BuildWeapon.canonicalWeaponSplId",
|
|
"=",
|
|
canonicalWeaponSplId(weaponId),
|
|
)
|
|
.where("BuildWeapon.sortValue", "is not", null)
|
|
.orderBy("BuildWeapon.sortValue", "asc")
|
|
.orderBy("BuildWeapon.updatedAt", "desc")
|
|
.limit(limit)
|
|
.execute();
|
|
|
|
return rows.map((row) => buildRowToResult(row, shouldSortAbilities));
|
|
}
|
|
|
|
/** Recomputes `BuildWeapon.sortValue` for every (build, weapon) from scratch
|
|
* (plus tier + per-weapon top500). When `userId` is provided, only the builds
|
|
* owned by that user are recomputed. */
|
|
export async function recalculateAllSortValues(userId?: number) {
|
|
await db.transaction().execute(async (trx) => {
|
|
// Pass 1: tier*2 + 1 for public, NULL for private.
|
|
await trx
|
|
.updateTable("BuildWeapon")
|
|
.set({
|
|
sortValue: sql<number | null>`(
|
|
select case
|
|
when "b"."private" = 1 then null
|
|
else coalesce(
|
|
(select "tier" from "PlusTier" where "userId" = "b"."ownerId"),
|
|
4
|
|
) * 2 + 1
|
|
end
|
|
from "Build" as "b"
|
|
where "b"."id" = "BuildWeapon"."buildId"
|
|
)`,
|
|
})
|
|
.$if(userId !== undefined, (qb) =>
|
|
qb.where((eb) =>
|
|
eb.exists(
|
|
eb
|
|
.selectFrom("Build as b")
|
|
.select("b.id")
|
|
.whereRef("b.id", "=", "BuildWeapon.buildId")
|
|
.where("b.ownerId", "=", userId!),
|
|
),
|
|
),
|
|
)
|
|
.execute();
|
|
|
|
// Pass 2: subtract 1 where this specific weapon is top500 for the owner.
|
|
await trx
|
|
.updateTable("BuildWeapon")
|
|
.set({
|
|
sortValue: sql<number>`"BuildWeapon"."sortValue" - 1`,
|
|
})
|
|
.where("BuildWeapon.sortValue", "is not", null)
|
|
.where((eb) =>
|
|
eb.exists(
|
|
eb
|
|
.selectFrom("Build as b")
|
|
.innerJoin("SplatoonPlayer as sp", "sp.userId", "b.ownerId")
|
|
.innerJoin("XRankPlacement as xrp", (join) =>
|
|
join
|
|
.onRef("xrp.playerId", "=", "sp.id")
|
|
.onRef("xrp.weaponSplId", "=", "BuildWeapon.weaponSplId"),
|
|
)
|
|
.select("b.id")
|
|
.whereRef("b.id", "=", "BuildWeapon.buildId")
|
|
.$if(userId !== undefined, (qb) =>
|
|
qb.where("b.ownerId", "=", userId!),
|
|
),
|
|
),
|
|
)
|
|
.execute();
|
|
});
|
|
}
|
|
|
|
// ---
|
|
|
|
function weaponIsTop500(sortValue: number | null): boolean {
|
|
return sortValue != null && sortValue % 2 === 0;
|
|
}
|
|
|
|
interface BuildRowToResultInput {
|
|
abilities: BuildAbilitiesTuple | null;
|
|
weapons: Array<{ weaponSplId: MainWeaponId; sortValue: number | null }>;
|
|
}
|
|
|
|
type BuildRowToResultOutput<T extends BuildRowToResultInput> = Omit<
|
|
T,
|
|
"abilities" | "weapons"
|
|
> & {
|
|
abilities: BuildAbilitiesTuple;
|
|
weapons: Array<{ weaponSplId: MainWeaponId; isTop500: number }>;
|
|
};
|
|
|
|
function buildRowToResult<T extends BuildRowToResultInput>(
|
|
row: T,
|
|
shouldSortAbilities: boolean,
|
|
): BuildRowToResultOutput<T> {
|
|
invariant(row.abilities, "expected build abilities to be populated");
|
|
|
|
return {
|
|
...row,
|
|
abilities: shouldSortAbilities
|
|
? sortAbilities(row.abilities)
|
|
: row.abilities,
|
|
weapons: row.weapons.map((w) => ({
|
|
weaponSplId: w.weaponSplId,
|
|
isTop500: weaponIsTop500(w.sortValue) ? 1 : 0,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function serializeModes(modes: Array<ModeShort> | null) {
|
|
if (!modes || modes.length === 0) return null;
|
|
|
|
return JSON.stringify(
|
|
modes.slice().sort((a, b) => modesShort.indexOf(a) - modesShort.indexOf(b)),
|
|
);
|
|
}
|
|
|
|
interface ComputedBuildData {
|
|
abilitySums: Array<[Ability, number]>;
|
|
abilitiesSignature: string;
|
|
sortValueByWeaponSplId: Map<MainWeaponId, number | null>;
|
|
}
|
|
|
|
async function computeBuildData(
|
|
trx: Transaction<DB>,
|
|
args: CreateArgs,
|
|
): Promise<ComputedBuildData> {
|
|
const abilitySums = computeAbilitySums(args.abilities);
|
|
|
|
const tier =
|
|
(
|
|
await trx
|
|
.selectFrom("PlusTier")
|
|
.select("tier")
|
|
.where("userId", "=", args.ownerId)
|
|
.executeTakeFirst()
|
|
)?.tier ?? 4;
|
|
|
|
const top500Weapons = new Set<MainWeaponId>();
|
|
if (args.weaponSplIds.length > 0) {
|
|
const rows = await trx
|
|
.selectFrom("XRankPlacement")
|
|
.innerJoin(
|
|
"SplatoonPlayer",
|
|
"SplatoonPlayer.id",
|
|
"XRankPlacement.playerId",
|
|
)
|
|
.select("XRankPlacement.weaponSplId")
|
|
.where("SplatoonPlayer.userId", "=", args.ownerId)
|
|
.where("XRankPlacement.weaponSplId", "in", args.weaponSplIds)
|
|
.distinct()
|
|
.execute();
|
|
for (const r of rows) top500Weapons.add(r.weaponSplId);
|
|
}
|
|
|
|
const sortValueByWeaponSplId = new Map<MainWeaponId, number | null>();
|
|
for (const weaponSplId of args.weaponSplIds) {
|
|
sortValueByWeaponSplId.set(
|
|
weaponSplId,
|
|
args.private ? null : tier * 2 + (top500Weapons.has(weaponSplId) ? 0 : 1),
|
|
);
|
|
}
|
|
|
|
return {
|
|
abilitySums,
|
|
abilitiesSignature: serializeSignature(abilitySums),
|
|
sortValueByWeaponSplId,
|
|
};
|
|
}
|
|
|
|
function computeAbilitySums(
|
|
abilities: BuildAbilitiesTuple,
|
|
): Array<[Ability, number]> {
|
|
const sums = new Map<Ability, number>();
|
|
for (const row of abilities) {
|
|
for (let slotIdx = 0; slotIdx < row.length; slotIdx++) {
|
|
const ability = row[slotIdx];
|
|
const ap = slotIdx === 0 ? MAIN_SLOT_AP : SUB_SLOT_AP;
|
|
sums.set(ability, (sums.get(ability) ?? 0) + ap);
|
|
}
|
|
}
|
|
return [...sums.entries()];
|
|
}
|
|
|
|
function serializeSignature(sums: Array<[Ability, number]>): string {
|
|
return sums
|
|
.slice()
|
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
.map(([ability, ap]) => `${ability}_${ap}`)
|
|
.join(",");
|
|
}
|
|
|
|
async function insertBuildChildrenInTrx({
|
|
trx,
|
|
buildId,
|
|
args,
|
|
computed,
|
|
updatedAt,
|
|
}: {
|
|
trx: Transaction<DB>;
|
|
buildId: number;
|
|
args: CreateArgs;
|
|
computed: ComputedBuildData;
|
|
updatedAt: number;
|
|
}) {
|
|
await trx
|
|
.insertInto("BuildWeapon")
|
|
.values(
|
|
args.weaponSplIds.map((weaponSplId) => ({
|
|
buildId,
|
|
weaponSplId,
|
|
canonicalWeaponSplId: canonicalWeaponSplId(weaponSplId),
|
|
sortValue: computed.sortValueByWeaponSplId.get(weaponSplId) ?? null,
|
|
updatedAt,
|
|
})),
|
|
)
|
|
.execute();
|
|
|
|
// Private builds are excluded from the sum tables so the stats queries can
|
|
// run as pure covering-index scans. Visibility flips are handled implicitly
|
|
// by `update`'s delete-then-reinsert.
|
|
if (args.private) return;
|
|
|
|
await trx
|
|
.insertInto("BuildAbilitySum")
|
|
.values(
|
|
computed.abilitySums.map(([ability, abilityPoints]) => ({
|
|
buildId,
|
|
ability,
|
|
abilityPoints,
|
|
})),
|
|
)
|
|
.execute();
|
|
|
|
const weaponAbilityRows: TablesInsertable["BuildWeaponAbility"][] =
|
|
args.weaponSplIds.flatMap((weaponSplId) =>
|
|
computed.abilitySums.map(([ability, abilityPoints]) => ({
|
|
canonicalWeaponSplId: canonicalWeaponSplId(weaponSplId),
|
|
buildId,
|
|
ability,
|
|
abilityPoints,
|
|
})),
|
|
);
|
|
if (weaponAbilityRows.length > 0) {
|
|
await trx
|
|
.insertInto("BuildWeaponAbility")
|
|
.values(weaponAbilityRows)
|
|
.execute();
|
|
}
|
|
}
|