mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-23 03:46:28 -05:00
Optimize builds loaders (#3076)
This commit is contained in:
parent
9635f386b2
commit
8dc92140fc
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
})
|
||||
: [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()!;
|
||||
|
|
|
|||
|
|
@ -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<number>;
|
||||
/** 3x4 ability tuple (head/clothes/shoes × main + 3 subs). */
|
||||
abilities: JSONColumnTypeNullable<BuildAbilitiesTuple>;
|
||||
/** 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<number>;
|
||||
}
|
||||
|
||||
export interface BuildWeapon {
|
||||
buildId: number;
|
||||
weaponSplId: MainWeaponId;
|
||||
/** Has the owner of this build reached top 500 of X Rank with this weapon? Denormalized for performance reasons. */
|
||||
isTop500: Generated<DBBoolean>;
|
||||
/** Plus tier or 4 if none. Denormalized for performance reasons. */
|
||||
tier: Generated<number>;
|
||||
/** Last time the build was updated. Denormalized for performance reasons. */
|
||||
/** 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<number>;
|
||||
/** 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;
|
||||
|
|
|
|||
|
|
@ -240,7 +240,8 @@ export async function linkUserAndPlayer({
|
|||
.execute();
|
||||
|
||||
await BadgeRepository.syncXPBadges();
|
||||
await BuildRepository.recalculateAllTop500();
|
||||
|
||||
await BuildRepository.recalculateAllSortValues(userId);
|
||||
}
|
||||
|
||||
export function forcePatron(args: {
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
await plusTiersFromVotingAndLeaderboard(),
|
||||
);
|
||||
|
||||
await BuildRepository.recalculateAllTiers();
|
||||
await BuildRepository.recalculateAllSortValues();
|
||||
|
||||
message = "Plus tiers refreshed";
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Ability, number>;
|
||||
|
||||
const POPULAR_BUILDS_TO_SHOW = 25;
|
||||
|
||||
export function popularBuilds(builds: Array<AbilitiesByWeapon>) {
|
||||
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<PopularBuildsRow>) {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
381
app/features/builds/BuildRepository.server.test.ts
Normal file
381
app/features/builds/BuildRepository.server.test.ts
Normal file
|
|
@ -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<Parameters<typeof BuildRepository.create>[0]> = {},
|
||||
): Parameters<typeof BuildRepository.create>[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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Tables["BuildAbility"]["gearType"]> = [
|
||||
"HEAD",
|
||||
"CLOTHES",
|
||||
"SHOES",
|
||||
];
|
||||
function dbAbilitiesToArrayOfArrays(
|
||||
abilities: Array<
|
||||
Pick<Tables["BuildAbility"], "ability" | "gearType" | "slotIndex">
|
||||
>,
|
||||
): 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<ModeShort> | 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<DB>;
|
||||
}) {
|
||||
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<DB>;
|
||||
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<number>("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<number>("BuildAbility.abilityPoints").as("abilityPointsSum"),
|
||||
"BuildAbilitySum.ability",
|
||||
fn.sum<number>("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<number>("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<typeof abilityPointAverages>
|
||||
>[number];
|
||||
|
||||
export type AbilitiesByWeapon = Awaited<
|
||||
export type PopularBuildsRow = Awaited<
|
||||
ReturnType<typeof popularAbilitiesByWeaponId>
|
||||
>[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<DB, "Build">) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("BuildAbility")
|
||||
.select([
|
||||
"BuildAbility.gearType",
|
||||
"BuildAbility.ability",
|
||||
"BuildAbility.slotIndex",
|
||||
])
|
||||
.whereRef("BuildAbility.buildId", "=", "Build.id"),
|
||||
).as("abilities");
|
||||
}
|
||||
|
||||
function hasXRankPlacement(eb: ExpressionBuilder<DB, "BuildWeapon">) {
|
||||
return eb.exists(
|
||||
eb
|
||||
.selectFrom("Build")
|
||||
.select("BuildWeapon.buildId")
|
||||
.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<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();
|
||||
|
||||
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<number>`"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<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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -102,6 +102,8 @@ interface FormFieldWeaponPool<T extends string> extends FormFieldBase<T> {
|
|||
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<T extends string> extends FormFieldBase<T> {
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ const weaponIdToAltId = new Map<MainWeaponId, MainWeaponId | MainWeaponId[]>([
|
|||
[7010, 7015],
|
||||
[8000, 8005],
|
||||
]);
|
||||
export const altWeaponIdToId = new Map<MainWeaponId, MainWeaponId>([
|
||||
const altWeaponIdToId = new Map<MainWeaponId, MainWeaponId>([
|
||||
[45, 40],
|
||||
[47, 40],
|
||||
[46, 41],
|
||||
|
|
@ -106,6 +106,29 @@ export const altWeaponIdToId = new Map<MainWeaponId, MainWeaponId>([
|
|||
[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)
|
||||
*/
|
||||
|
|
|
|||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
255
migrations/142-builds-optimization.js
Normal file
255
migrations/142-builds-optimization.js
Normal file
|
|
@ -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");
|
||||
})();
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user