diff --git a/app/components/BuildCard.tsx b/app/components/BuildCard.tsx index b70079530..3296c1485 100644 --- a/app/components/BuildCard.tsx +++ b/app/components/BuildCard.tsx @@ -10,7 +10,7 @@ import type { BuildAbilitiesTuple, ModeShort, } from "~/modules/in-game-lists/types"; -import { altWeaponIdToId } from "~/modules/in-game-lists/weapon-ids"; +import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids"; import { gearTypeToInitial } from "~/utils/strings"; import { analyzerPage, @@ -228,8 +228,7 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) { } function RoundWeaponImage({ weapon }: { weapon: BuildWeaponWithTop500Info }) { - const normalizedWeaponSplId = - altWeaponIdToId.get(weapon.weaponSplId) ?? weapon.weaponSplId; + const normalizedWeaponSplId = canonicalWeaponSplId(weapon.weaponSplId); const { t } = useTranslation(["weapons"]); const slug = mySlugify( diff --git a/app/components/layout/GlobalSearch.tsx b/app/components/layout/GlobalSearch.tsx index 496d3ab00..73f0df52f 100644 --- a/app/components/layout/GlobalSearch.tsx +++ b/app/components/layout/GlobalSearch.tsx @@ -21,7 +21,7 @@ import { Image } from "~/components/Image"; import { Input } from "~/components/Input"; import type { SearchLoaderData } from "~/features/search/routes/search"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import { altWeaponIdToId } from "~/modules/in-game-lists/weapon-ids"; +import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids"; import { mySlugify, navIconUrl, @@ -167,11 +167,7 @@ function resolveInitialWeapon( const name = t(`weapons:MAIN_${id}`); if (!name || name === `MAIN_${id}`) return null; const englishName = t(`weapons:MAIN_${id}`, { lng: "en" }); - const baseId = altWeaponIdToId.get(id); - const slugName = - baseId !== undefined - ? t(`weapons:MAIN_${baseId}`, { lng: "en" }) - : englishName; + const slugName = t(`weapons:MAIN_${canonicalWeaponSplId(id)}`, { lng: "en" }); return { id, name, englishName, slug: mySlugify(slugName) }; } @@ -246,11 +242,9 @@ function GlobalSearchContent({ ? getRecentWeapons().map((id) => { const name = t(`weapons:MAIN_${id}`); const englishName = t(`weapons:MAIN_${id}`, { lng: "en" }); - const baseId = altWeaponIdToId.get(id); - const slugName = - baseId !== undefined - ? t(`weapons:MAIN_${baseId}`, { lng: "en" }) - : englishName; + const slugName = t(`weapons:MAIN_${canonicalWeaponSplId(id)}`, { + lng: "en", + }); return { id, name, englishName, slug: mySlugify(slugName) }; }) : []; diff --git a/app/components/layout/WeaponSearch.tsx b/app/components/layout/WeaponSearch.tsx index 972fd5c4e..28269cd58 100644 --- a/app/components/layout/WeaponSearch.tsx +++ b/app/components/layout/WeaponSearch.tsx @@ -17,7 +17,7 @@ import { Image } from "~/components/Image"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { filterWeapon } from "~/modules/in-game-lists/utils"; import { - altWeaponIdToId, + canonicalWeaponSplId, mainWeaponIds, } from "~/modules/in-game-lists/weapon-ids"; import { @@ -67,11 +67,9 @@ export function filterWeaponResults( if (isMatch) { const englishName = t(`weapons:MAIN_${id}`, { lng: "en" }); - const baseId = altWeaponIdToId.get(id); - const slugName = - baseId !== undefined - ? t(`weapons:MAIN_${baseId}`, { lng: "en" }) - : englishName; + const slugName = t(`weapons:MAIN_${canonicalWeaponSplId(id)}`, { + lng: "en", + }); matches.push({ id, name: weaponName, diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index e56ee94cc..1b805ef42 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -51,7 +51,10 @@ import type { ModeShort, StageId, } from "~/modules/in-game-lists/types"; -import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; +import { + canonicalWeaponSplId, + mainWeaponIds, +} from "~/modules/in-game-lists/weapon-ids"; import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types"; import { nullFilledArray } from "~/utils/arrays"; import { @@ -2052,7 +2055,12 @@ const randomAbility = (legalTypes: AbilityType[]) => { return randomOrderAbilities.find((a) => legalTypes.includes(a.type))!.name; }; -const adminWeaponPool = mainWeaponIds.filter(() => faker.number.float(1) > 0.8); +const canonicalMainWeaponIds = mainWeaponIds.filter( + (id) => canonicalWeaponSplId(id) === id, +); +const adminWeaponPool = canonicalMainWeaponIds.filter( + () => faker.number.float(1) > 0.8, +); async function adminBuilds() { for (let i = 0; i < 50; i++) { const randomOrderHeadGear = faker.helpers.shuffle(headGearIds.slice()); @@ -2127,7 +2135,7 @@ async function manySplattershotBuilds() { ); const randomOrderShoesGear = faker.helpers.shuffle(shoesGearIds.slice()); const randomOrderWeaponIds = faker.helpers - .shuffle(mainWeaponIds.slice()) + .shuffle(canonicalMainWeaponIds.slice()) .filter((id) => id !== SPLATTERSHOT_ID); const ownerId = users.pop()!; diff --git a/app/db/tables.ts b/app/db/tables.ts index fe172dd90..6f8e45bc1 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -20,6 +20,7 @@ import type { StoredWidget } from "~/features/user-page/core/widgets/types"; import type { ParticipantResult } from "~/modules/brackets-model"; import type { Ability, + BuildAbilitiesTuple, MainWeaponId, ModeShort, StageId, @@ -159,28 +160,38 @@ export interface Build { shoesGearSplId: number | null; title: string; updatedAt: Generated; + /** 3x4 ability tuple (head/clothes/shoes × main + 3 subs). */ + abilities: JSONColumnTypeNullable; + /** Serialized ability+AP combo (e.g. `SSU_30,ISS_10`) used to group identical builds for the popular builds view. */ + abilitiesSignature: string | null; } export type GearType = "HEAD" | "CLOTHES" | "SHOES"; -export interface BuildAbility { - ability: Ability; - buildId: number; - gearType: GearType; - slotIndex: number; - /** 10 if main ability, 3 if sub */ - abilityPoints: GeneratedAlways; -} - 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; - /** Plus tier or 4 if none. Denormalized for performance reasons. */ - tier: Generated; - /** Last time the build was updated. Denormalized for performance reasons. */ + /** Alt skins collapse to their base weapon (e.g. Hero Shot Replica `45` → Splattershot `40`). Indexed for the builds-by-weapon, popular, and stats queries so they can filter `= ?` against a covering index instead of `IN (alt skins…)`. */ + canonicalWeaponSplId: MainWeaponId; + /** Mirror of `Build.updatedAt`. Denormalized so the `(canonicalWeaponSplId, sortValue, updatedAt, buildId)` covering index serves the builds-by-weapon list. */ updatedAt: Generated; + /** Per-weapon sort priority: `plusTier * 2 + (this weapon is top500 ? 0 : 1)` for public builds, NULL for private. */ + sortValue: number | null; +} + +/** Per-build ability point sums across all gear slots. Used to compute global `abilityPointAverages`. */ +export interface BuildAbilitySum { + buildId: number; + ability: Ability; + abilityPoints: number; +} + +/** Per-weapon, per-build ability point sums. Used to compute per-weapon `abilityPointAverages`. One row per canonical weapon × build × ability with non-zero AP. */ +export interface BuildWeaponAbility { + canonicalWeaponSplId: MainWeaponId; + buildId: number; + ability: Ability; + abilityPoints: number; } export type CalendarEventTag = keyof typeof tags; @@ -1355,8 +1366,9 @@ export interface DB { BanLog: BanLog; ModNote: ModNote; Build: Build; - BuildAbility: BuildAbility; + BuildAbilitySum: BuildAbilitySum; BuildWeapon: BuildWeapon; + BuildWeaponAbility: BuildWeaponAbility; CalendarEvent: CalendarEvent; CalendarEventBadge: CalendarEventBadge; CalendarEventDate: CalendarEventDate; diff --git a/app/features/admin/AdminRepository.server.ts b/app/features/admin/AdminRepository.server.ts index f27440e5c..008f3b7c6 100644 --- a/app/features/admin/AdminRepository.server.ts +++ b/app/features/admin/AdminRepository.server.ts @@ -240,7 +240,8 @@ export async function linkUserAndPlayer({ .execute(); await BadgeRepository.syncXPBadges(); - await BuildRepository.recalculateAllTop500(); + + await BuildRepository.recalculateAllSortValues(userId); } export function forcePatron(args: { diff --git a/app/features/admin/actions/admin.server.ts b/app/features/admin/actions/admin.server.ts index 32c9ca08c..0292d339e 100644 --- a/app/features/admin/actions/admin.server.ts +++ b/app/features/admin/actions/admin.server.ts @@ -60,7 +60,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { await plusTiersFromVotingAndLeaderboard(), ); - await BuildRepository.recalculateAllTiers(); + await BuildRepository.recalculateAllSortValues(); message = "Plus tiers refreshed"; break; diff --git a/app/features/build-analyzer/analyzer-constants.ts b/app/features/build-analyzer/analyzer-constants.ts index ac069fa3d..85db0e0a0 100644 --- a/app/features/build-analyzer/analyzer-constants.ts +++ b/app/features/build-analyzer/analyzer-constants.ts @@ -106,3 +106,6 @@ export const RAINMAKER_SPEED_PENALTY_MODIFIER = 0.8; export const UNKNOWN_SHORT = "U"; export const MAX_AP = 57; + +export const MAIN_SLOT_AP = 10; +export const SUB_SLOT_AP = 3; diff --git a/app/features/build-analyzer/core/utils.ts b/app/features/build-analyzer/core/utils.ts index e289be903..13d631d2d 100644 --- a/app/features/build-analyzer/core/utils.ts +++ b/app/features/build-analyzer/core/utils.ts @@ -20,7 +20,11 @@ import { } from "~/modules/in-game-lists/weapon-ids"; import invariant from "~/utils/invariant"; import type { Unpacked } from "~/utils/types"; -import { UNKNOWN_SHORT } from "../analyzer-constants"; +import { + MAIN_SLOT_AP, + SUB_SLOT_AP, + UNKNOWN_SHORT, +} from "../analyzer-constants"; import type { AbilityPoints, AnalyzedBuild, @@ -60,7 +64,7 @@ export function buildToAbilityPoints(build: BuildAbilitiesTupleWithUnknown) { continue; } - const aps = i === 0 ? 10 : 3; + const aps = i === 0 ? MAIN_SLOT_AP : SUB_SLOT_AP; const apsDoubled = aps * (abilityDoublerActive ? 2 : 1); const newAp = (result.get(ability) ?? 0) + apsDoubled; diff --git a/app/features/build-stats/build-stats-utils.test.ts b/app/features/build-stats/build-stats-utils.test.ts index 586c589f5..2d846da1b 100644 --- a/app/features/build-stats/build-stats-utils.test.ts +++ b/app/features/build-stats/build-stats-utils.test.ts @@ -77,68 +77,41 @@ describe("abilityPointCountsToAverages", () => { }); describe("popularBuilds", () => { - test("calculates popular build", () => { - const builds = popularBuilds([ - ...new Array(10).fill(null).map(() => ({ - abilities: [{ ability: "QR" as const, abilityPoints: 57 }], - })), - { - abilities: [{ ability: "BRU" as const, abilityPoints: 57 }], - }, - ]); + test("expands a single signature into ability rows", () => { + const builds = popularBuilds([{ abilitiesSignature: "QR_57", count: 10 }]); expect(builds.length).toBe(1); expect(builds[0].count).toBe(10); expect(builds[0].abilities[0].ability).toBe("QR"); }); - test("calculates second most popular build (sorted by count)", () => { + test("preserves the SQL-provided order across signatures", () => { const builds = popularBuilds([ - ...new Array(10).fill(null).map(() => ({ - abilities: [{ ability: "QR" as const, abilityPoints: 57 }], - })), - ...new Array(3).fill(null).map(() => ({ - abilities: [{ ability: "SS" as const, abilityPoints: 57 }], - })), - ...new Array(5).fill(null).map(() => ({ - abilities: [{ ability: "SSU" as const, abilityPoints: 57 }], - })), + { abilitiesSignature: "QR_57", count: 10 }, + { abilitiesSignature: "SSU_57", count: 5 }, + { abilitiesSignature: "SS_57", count: 3 }, ]); expect(builds.length).toBe(3); expect(builds[1].abilities[0].ability).toBe("SSU"); }); - test("sums up abilities", () => { + test("hides repeated count when consecutive rows share a count", () => { const builds = popularBuilds([ - { abilities: [{ ability: "QR" as const, abilityPoints: 57 }] }, - { - abilities: [ - { ability: "QR" as const, abilityPoints: 10 }, - { ability: "QR" as const, abilityPoints: 47 }, - ], - }, + { abilitiesSignature: "QR_57", count: 4 }, + { abilitiesSignature: "SSU_57", count: 4 }, ]); - expect(builds.length).toBe(1); + expect(builds[0].count).toBe(4); + expect(builds[1].count).toBeNull(); }); - test("sorts abilities", () => { + test("preserves the order of abilities within a signature", () => { const builds = popularBuilds([ - { - abilities: [ - { ability: "QR" as const, abilityPoints: 10 }, - { ability: "SS" as const, abilityPoints: 47 }, - ], - }, - { - abilities: [ - { ability: "QR" as const, abilityPoints: 10 }, - { ability: "SS" as const, abilityPoints: 47 }, - ], - }, + { abilitiesSignature: "SS_47,QR_10", count: 2 }, ]); + expect(builds[0].abilities[0].ability).toBe("SS"); expect(builds[0].abilities[1].ability).toBe("QR"); }); }); diff --git a/app/features/build-stats/build-stats-utils.ts b/app/features/build-stats/build-stats-utils.ts index ec19d81de..18ff60e9c 100644 --- a/app/features/build-stats/build-stats-utils.ts +++ b/app/features/build-stats/build-stats-utils.ts @@ -1,4 +1,3 @@ -import * as R from "remeda"; import { abilities } from "~/modules/in-game-lists/abilities"; import type { Ability } from "~/modules/in-game-lists/types"; import invariant from "~/utils/invariant"; @@ -6,8 +5,8 @@ import { roundToNDecimalPlaces } from "~/utils/number"; import { MAX_AP } from "../build-analyzer/analyzer-constants"; import { isStackableAbility } from "../build-analyzer/core/utils"; import type { - AbilitiesByWeapon, AverageAbilityPointsResult, + PopularBuildsRow, } from "../builds/BuildRepository.server"; const toBuildsCount = (counts: AverageAbilityPointsResult[]) => @@ -97,65 +96,23 @@ export function abilityPointCountsToAverages({ // --- -type AbilityCountsMap = Map; - -const POPULAR_BUILDS_TO_SHOW = 25; - -export function popularBuilds(builds: Array) { - const serializedToShow = R.pipe( - builds, - R.countBy((build) => serializeAbilityCountsMap(sumUpAbilities(build))), - R.entries(), - R.sortBy([([, count]) => count, "desc"]), - R.filter(([, count]) => count > 1), - R.take(POPULAR_BUILDS_TO_SHOW), - ); - - return serializedToShowToResultType(serializedToShow); -} - -function sumUpAbilities(build: AbilitiesByWeapon) { - const result: AbilityCountsMap = new Map(); - for (const { ability, abilityPoints } of build.abilities) { - result.set(ability, (result.get(ability) ?? 0) + abilityPoints); - } - - return result; -} - -function serializeAbilityCountsMap(abilityCountsMap: AbilityCountsMap) { - return Array.from(abilityCountsMap.entries()) - .sort((a, b) => { - if (a[1] === b[1]) { - return a[0].localeCompare(b[0]); - } - - return b[1] - a[1]; - }) - .map(([ability, count]) => `${ability}_${count}`) - .join(","); -} - -function serializedToShowToResultType(serializedToShow: [string, number][]) { +export function popularBuilds(rows: Array) { let previousCount: number; - return serializedToShow.map(([serialized, count]) => { - const abilities = serialized.split(",").map((serializedAbility) => { - const [ability, count] = serializedAbility.split("_"); + return rows.map(({ abilitiesSignature, count }) => { + const abilities = abilitiesSignature.split(",").map((serializedAbility) => { + const [ability, points] = serializedAbility.split("_"); invariant(ability, "ability is not defined"); - invariant(count, "count is not defined"); + invariant(points, "count is not defined"); return { ability: ability as Ability, count: isStackableAbility(ability as Ability) - ? Number(count) + ? Number(points) : undefined, }; }); - if (previousCount === count) { - return { abilities, count: null, id: serialized }; - } - + const displayCount = previousCount === count ? null : count; previousCount = count; - return { abilities, count, id: serialized }; + return { abilities, count: displayCount, id: abilitiesSignature }; }); } diff --git a/app/features/builds/BuildRepository.server.test.ts b/app/features/builds/BuildRepository.server.test.ts new file mode 100644 index 000000000..9f37c9c70 --- /dev/null +++ b/app/features/builds/BuildRepository.server.test.ts @@ -0,0 +1,381 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { db } from "~/db/sql"; +import type { + BuildAbilitiesTuple, + MainWeaponId, +} from "~/modules/in-game-lists/types"; +import { dbInsertUsers, dbReset } from "~/utils/Test"; +import * as AdminRepository from "../admin/AdminRepository.server"; +import * as BuildRepository from "./BuildRepository.server"; + +const OWNER_ID = 1; + +// Splattershot (40) is the canonical base, Hero Shot Replica (45) is an alt skin +// that should be folded to 40 by the canonical id mapping. +const SPLATTERSHOT: MainWeaponId = 40; +const HERO_SHOT_REPLICA: MainWeaponId = 45; +const SPLATTERSHOT_NOUVEAU: MainWeaponId = 41; + +// Head ["ISM", "ISM", "ISS", "ISS"]: ISM main+sub = 13, ISS sub+sub = 6 +// Clothes ["ISS", "ISM", "ISS", "ISM"]: ISS main+sub = 13, ISM sub+sub = 6 +// Shoes ["ISM", "ISM", "ISM", "ISM"]: ISM main+3 subs = 19 +// Totals: ISM = 38, ISS = 19 (MAIN_SLOT_AP=10, SUB_SLOT_AP=3) +const ABILITIES: BuildAbilitiesTuple = [ + ["ISM", "ISM", "ISS", "ISS"], + ["ISS", "ISM", "ISS", "ISM"], + ["ISM", "ISM", "ISM", "ISM"], +]; +const EXPECTED_SIGNATURE = "ISM_38,ISS_19"; + +const baseArgs = ( + overrides: Partial[0]> = {}, +): Parameters[0] => ({ + ownerId: OWNER_ID, + title: "Test Build", + description: null, + modes: null, + headGearSplId: null, + clothesGearSplId: null, + shoesGearSplId: null, + weaponSplIds: [SPLATTERSHOT], + abilities: ABILITIES, + private: 0, + ...overrides, +}); + +const insertSplatoonPlayer = async (userId: number, splId: string) => { + const { id } = await db + .insertInto("SplatoonPlayer") + .values({ splId, userId }) + .returning("id") + .executeTakeFirstOrThrow(); + return id; +}; + +const insertXRankPlacement = async ( + playerId: number, + weaponSplId: MainWeaponId, + rank: number, +) => { + await db + .insertInto("XRankPlacement") + .values({ + playerId, + weaponSplId, + badges: "[]", + bannerSplId: 1, + mode: "SZ", + month: 1, + year: 2024, + name: "Test Player", + nameDiscriminator: "0000", + power: 2500, + rank, + region: "WEST", + title: "Test", + }) + .execute(); +}; + +const buildById = (id: number) => + db + .selectFrom("Build") + .select(["abilitiesSignature", "private"]) + .where("id", "=", id) + .executeTakeFirstOrThrow(); + +const buildWeaponsByBuildId = (buildId: number) => + db + .selectFrom("BuildWeapon") + .select(["weaponSplId", "canonicalWeaponSplId", "sortValue"]) + .where("buildId", "=", buildId) + .orderBy("weaponSplId", "asc") + .execute(); + +const buildAbilitySumsByBuildId = (buildId: number) => + db + .selectFrom("BuildAbilitySum") + .select(["ability", "abilityPoints"]) + .where("buildId", "=", buildId) + .execute(); + +const buildWeaponAbilitiesByBuildId = (buildId: number) => + db + .selectFrom("BuildWeaponAbility") + .select(["canonicalWeaponSplId", "ability", "abilityPoints"]) + .where("buildId", "=", buildId) + .execute(); + +const onlyBuildId = async () => { + const row = await db + .selectFrom("Build") + .select("id") + .executeTakeFirstOrThrow(); + return row.id; +}; + +describe("BuildRepository.create — computeBuildData", () => { + beforeEach(async () => { + await dbInsertUsers(2); + }); + + afterEach(() => { + dbReset(); + }); + + describe("abilitiesSignature & ability sums", () => { + test("writes the serialized abilitiesSignature sorted by AP desc", async () => { + await BuildRepository.create(baseArgs()); + + const build = await buildById(await onlyBuildId()); + expect(build.abilitiesSignature).toBe(EXPECTED_SIGNATURE); + }); + + test("inserts one BuildAbilitySum row per distinct ability with summed AP", async () => { + await BuildRepository.create(baseArgs()); + + const sums = await buildAbilitySumsByBuildId(await onlyBuildId()); + + expect(sums).toHaveLength(2); + expect(sums).toContainEqual({ ability: "ISM", abilityPoints: 38 }); + expect(sums).toContainEqual({ ability: "ISS", abilityPoints: 19 }); + }); + + test("does not insert BuildAbilitySum rows for private builds", async () => { + await BuildRepository.create(baseArgs({ private: 1 })); + + const sums = await buildAbilitySumsByBuildId(await onlyBuildId()); + expect(sums).toHaveLength(0); + }); + + test("still writes abilitiesSignature for private builds", async () => { + await BuildRepository.create(baseArgs({ private: 1 })); + + const build = await buildById(await onlyBuildId()); + expect(build.abilitiesSignature).toBe(EXPECTED_SIGNATURE); + }); + }); + + describe("BuildWeaponAbility rows", () => { + test("inserts one row per weapon × ability for public builds", async () => { + await BuildRepository.create( + baseArgs({ weaponSplIds: [SPLATTERSHOT, SPLATTERSHOT_NOUVEAU] }), + ); + + const rows = await buildWeaponAbilitiesByBuildId(await onlyBuildId()); + expect(rows).toHaveLength(4); + expect(rows).toContainEqual({ + canonicalWeaponSplId: SPLATTERSHOT, + ability: "ISM", + abilityPoints: 38, + }); + expect(rows).toContainEqual({ + canonicalWeaponSplId: SPLATTERSHOT_NOUVEAU, + ability: "ISS", + abilityPoints: 19, + }); + }); + + test("folds alt skins to their canonical weapon id", async () => { + await BuildRepository.create( + baseArgs({ weaponSplIds: [HERO_SHOT_REPLICA] }), + ); + + const rows = await buildWeaponAbilitiesByBuildId(await onlyBuildId()); + const weaponIds = new Set(rows.map((r) => r.canonicalWeaponSplId)); + expect(weaponIds).toEqual(new Set([SPLATTERSHOT])); + }); + + test("does not insert any rows for private builds", async () => { + await BuildRepository.create(baseArgs({ private: 1 })); + + const rows = await buildWeaponAbilitiesByBuildId(await onlyBuildId()); + expect(rows).toHaveLength(0); + }); + }); + + describe("BuildWeapon.canonicalWeaponSplId", () => { + test("stores the canonical id alongside the original weaponSplId", async () => { + await BuildRepository.create( + baseArgs({ weaponSplIds: [HERO_SHOT_REPLICA] }), + ); + + const weapons = await buildWeaponsByBuildId(await onlyBuildId()); + expect(weapons).toHaveLength(1); + expect(weapons[0].weaponSplId).toBe(HERO_SHOT_REPLICA); + expect(weapons[0].canonicalWeaponSplId).toBe(SPLATTERSHOT); + }); + }); + + describe("sortValue", () => { + test("defaults to tier 4 (sortValue = 9) when owner has no PlusTier", async () => { + await BuildRepository.create(baseArgs()); + + const [weapon] = await buildWeaponsByBuildId(await onlyBuildId()); + expect(weapon.sortValue).toBe(9); + }); + + test("uses owner's PlusTier (tier 2 → sortValue = 5)", async () => { + await AdminRepository.replacePlusTiers([ + { userId: OWNER_ID, plusTier: 2 }, + ]); + + await BuildRepository.create(baseArgs()); + + const [weapon] = await buildWeaponsByBuildId(await onlyBuildId()); + expect(weapon.sortValue).toBe(5); + }); + + test("is null for private builds regardless of tier", async () => { + await AdminRepository.replacePlusTiers([ + { userId: OWNER_ID, plusTier: 1 }, + ]); + + await BuildRepository.create(baseArgs({ private: 1 })); + + const [weapon] = await buildWeaponsByBuildId(await onlyBuildId()); + expect(weapon.sortValue).toBeNull(); + }); + + test("subtracts 1 when the weapon is top500 for the owner", async () => { + const playerId = await insertSplatoonPlayer(OWNER_ID, "owner-spl-id"); + await insertXRankPlacement(playerId, SPLATTERSHOT, 1); + + await BuildRepository.create( + baseArgs({ weaponSplIds: [SPLATTERSHOT, SPLATTERSHOT_NOUVEAU] }), + ); + + const weapons = await buildWeaponsByBuildId(await onlyBuildId()); + const splattershot = weapons.find((w) => w.weaponSplId === SPLATTERSHOT); + const nouveau = weapons.find( + (w) => w.weaponSplId === SPLATTERSHOT_NOUVEAU, + ); + + expect(splattershot?.sortValue).toBe(8); + expect(nouveau?.sortValue).toBe(9); + }); + + test("combines top500 with the owner's PlusTier", async () => { + await AdminRepository.replacePlusTiers([ + { userId: OWNER_ID, plusTier: 1 }, + ]); + const playerId = await insertSplatoonPlayer(OWNER_ID, "owner-spl-id"); + await insertXRankPlacement(playerId, SPLATTERSHOT, 1); + + await BuildRepository.create(baseArgs()); + + const [weapon] = await buildWeaponsByBuildId(await onlyBuildId()); + expect(weapon.sortValue).toBe(2); + }); + }); + + test("allByWeaponId.weapons[].isTop500 matches the sortValue formula", async () => { + const playerId = await insertSplatoonPlayer(OWNER_ID, "owner-spl-id"); + await insertXRankPlacement(playerId, SPLATTERSHOT, 1); + + await BuildRepository.create( + baseArgs({ weaponSplIds: [SPLATTERSHOT, SPLATTERSHOT_NOUVEAU] }), + ); + + const [build] = await BuildRepository.allByWeaponId(SPLATTERSHOT, { + limit: 10, + }); + + const splattershot = build.weapons.find( + (w) => w.weaponSplId === SPLATTERSHOT, + ); + const nouveau = build.weapons.find( + (w) => w.weaponSplId === SPLATTERSHOT_NOUVEAU, + ); + + expect(splattershot?.isTop500).toBe(1); + expect(nouveau?.isTop500).toBe(0); + }); + + test("a multi-weapon build is returned by allByWeaponId for each of its weapons", async () => { + await BuildRepository.create( + baseArgs({ + title: "Multi-weapon Build", + weaponSplIds: [SPLATTERSHOT, SPLATTERSHOT_NOUVEAU], + }), + ); + + const splattershotBuilds = await BuildRepository.allByWeaponId( + SPLATTERSHOT, + { limit: 10 }, + ); + const nouveauBuilds = await BuildRepository.allByWeaponId( + SPLATTERSHOT_NOUVEAU, + { limit: 10 }, + ); + + expect(splattershotBuilds).toHaveLength(1); + expect(splattershotBuilds[0].title).toBe("Multi-weapon Build"); + expect(nouveauBuilds).toHaveLength(1); + expect(nouveauBuilds[0].id).toBe(splattershotBuilds[0].id); + }); +}); + +describe("BuildRepository.popularAbilitiesByWeaponId", () => { + // All SS: each gear sums to 10 (main) + 3*3 (subs) = 19, total 57. + const SS_ABILITIES: BuildAbilitiesTuple = [ + ["SS", "SS", "SS", "SS"], + ["SS", "SS", "SS", "SS"], + ["SS", "SS", "SS", "SS"], + ]; + + beforeEach(async () => { + await dbInsertUsers(2); + }); + + afterEach(() => { + dbReset(); + }); + + test("counts each user at most once across signature buckets", async () => { + // Each user has two Splattershot builds with different signatures. + // Without per-user dedup, both users would inflate both buckets and + // the total count across rows would be 4 instead of <=2. + await BuildRepository.create(baseArgs({ ownerId: 1 })); + await BuildRepository.create( + baseArgs({ ownerId: 1, abilities: SS_ABILITIES }), + ); + await BuildRepository.create(baseArgs({ ownerId: 2 })); + await BuildRepository.create( + baseArgs({ ownerId: 2, abilities: SS_ABILITIES }), + ); + + const rows = await BuildRepository.popularAbilitiesByWeaponId(SPLATTERSHOT); + const totalCount = rows.reduce((acc, row) => acc + row.count, 0); + + expect(totalCount).toBeLessThanOrEqual(2); + expect(rows.every((row) => row.count <= 2)).toBe(true); + }); + + test("only counts public builds", async () => { + await BuildRepository.create(baseArgs({ ownerId: 1 })); + await BuildRepository.create(baseArgs({ ownerId: 2, private: 1 })); + + const rows = await BuildRepository.popularAbilitiesByWeaponId(SPLATTERSHOT); + + // only one user with a public build → filtered by HAVING count > 1 + expect(rows).toHaveLength(0); + }); + + test("folds alt skins via canonicalWeaponSplId", async () => { + await BuildRepository.create(baseArgs({ ownerId: 1 })); + await BuildRepository.create( + baseArgs({ ownerId: 2, weaponSplIds: [HERO_SHOT_REPLICA] }), + ); + + const rows = await BuildRepository.popularAbilitiesByWeaponId(SPLATTERSHOT); + + expect(rows).toEqual([ + { abilitiesSignature: EXPECTED_SIGNATURE, count: 2 }, + ]); + // the alt-skin id alone should also resolve to the same canonical bucket + const altRows = + await BuildRepository.popularAbilitiesByWeaponId(HERO_SHOT_REPLICA); + expect(altRows).toEqual(rows); + }); +}); diff --git a/app/features/builds/BuildRepository.server.ts b/app/features/builds/BuildRepository.server.ts index 3c3f7e96e..f61cd8815 100644 --- a/app/features/builds/BuildRepository.server.ts +++ b/app/features/builds/BuildRepository.server.ts @@ -1,8 +1,7 @@ -import type { ExpressionBuilder, Transaction } from "kysely"; +import { type NotNull, sql, type Transaction } from "kysely"; import { jsonArrayFrom } from "kysely/helpers/sqlite"; -import * as R from "remeda"; import { db } from "~/db/sql"; -import type { BuildWeapon, DB, Tables, TablesInsertable } from "~/db/tables"; +import type { BuildWeapon, DB, TablesInsertable } from "~/db/tables"; import { modesShort } from "~/modules/in-game-lists/modes"; import type { Ability, @@ -10,11 +9,15 @@ import type { MainWeaponId, ModeShort, } from "~/modules/in-game-lists/types"; -import { weaponIdToArrayWithAlts } from "~/modules/in-game-lists/weapon-ids"; +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"; @@ -43,14 +46,14 @@ export async function allByUserId( "Build.shoesGearSplId", "Build.updatedAt", "Build.private", + "Build.abilities", jsonArrayFrom( eb .selectFrom("BuildWeapon") - .select(["BuildWeapon.weaponSplId", "BuildWeapon.isTop500"]) + .select(["BuildWeapon.weaponSplId", "BuildWeapon.sortValue"]) .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)) @@ -58,35 +61,7 @@ export async function allByUserId( .orderBy("Build.updatedAt", "desc") .execute(); - return rows.map((row) => { - const abilities = dbAbilitiesToArrayOfArrays(row.abilities); - - return { - ...row, - abilities: shouldSortAbilities ? sortAbilities(abilities) : abilities, - }; - }); -} - -const gearOrder: Array = [ - "HEAD", - "CLOTHES", - "SHOES", -]; -function dbAbilitiesToArrayOfArrays( - abilities: Array< - Pick - >, -): BuildAbilitiesTuple { - const sorted = R.sortBy( - abilities, - (a) => gearOrder.indexOf(a.gearType), - (a) => a.slotIndex, - ).map((a) => a.ability); - - invariant(sorted.length === 12, "expected 12 abilities"); - - return R.chunk(sorted, 4) as BuildAbilitiesTuple; + return rows.map((row) => buildRowToResult(row, shouldSortAbilities)); } interface CreateArgs { @@ -102,103 +77,36 @@ interface CreateArgs { private: TablesInsertable["Build"]["private"]; } -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)), - ); -} - -async function createInTrx({ - args, - trx, -}: { - args: CreateArgs; - trx: Transaction; -}) { - const { id: buildId, updatedAt } = 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, - }) - .returningAll() - .executeTakeFirstOrThrow(); - - await populateBuildChildrenInTrx({ trx, buildId, updatedAt, args }); -} - -async function populateBuildChildrenInTrx({ - trx, - buildId, - updatedAt, - args, -}: { - trx: Transaction; - buildId: number; - updatedAt: number; - args: CreateArgs; -}) { - 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 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") @@ -214,7 +122,10 @@ export async function create(args: CreateArgs) { export async function update(args: CreateArgs & { id: number }) { return db.transaction().execute(async (trx) => { - const { updatedAt } = await trx + const computed = await computeBuildData(trx, args); + const updatedAt = dateToDatabaseTimestamp(new Date()); + + await trx .updateTable("Build") .set({ title: args.title, @@ -224,26 +135,32 @@ export async function update(args: CreateArgs & { id: number }) { clothesGearSplId: args.clothesGearSplId, shoesGearSplId: args.shoesGearSplId, private: args.private, - updatedAt: dateToDatabaseTimestamp(new Date()), + abilities: JSON.stringify(args.abilities), + abilitiesSignature: computed.abilitiesSignature, + updatedAt, }) .where("id", "=", args.id) - .returning("updatedAt") - .executeTakeFirstOrThrow(); + .execute(); await trx .deleteFrom("BuildWeapon") .where("buildId", "=", args.id) .execute(); await trx - .deleteFrom("BuildAbility") + .deleteFrom("BuildAbilitySum") + .where("buildId", "=", args.id) + .execute(); + await trx + .deleteFrom("BuildWeaponAbility") .where("buildId", "=", args.id) .execute(); - await populateBuildChildrenInTrx({ + await insertBuildChildrenInTrx({ trx, buildId: args.id, - updatedAt, args, + computed, + updatedAt, }); }); } @@ -263,53 +180,79 @@ export async function ownerIdById(buildId: number) { } 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("BuildAbility") + .selectFrom("BuildAbilitySum") .select(({ fn }) => [ - "BuildAbility.ability", - fn.sum("BuildAbility.abilityPoints").as("abilityPointsSum"), + "BuildAbilitySum.ability", + fn.sum("BuildAbilitySum.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) + .groupBy("BuildAbilitySum.ability") .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"), + // 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"), ]) - .where("BuildWeapon.weaponSplId", "=", weaponSplId) - .where("Build.private", "=", 0) - .groupBy("Build.ownerId") // consider only one build per user + .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(); - - return result as Array<{ - abilities: Array<{ - ability: Ability; - abilityPoints: number; - }>; - }>; } export type AverageAbilityPointsResult = Awaited< ReturnType >[number]; -export type AbilitiesByWeapon = Awaited< +export type PopularBuildsRow = Awaited< ReturnType >[number]; @@ -318,38 +261,8 @@ export async function allByWeaponId( options: { limit: number; sortAbilities?: boolean }, ) { const { limit, sortAbilities: shouldSortAbilities = false } = options; - const weaponIds = weaponIdToArrayWithAlts(weaponId); - // 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 rows = R.pipe( - allResults.flat(), - R.sortBy( - (row) => row.bwTier, - [(row) => row.bwIsTop500, "desc"], - [(row) => row.bwUpdatedAt, "desc"], - ), - R.uniqueBy((row) => row.id), - R.take(limit), - ); - - return rows.map((row) => { - const abilities = dbAbilitiesToArrayOfArrays(row.abilities); - - return { - ...row, - abilities: shouldSortAbilities ? sortAbilities(abilities) : abilities, - }; - }); -} - -function buildsByWeaponIdQuery(weaponSplId: MainWeaponId, limit: number) { - return db + const rows = await db .selectFrom("BuildWeapon") .innerJoin("Build", "Build.id", "BuildWeapon.buildId") .innerJoin("User", "User.id", "Build.ownerId") @@ -364,99 +277,268 @@ function buildsByWeaponIdQuery(weaponSplId: MainWeaponId, limit: number) { "Build.shoesGearSplId", "Build.updatedAt", "Build.private", + "Build.abilities", "PlusTier.tier as plusTier", - "BuildWeapon.tier as bwTier", - "BuildWeapon.isTop500 as bwIsTop500", - "BuildWeapon.updatedAt as bwUpdatedAt", - withAbilities(eb), + commonUserJsonObject(eb).as("owner"), jsonArrayFrom( eb - .selectFrom("BuildWeapon as BuildWeaponInner") - .select(["BuildWeaponInner.weaponSplId", "BuildWeaponInner.isTop500"]) - .orderBy("BuildWeaponInner.weaponSplId", "asc") - .whereRef("BuildWeaponInner.buildId", "=", "Build.id"), + .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"), - commonUserJsonObject(eb).as("owner"), ]) - .where("Build.private", "=", 0) - .where("BuildWeapon.weaponSplId", "=", weaponSplId) - .orderBy("BuildWeapon.tier", "asc") - .orderBy("BuildWeapon.isTop500", "desc") + .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)); } -function withAbilities(eb: ExpressionBuilder) { - return jsonArrayFrom( - eb - .selectFrom("BuildAbility") - .select([ - "BuildAbility.gearType", - "BuildAbility.ability", - "BuildAbility.slotIndex", - ]) - .whereRef("BuildAbility.buildId", "=", "Build.id"), - ).as("abilities"); -} - -function hasXRankPlacement(eb: ExpressionBuilder) { - return eb.exists( - eb - .selectFrom("Build") - .select("BuildWeapon.buildId") - .innerJoin("SplatoonPlayer", "SplatoonPlayer.userId", "Build.ownerId") - .innerJoin( - "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() { +/** 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({ - tier: 4, + 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(); - 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) => + // 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") - .select("Build.id") - .where("Build.ownerId", "in", tierMembers), - ) - .execute(); - } + .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(); + } +} diff --git a/app/features/user-page/user-page-schemas.ts b/app/features/user-page/user-page-schemas.ts index 642edf4a7..45d1ea717 100644 --- a/app/features/user-page/user-page-schemas.ts +++ b/app/features/user-page/user-page-schemas.ts @@ -302,6 +302,7 @@ export const newBuildBaseSchema = z.object({ maxCount: 5, disableSorting: true, disableFavorites: true, + disableAltSkinDuplicates: true, }), head: customField({ initialValue: null }, headGearIdSchema), clothes: customField({ initialValue: null }, clothesGearIdSchema), diff --git a/app/form/fields.ts b/app/form/fields.ts index 6fb8cf96d..576dce086 100644 --- a/app/form/fields.ts +++ b/app/form/fields.ts @@ -1,5 +1,6 @@ import * as R from "remeda"; import { z } from "zod"; +import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids"; import { date, falsyToNull, @@ -562,6 +563,14 @@ export function weaponPool( ); } + if (args.disableAltSkinDuplicates) { + schema = schema.refine( + (val) => + val.length === + R.uniqueBy(val, (item) => canonicalWeaponSplId(item.id)).length, + ); + } + return schema.register(formRegistry, { ...args, label: prefixKey(args.label), diff --git a/app/form/fields/WeaponPoolFormField.tsx b/app/form/fields/WeaponPoolFormField.tsx index 0db34ce3f..515be77e4 100644 --- a/app/form/fields/WeaponPoolFormField.tsx +++ b/app/form/fields/WeaponPoolFormField.tsx @@ -22,6 +22,7 @@ import { SendouButton } from "~/components/elements/Button"; import { WeaponImage } from "~/components/Image"; import { WeaponSelect } from "~/components/WeaponSelect"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { weaponIdToArrayWithAlts } from "~/modules/in-game-lists/weapon-ids"; import type { FormFieldProps } from "../types"; import { FormFieldWrapper } from "./FormFieldWrapper"; @@ -45,6 +46,7 @@ export function WeaponPoolFormField({ maxCount, disableSorting, disableFavorites, + disableAltSkinDuplicates, value, onChange, onBlur, @@ -60,7 +62,9 @@ export function WeaponPoolFormField({ }), ); - const disabledWeaponIds = value.map((weapon) => weapon.id); + const disabledWeaponIds = disableAltSkinDuplicates + ? value.flatMap((weapon) => weaponIdToArrayWithAlts(weapon.id)) + : value.map((weapon) => weapon.id); const handleSelect = (weaponId: MainWeaponId) => { const newWeapon = { diff --git a/app/form/types.ts b/app/form/types.ts index c9274a008..d9a5cd873 100644 --- a/app/form/types.ts +++ b/app/form/types.ts @@ -102,6 +102,8 @@ interface FormFieldWeaponPool extends FormFieldBase { disableFavorites?: boolean; /** Allow duplicate weapon IDs in the pool */ allowDuplicates?: boolean; + /** Treat alt-skin variants of an already-picked weapon as duplicates (e.g. picking Splattershot also disables Hero Shot Replica) */ + disableAltSkinDuplicates?: boolean; } interface FormFieldMapPool extends FormFieldBase { diff --git a/app/modules/in-game-lists/weapon-ids.ts b/app/modules/in-game-lists/weapon-ids.ts index e5de10125..1da74305f 100644 --- a/app/modules/in-game-lists/weapon-ids.ts +++ b/app/modules/in-game-lists/weapon-ids.ts @@ -90,7 +90,7 @@ const weaponIdToAltId = new Map([ [7010, 7015], [8000, 8005], ]); -export const altWeaponIdToId = new Map([ +const altWeaponIdToId = new Map([ [45, 40], [47, 40], [46, 41], @@ -106,6 +106,29 @@ export const altWeaponIdToId = new Map([ [8005, 8000], ]); +/** + * Folds an alt-skin weapon id to its base id, leaving non-alt ids untouched. + * + * Unlike {@link weaponIdToBaseWeaponId}, alt kits are preserved (e.g. Tentatek + * Splattershot stays 41) — only cosmetic alt skins collapse to their base. + * + * Mirrors the `BuildWeapon.canonicalWeaponSplId` column so TypeScript callers + * and SQL queries agree on what a weapon's canonical id is. + * + * @example + * // Splattershot — base weapon, returned unchanged + * canonicalWeaponSplId(40); // -> 40 + * + * // Tentatek Splattershot — alt kit, returned unchanged + * canonicalWeaponSplId(41); // -> 41 + * + * // Hero Shot Replica — alt skin, folded to Splattershot + * canonicalWeaponSplId(45); // -> 40 + */ +export function canonicalWeaponSplId(weaponSplId: MainWeaponId): MainWeaponId { + return altWeaponIdToId.get(weaponSplId) ?? weaponSplId; +} + /** * Converts a given weapon ID to an array containing the weapon ID and its alternate IDs. For example if you enter the ID 40 (Splattershot) it will return [40, 45, 47] (Splattershot, Hero Shot Replica, Order Shot Replica) */ diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 3d78d6a85..71c1c353f 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/seeds/db-seed-AB_RR.sqlite3 b/e2e/seeds/db-seed-AB_RR.sqlite3 index d54121e5a..28c98f243 100644 Binary files a/e2e/seeds/db-seed-AB_RR.sqlite3 and b/e2e/seeds/db-seed-AB_RR.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 7cda91bea..51bb2da10 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 index 78a91aa8d..b2ccf4cda 100644 Binary files a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ diff --git a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 index 0a06ba5a2..b1b4756c0 100644 Binary files a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 and b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 0834c38a5..5240dd83f 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index 2416fe361..5c6587355 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index d2ebbb102..7295ec087 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index c412600de..cd17d3164 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index 4dd205c7c..f5d35af8d 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index 8e4b0ad27..8bc4ffdff 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 index e14215013..6e1bb68d0 100644 Binary files a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 and b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 differ diff --git a/migrations/142-builds-optimization.js b/migrations/142-builds-optimization.js new file mode 100644 index 000000000..d420817be --- /dev/null +++ b/migrations/142-builds-optimization.js @@ -0,0 +1,255 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ `alter table "Build" add column "abilities" text`, + ).run(); + + db.prepare( + /* sql */ `alter table "Build" add column "abilitiesSignature" text`, + ).run(); + + db.prepare( + /* sql */ ` + create table "BuildAbilitySum" ( + "buildId" integer not null, + "ability" text not null, + "abilityPoints" integer not null, + foreign key ("buildId") references "Build"("id") on delete cascade, + unique("buildId", "ability") on conflict rollback + ) strict + `, + ).run(); + + db.prepare( + /* sql */ ` + create table "BuildWeaponAbility" ( + "canonicalWeaponSplId" integer not null, + "buildId" integer not null, + "ability" text not null, + "abilityPoints" integer not null, + foreign key ("buildId") references "Build"("id") on delete cascade, + unique("canonicalWeaponSplId", "buildId", "ability") on conflict rollback + ) strict + `, + ).run(); + + db.prepare( + /* sql */ ` + update "Build" set "abilities" = ( + select json_array( + ( + select json_group_array("ability") from ( + select "ability" from "BuildAbility" + where "buildId" = "Build"."id" and "gearType" = 'HEAD' + order by "slotIndex" + ) + ), + ( + select json_group_array("ability") from ( + select "ability" from "BuildAbility" + where "buildId" = "Build"."id" and "gearType" = 'CLOTHES' + order by "slotIndex" + ) + ), + ( + select json_group_array("ability") from ( + select "ability" from "BuildAbility" + where "buildId" = "Build"."id" and "gearType" = 'SHOES' + order by "slotIndex" + ) + ) + ) + ) + `, + ).run(); + + // Public builds only: private builds are never surfaced in stats/popular + // queries, so excluding them here turns the sum-table queries into pure + // covering-index scans (no Build join needed for the private filter). + db.prepare( + /* sql */ ` + insert into "BuildAbilitySum" ("buildId", "ability", "abilityPoints") + select "ba"."buildId", "ba"."ability", sum("ba"."abilityPoints") + from "BuildAbility" as "ba" + inner join "Build" as "b" on "b"."id" = "ba"."buildId" + where "b"."private" = 0 + group by "ba"."buildId", "ba"."ability" + `, + ).run(); + + // Alt skins are folded into their base weapon so a build that lists + // multiple alt skins of the same weapon only contributes its ability + // points once. `insert or ignore` handles the dedup against the + // unique(canonicalWeaponSplId, buildId, ability). + db.prepare( + /* sql */ ` + insert or ignore into "BuildWeaponAbility" ("canonicalWeaponSplId", "buildId", "ability", "abilityPoints") + select + case "bw"."weaponSplId" + when 45 then 40 + when 47 then 40 + when 46 then 41 + when 205 then 200 + when 1015 then 1010 + when 1115 then 1110 + when 2015 then 2010 + when 3005 then 3000 + when 4015 then 4010 + when 5015 then 5010 + when 6005 then 6000 + when 7015 then 7010 + when 8005 then 8000 + else "bw"."weaponSplId" + end, + "bs"."buildId", "bs"."ability", "bs"."abilityPoints" + from "BuildAbilitySum" as "bs" + inner join "BuildWeapon" as "bw" on "bw"."buildId" = "bs"."buildId" + `, + ).run(); + + db.prepare( + /* sql */ ` + update "Build" set "abilitiesSignature" = ( + select group_concat("ability" || '_' || "abilityPoints", ',') from ( + select "ability", "abilityPoints" from "BuildAbilitySum" + where "buildId" = "Build"."id" + order by "abilityPoints" desc, "ability" asc + ) + ) + `, + ).run(); + + // Covering indexes: `abilityPoints` included so SUM(...) GROUP BY ability + // can be answered entirely from the index without rowid lookups. + db.prepare( + /* sql */ `create index build_ability_sum_ability_ap on "BuildAbilitySum"("ability", "abilityPoints")`, + ).run(); + + db.prepare( + /* sql */ `create index build_weapon_ability_weapon_ability_ap on "BuildWeaponAbility"("canonicalWeaponSplId", "ability", "abilityPoints")`, + ).run(); + + db.prepare( + /* sql */ `create index build_abilities_signature on "Build"("abilitiesSignature")`, + ).run(); + + // sortValue lives only on BuildWeapon — it depends on the owner's plus + // tier and on whether the owner has an X Rank placement for *this + // specific weapon*. The `(weaponSplId, sortValue, updatedAt DESC, + // buildId)` covering index below answers builds-by-weapon end-to-end. + db.prepare( + /* sql */ `alter table "BuildWeapon" add column "sortValue" integer`, + ).run(); + + // Pass 1: tier*2 + 1 for public, NULL for private. Mirror Build.updatedAt. + db.prepare( + /* sql */ ` + update "BuildWeapon" set + "updatedAt" = (select "updatedAt" from "Build" where "id" = "BuildWeapon"."buildId"), + "sortValue" = ( + 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" + ) + `, + ).run(); + + // Pass 2: subtract 1 where THIS weapon is top500 for the owner. Per-weapon, + // not per-build, so a build with Luna Blaster + Blaster gets the crown only + // on the weapon the owner actually placed with. + db.prepare( + /* sql */ ` + update "BuildWeapon" set "sortValue" = "sortValue" - 1 + where "sortValue" is not null + and exists ( + select 1 + from "Build" as "b" + inner join "SplatoonPlayer" as "sp" on "sp"."userId" = "b"."ownerId" + inner join "XRankPlacement" as "xrp" + on "xrp"."playerId" = "sp"."id" + and "xrp"."weaponSplId" = "BuildWeapon"."weaponSplId" + where "b"."id" = "BuildWeapon"."buildId" + ) + `, + ).run(); + + db.prepare(/* sql */ `drop index "idx_buildweapon_lookup"`).run(); + db.prepare( + /* sql */ `drop index "build_weapon_weapon_spl_id_build_id"`, + ).run(); + + // Canonicalize weaponSplId: alt skins collapse to their base weapon so + // the builds-by-weapon and popular/stats queries can filter with a + // single `= ?` against the covering index instead of an IN-list across + // alt skins. + db.prepare( + /* sql */ `alter table "BuildWeapon" add column "canonicalWeaponSplId" integer`, + ).run(); + + db.prepare( + /* sql */ ` + update "BuildWeapon" set "canonicalWeaponSplId" = case "weaponSplId" + when 45 then 40 + when 47 then 40 + when 46 then 41 + when 205 then 200 + when 1015 then 1010 + when 1115 then 1110 + when 2015 then 2010 + when 3005 then 3000 + when 4015 then 4010 + when 5015 then 5010 + when 6005 then 6000 + when 7015 then 7010 + when 8005 then 8000 + else "weaponSplId" + end + `, + ).run(); + + // Collapse alt-skin duplicates within a build down to one row per + // canonical weapon. Prefer the base weapon (weaponSplId = canonical), + // falling back to the lowest weaponSplId when only alts are present. + db.prepare( + /* sql */ ` + delete from "BuildWeapon" where rowid in ( + select rowid from ( + select rowid, + row_number() over ( + partition by "buildId", "canonicalWeaponSplId" + order by ("weaponSplId" = "canonicalWeaponSplId") desc, "weaponSplId" asc + ) as rn + from "BuildWeapon" + ) where rn > 1 + ) + `, + ).run(); + + db.prepare( + /* sql */ `create unique index build_weapon_canonical_unique on "BuildWeapon"("buildId", "canonicalWeaponSplId")`, + ).run(); + + // `updatedAt desc` matches the query's ORDER BY direction so the index + // also covers the secondary sort — no temp B-tree. + db.prepare( + /* sql */ `create index build_weapon_lookup on "BuildWeapon"("canonicalWeaponSplId", "sortValue", "updatedAt" desc, "buildId")`, + ).run(); + + // Drop dead schema: `BuildAbility` is fully replaced by `Build.abilities` + // (JSON), `BuildAbilitySum` and `BuildWeaponAbility`. `BuildWeapon`'s + // `isTop500` / `tier` are folded into `Build.sortValue` / `BuildWeapon.sortValue`. + db.prepare(/* sql */ `drop table "BuildAbility"`).run(); + db.prepare( + /* sql */ `alter table "BuildWeapon" drop column "isTop500"`, + ).run(); + db.prepare(/* sql */ `alter table "BuildWeapon" drop column "tier"`).run(); + + db.pragma("foreign_key_check"); + })(); +} diff --git a/scripts/placements/index.ts b/scripts/placements/index.ts index 4d6a67300..fe12a35a6 100644 --- a/scripts/placements/index.ts +++ b/scripts/placements/index.ts @@ -56,7 +56,7 @@ async function main() { addPlacements(placements); await XRankPlacementRepository.refreshAllPeakXp(); await BadgeRepository.syncXPBadges(); - await BuildRepository.recalculateAllTop500(); + await BuildRepository.recalculateAllSortValues(); logger.info(`done reading in ${placements.length} placements`); } diff --git a/scripts/recalculate-build-data.ts b/scripts/recalculate-build-data.ts deleted file mode 100644 index d3e2fd245..000000000 --- a/scripts/recalculate-build-data.ts +++ /dev/null @@ -1,26 +0,0 @@ -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"); -}