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 | null; headGearSplId: number | null; clothesGearSplId: number | null; shoesGearSplId: number | null; weaponSplIds: Array; 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().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("BuildWeaponAbility.abilityPoints") .as("abilityPointsSum"), ]) .where( "BuildWeaponAbility.canonicalWeaponSplId", "=", canonicalWeaponSplId(weaponSplId), ) .groupBy("BuildWeaponAbility.ability") .execute(); } return db .selectFrom("BuildAbilitySum") .select(({ fn }) => [ "BuildAbilitySum.ability", fn.sum("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("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 >[number]; export type PopularBuildsRow = Awaited< ReturnType >[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`( 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`"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 = Omit< T, "abilities" | "weapons" > & { abilities: BuildAbilitiesTuple; weapons: Array<{ weaponSplId: MainWeaponId; isTop500: number }>; }; function buildRowToResult( row: T, shouldSortAbilities: boolean, ): BuildRowToResultOutput { 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 | 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; } async function computeBuildData( trx: Transaction, args: CreateArgs, ): Promise { const abilitySums = computeAbilitySums(args.abilities); const tier = ( await trx .selectFrom("PlusTier") .select("tier") .where("userId", "=", args.ownerId) .executeTakeFirst() )?.tier ?? 4; const top500Weapons = new Set(); 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(); 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(); 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; 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(); } }