mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-20 10:04:57 -05:00
Fix even more bugs
This commit is contained in:
@@ -10,9 +10,10 @@ import { isAbility } from "~/modules/in-game-lists/utils";
|
||||
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
|
||||
import { analyzerSearchParams } from "./analyzer-search-params";
|
||||
import type { SpecialEffectType } from "./analyzer-types";
|
||||
import { buildToAbilityPoints } from "./core/ability-points";
|
||||
import { applySpecialEffects, SPECIAL_EFFECTS } from "./core/specialEffects";
|
||||
import { buildStats } from "./core/stats";
|
||||
import { buildIsEmpty, buildToAbilityPoints } from "./core/utils";
|
||||
import { buildIsEmpty } from "./core/utils";
|
||||
|
||||
export function useAnalyzeBuild() {
|
||||
const [params, setParams] = useSearchParamsTyped(analyzerSearchParams);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { AbilityWithUnknown } from "~/modules/in-game-lists/types";
|
||||
import { buildToAbilityPoints } from "./utils";
|
||||
import { buildToAbilityPoints } from "./ability-points";
|
||||
|
||||
describe("buildToAbilityPoints", () => {
|
||||
const EMPTY_ROW: [
|
||||
58
app/features/build-analyzer/core/ability-points.ts
Normal file
58
app/features/build-analyzer/core/ability-points.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { abilities } from "~/modules/in-game-lists/abilities";
|
||||
import type {
|
||||
Ability,
|
||||
AbilityWithUnknown,
|
||||
BuildAbilitiesTupleWithUnknown,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { MAIN_SLOT_AP, SUB_SLOT_AP } from "../analyzer-constants";
|
||||
import type { AbilityPoints } from "../analyzer-types";
|
||||
|
||||
/**
|
||||
* Sums a build's stackable ability points per ability, accounting for
|
||||
* Ability Doubler doubling the sub slots of its row. Main-only abilities are
|
||||
* left out as they have no ability point value.
|
||||
*/
|
||||
export function buildToAbilityPoints(build: BuildAbilitiesTupleWithUnknown) {
|
||||
const result: AbilityPoints = new Map();
|
||||
|
||||
for (const abilityRow of build) {
|
||||
let abilityDoublerActive = false;
|
||||
for (const [i, ability] of abilityRow.entries()) {
|
||||
if (ability === "AD") {
|
||||
abilityDoublerActive = true;
|
||||
}
|
||||
if (!isStackableAbility(ability) && ability !== "UNKNOWN") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const aps = i === 0 ? MAIN_SLOT_AP : SUB_SLOT_AP;
|
||||
const apsDoubled = aps * (abilityDoublerActive ? 2 : 1);
|
||||
const newAp = (result.get(ability) ?? 0) + apsDoubled;
|
||||
|
||||
result.set(ability, newAp);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Whether the ability stacks in sub slots as ability points (e.g. ISM) as opposed to a main-only ability (e.g. SJ). */
|
||||
export function isStackableAbility(
|
||||
ability: AbilityWithUnknown,
|
||||
): ability is Ability {
|
||||
if (ability === "UNKNOWN") return false;
|
||||
const abilityObj = abilities.find((a) => a.name === ability);
|
||||
invariant(abilityObj);
|
||||
|
||||
return abilityObj.type === "STACKABLE";
|
||||
}
|
||||
|
||||
/** Whether the ability only exists in the main slot of one gear type (e.g. SJ). */
|
||||
export function isMainOnlyAbility(
|
||||
ability: AbilityWithUnknown,
|
||||
): ability is Ability {
|
||||
if (ability === "UNKNOWN") return false;
|
||||
|
||||
return !isStackableAbility(ability);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { abilities } from "~/modules/in-game-lists/abilities";
|
||||
import type {
|
||||
Ability,
|
||||
AbilityWithUnknown,
|
||||
BuildAbilitiesTupleWithUnknown,
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
@@ -9,7 +7,6 @@ import type {
|
||||
import { weaponIdToBaseWeaponId } from "~/modules/in-game-lists/weapon-ids";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import { MAIN_SLOT_AP, SUB_SLOT_AP } from "../analyzer-constants";
|
||||
import type {
|
||||
AbilityPoints,
|
||||
AnalyzedBuild,
|
||||
@@ -43,48 +40,6 @@ export function specialWeaponParams(
|
||||
return params.specialWeapons[specialWeaponId] as SpecialWeaponParams;
|
||||
}
|
||||
|
||||
export function buildToAbilityPoints(build: BuildAbilitiesTupleWithUnknown) {
|
||||
const result: AbilityPoints = new Map();
|
||||
|
||||
for (const abilityRow of build) {
|
||||
let abilityDoublerActive = false;
|
||||
for (const [i, ability] of abilityRow.entries()) {
|
||||
if (ability === "AD") {
|
||||
abilityDoublerActive = true;
|
||||
}
|
||||
if (!isStackableAbility(ability) && ability !== "UNKNOWN") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const aps = i === 0 ? MAIN_SLOT_AP : SUB_SLOT_AP;
|
||||
const apsDoubled = aps * (abilityDoublerActive ? 2 : 1);
|
||||
const newAp = (result.get(ability) ?? 0) + apsDoubled;
|
||||
|
||||
result.set(ability, newAp);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isStackableAbility(
|
||||
ability: AbilityWithUnknown,
|
||||
): ability is Ability {
|
||||
if (ability === "UNKNOWN") return false;
|
||||
const abilityObj = abilities.find((a) => a.name === ability);
|
||||
invariant(abilityObj);
|
||||
|
||||
return abilityObj.type === "STACKABLE";
|
||||
}
|
||||
|
||||
export function isMainOnlyAbility(
|
||||
ability: AbilityWithUnknown,
|
||||
): ability is Ability {
|
||||
if (ability === "UNKNOWN") return false;
|
||||
|
||||
return !isStackableAbility(ability);
|
||||
}
|
||||
|
||||
export function apFromMap({
|
||||
abilityPoints,
|
||||
ability,
|
||||
|
||||
@@ -77,6 +77,7 @@ import type {
|
||||
} from "../analyzer-types";
|
||||
import { INK_CONSUME_TYPES } from "../analyzer-types";
|
||||
import { PerInkTankGrid } from "../components/PerInkTankGrid";
|
||||
import { isMainOnlyAbility, isStackableAbility } from "../core/ability-points";
|
||||
import {
|
||||
ABILITIES_WITHOUT_CHUNKS,
|
||||
getAbilityChunksMapAsArray,
|
||||
@@ -86,12 +87,7 @@ import {
|
||||
SPECIAL_EFFECTS,
|
||||
} from "../core/specialEffects";
|
||||
import { buildStats } from "../core/stats";
|
||||
import {
|
||||
buildIsEmpty,
|
||||
damageIsSubWeaponDamage,
|
||||
isMainOnlyAbility,
|
||||
isStackableAbility,
|
||||
} from "../core/utils";
|
||||
import { buildIsEmpty, damageIsSubWeaponDamage } from "../core/utils";
|
||||
import styles from "./analyzer.module.css";
|
||||
|
||||
export const CURRENT_PATCH = "11.2";
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Ability } from "~/modules/in-game-lists/types";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { roundToNDecimalPlaces } from "~/utils/number";
|
||||
import { MAX_AP } from "../build-analyzer/analyzer-constants";
|
||||
import { isStackableAbility } from "../build-analyzer/core/utils";
|
||||
import { isStackableAbility } from "../build-analyzer/core/ability-points";
|
||||
import type {
|
||||
AverageAbilityPointsResult,
|
||||
PopularBuildsRow,
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as BuildFactory from "~/db/seed/factories/BuildFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import * as XRankPlacementFactory from "~/db/seed/factories/XRankPlacementFactory";
|
||||
import { db } from "~/db/sql";
|
||||
import { buildToAbilityPoints } from "~/features/build-analyzer/core/ability-points";
|
||||
import type {
|
||||
BuildAbilitiesTuple,
|
||||
MainWeaponId,
|
||||
@@ -113,6 +114,26 @@ describe("BuildRepository.insert — computeBuildData", () => {
|
||||
expect(sums).toContainEqual({ ability: "ISS", abilityPoints: 19 });
|
||||
});
|
||||
|
||||
test("agrees with the analyzer's AP calculation for Ability Doubler builds", async () => {
|
||||
const abilitiesWithDoubler: BuildAbilitiesTuple = [
|
||||
["ISM", "ISM", "ISM", "ISM"],
|
||||
["AD", "ISM", "ISM", "ISM"],
|
||||
["SJ", "ISM", "ISM", "ISM"],
|
||||
];
|
||||
const { id } = await BuildRepository.insert(
|
||||
baseArgs({ abilities: abilitiesWithDoubler }),
|
||||
);
|
||||
|
||||
const sums = await buildAbilitySumsByBuildId(id);
|
||||
const analyzerIsmAp =
|
||||
buildToAbilityPoints(abilitiesWithDoubler).get("ISM");
|
||||
|
||||
expect(sums).toContainEqual({
|
||||
ability: "ISM",
|
||||
abilityPoints: analyzerIsmAp,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not insert BuildAbilitySum rows for private builds", async () => {
|
||||
const { id } = await BuildRepository.insert(baseArgs({ isPrivate: 1 }));
|
||||
|
||||
|
||||
@@ -14,10 +14,11 @@ 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 } from "../build-analyzer/analyzer-constants";
|
||||
import {
|
||||
MAIN_SLOT_AP,
|
||||
SUB_SLOT_AP,
|
||||
} from "../build-analyzer/analyzer-constants";
|
||||
buildToAbilityPoints,
|
||||
isStackableAbility,
|
||||
} from "../build-analyzer/core/ability-points";
|
||||
import { BUILD } from "./builds-constants";
|
||||
import { sortAbilities } from "./core/ability-sorting.server";
|
||||
|
||||
@@ -475,15 +476,18 @@ async function computeBuildData(
|
||||
function computeAbilitySums(
|
||||
abilities: BuildAbilitiesTuple,
|
||||
): Array<[Ability, number]> {
|
||||
const sums = new Map<Ability, number>();
|
||||
const sums = buildToAbilityPoints(abilities);
|
||||
|
||||
// unlike the analyzer, the sums also track main-only abilities so that
|
||||
// builds differing only by them get distinct signatures
|
||||
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);
|
||||
}
|
||||
const mainAbility = row[0];
|
||||
if (isStackableAbility(mainAbility)) continue;
|
||||
|
||||
sums.set(mainAbility, (sums.get(mainAbility) ?? 0) + MAIN_SLOT_AP);
|
||||
}
|
||||
return [...sums.entries()];
|
||||
|
||||
return [...sums.entries()] as Array<[Ability, number]>;
|
||||
}
|
||||
|
||||
function serializeSignature(sums: Array<[Ability, number]>): string {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { buildToAbilityPoints } from "~/features/build-analyzer/core/utils";
|
||||
import { buildToAbilityPoints } from "~/features/build-analyzer/core/ability-points";
|
||||
import type {
|
||||
BuildAbilitiesTuple,
|
||||
ModeShort,
|
||||
|
||||
60
app/features/calendar/loaders/calendar.server.test.ts
Normal file
60
app/features/calendar/loaders/calendar.server.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { wrappedLoader } from "~/utils/Test";
|
||||
import { type CalendarLoaderData, loader } from "./calendar.server";
|
||||
|
||||
const calendarLoader = wrappedLoader<CalendarLoaderData>({ loader });
|
||||
|
||||
const eventNames = (data: CalendarLoaderData) =>
|
||||
data.eventTimes.flatMap((time) => [
|
||||
...time.events.shown.map((event) => event.name),
|
||||
...time.events.hidden.map((event) => event.name),
|
||||
]);
|
||||
|
||||
describe("calendar loader default view", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// The client resolves the shown week from the user's local clock while the
|
||||
// loader resolves the fetched week from the server's clock. Around the week
|
||||
// boundary these disagree by a full week for users ahead of UTC: e.g. in
|
||||
// Auckland (UTC+13) Monday 2026-01-12 10:00 local is still Sunday 2026-01-11
|
||||
// 21:00 UTC, so the user is shown the week Jan 12–18 but the loader only
|
||||
// fetches up to ~Jan 13. Events from Tuesday evening onwards are missing.
|
||||
test("fetches events for the whole week shown to a user ahead of UTC", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-01-11T21:00:00Z"));
|
||||
|
||||
const author = await UserFactory.createRegular();
|
||||
await CalendarEventFactory.create({
|
||||
authorId: author.id,
|
||||
name: "Midweek Cup",
|
||||
tags: null,
|
||||
startTimes: [dateToDatabaseTimestamp(new Date("2026-01-14T06:00:00Z"))],
|
||||
});
|
||||
|
||||
const data = await calendarLoader();
|
||||
|
||||
expect(eventNames(data)).toContain("Midweek Cup");
|
||||
});
|
||||
|
||||
test("control: same event is returned once the server clock reaches the same week", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-01-12T12:00:00Z"));
|
||||
|
||||
const author = await UserFactory.createRegular();
|
||||
await CalendarEventFactory.create({
|
||||
authorId: author.id,
|
||||
name: "Midweek Cup",
|
||||
tags: null,
|
||||
startTimes: [dateToDatabaseTimestamp(new Date("2026-01-14T06:00:00Z"))],
|
||||
});
|
||||
|
||||
const data = await calendarLoader();
|
||||
|
||||
expect(eventNames(data)).toContain("Midweek Cup");
|
||||
});
|
||||
});
|
||||
@@ -31,9 +31,11 @@ export const loader = async (args: LoaderFunctionArgs) => {
|
||||
|
||||
const weekStart = startOfWeek(new Date(date), { weekStartsOn: 1 });
|
||||
const events = await CalendarRepository.findAllBetweenTwoTimestamps({
|
||||
// add a bit of tolerance to the timestamps to account for timezones
|
||||
startTime: sub(weekStart, { hours: 24 }),
|
||||
endTime: add(weekStart, { days: DAYS_SHOWN_AT_A_TIME + 1 }),
|
||||
// on the default view the client resolves the shown week from its own clock,
|
||||
// which around the week boundary can be a full week ahead of or behind the
|
||||
// server's week, so fetch wide enough to cover every timezone's current week
|
||||
startTime: sub(weekStart, { days: DAYS_SHOWN_AT_A_TIME + 1 }),
|
||||
endTime: add(weekStart, { days: DAYS_SHOWN_AT_A_TIME * 2 + 1 }),
|
||||
});
|
||||
|
||||
const filters = resolveFilters(args.request, user?.preferences);
|
||||
|
||||
@@ -5,11 +5,9 @@ import { Image, ModeImage, WeaponImage } from "~/components/Image";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type { AbilityPoints } from "~/features/build-analyzer/analyzer-types";
|
||||
import { buildToAbilityPoints } from "~/features/build-analyzer/core/ability-points";
|
||||
import { getAbilityChunksMapAsArray } from "~/features/build-analyzer/core/abilityChunksCalc";
|
||||
import {
|
||||
apFromMap,
|
||||
buildToAbilityPoints,
|
||||
} from "~/features/build-analyzer/core/utils";
|
||||
import { apFromMap } from "~/features/build-analyzer/core/utils";
|
||||
import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types";
|
||||
import type {
|
||||
Ability as AbilityType,
|
||||
|
||||
68
app/features/leaderboards/core/leaderboards.server.test.ts
Normal file
68
app/features/leaderboards/core/leaderboards.server.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DEFAULT_LEADERBOARD_MAX_SIZE } from "../leaderboards-constants";
|
||||
import {
|
||||
ownEntryPeek,
|
||||
shownUserLeaderboard,
|
||||
type UserLeaderboardWithAdditionsItem,
|
||||
} from "./leaderboards.server";
|
||||
|
||||
const FIRST_TIED_RANK = DEFAULT_LEADERBOARD_MAX_SIZE - 2;
|
||||
|
||||
/**
|
||||
* Leaderboard where five players are tied in SP across the shown-size cutoff:
|
||||
* indices 497–501 (0-based) all share placementRank 498, like players who
|
||||
* finished the season having played every match in the same stack do.
|
||||
*/
|
||||
const leaderboardWithTieAcrossCutoff = () =>
|
||||
Array.from({ length: DEFAULT_LEADERBOARD_MAX_SIZE + 2 }, (_, i) => {
|
||||
const placementRank = i >= FIRST_TIED_RANK - 1 ? FIRST_TIED_RANK : i + 1;
|
||||
|
||||
return {
|
||||
id: i + 1,
|
||||
placementRank,
|
||||
power: 2100 - placementRank,
|
||||
} as unknown as UserLeaderboardWithAdditionsItem;
|
||||
});
|
||||
|
||||
describe("shownUserLeaderboard & ownEntryPeek", () => {
|
||||
test("player tied across the cutoff is visible in the table or via own entry peek", async () => {
|
||||
const leaderboard = leaderboardWithTieAcrossCutoff();
|
||||
const cutOffUserId = DEFAULT_LEADERBOARD_MAX_SIZE + 2;
|
||||
|
||||
const shownIds = shownUserLeaderboard(leaderboard).map((entry) => entry.id);
|
||||
|
||||
if (!shownIds.includes(cutOffUserId)) {
|
||||
const peek = await ownEntryPeek({
|
||||
leaderboard,
|
||||
userId: cutOffUserId,
|
||||
season: 1,
|
||||
});
|
||||
|
||||
expect(peek).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows every tied player at the cutoff rank", () => {
|
||||
const leaderboard = leaderboardWithTieAcrossCutoff();
|
||||
|
||||
const shown = shownUserLeaderboard(leaderboard);
|
||||
|
||||
expect(shown).toHaveLength(leaderboard.length);
|
||||
});
|
||||
|
||||
test("cuts players ranked below the max size", () => {
|
||||
const leaderboard = Array.from(
|
||||
{ length: DEFAULT_LEADERBOARD_MAX_SIZE + 2 },
|
||||
(_, i) =>
|
||||
({
|
||||
id: i + 1,
|
||||
placementRank: i + 1,
|
||||
power: 2100 - i,
|
||||
}) as unknown as UserLeaderboardWithAdditionsItem,
|
||||
);
|
||||
|
||||
const shown = shownUserLeaderboard(leaderboard);
|
||||
|
||||
expect(shown).toHaveLength(DEFAULT_LEADERBOARD_MAX_SIZE);
|
||||
});
|
||||
});
|
||||
@@ -145,6 +145,20 @@ export function filterByWeaponCategory<
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries of the full user leaderboard that are visible on the leaderboard
|
||||
* page. Cut by placement rank instead of entry count so that players tied
|
||||
* across the cutoff are all shown; {@link ownEntryPeek} covers exactly the
|
||||
* entries this leaves out.
|
||||
*/
|
||||
export function shownUserLeaderboard(
|
||||
leaderboard: UserLeaderboardWithAdditionsItem[],
|
||||
) {
|
||||
return leaderboard.filter(
|
||||
(entry) => entry.placementRank <= DEFAULT_LEADERBOARD_MAX_SIZE,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ownEntryPeek({
|
||||
leaderboard,
|
||||
userId,
|
||||
|
||||
@@ -13,11 +13,9 @@ import {
|
||||
cachedFullUserLeaderboard,
|
||||
filterByWeaponCategory,
|
||||
ownEntryPeek,
|
||||
shownUserLeaderboard,
|
||||
} from "../core/leaderboards.server";
|
||||
import {
|
||||
DEFAULT_LEADERBOARD_MAX_SIZE,
|
||||
WEAPON_LEADERBOARD_MAX_SIZE,
|
||||
} from "../leaderboards-constants";
|
||||
import { WEAPON_LEADERBOARD_MAX_SIZE } from "../leaderboards-constants";
|
||||
import { leaderboardsSearchParams } from "../leaderboards-search-params";
|
||||
|
||||
export const loader = async ({ url }: LoaderFunctionArgs) => {
|
||||
@@ -32,10 +30,9 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
|
||||
? await cachedFullUserLeaderboard(season)
|
||||
: null;
|
||||
|
||||
const userLeaderboard = fullUserLeaderboard?.slice(
|
||||
0,
|
||||
DEFAULT_LEADERBOARD_MAX_SIZE,
|
||||
);
|
||||
const userLeaderboard = fullUserLeaderboard
|
||||
? shownUserLeaderboard(fullUserLeaderboard)
|
||||
: undefined;
|
||||
|
||||
const teamLeaderboard =
|
||||
type === "TEAM" || type === "TEAM-ALL"
|
||||
|
||||
@@ -497,3 +497,46 @@ describe("Resolving the team a user is a member of", () => {
|
||||
expect(tournament.teamMemberOfByUser({ id: USER_ID + 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("teamById division seeds", () => {
|
||||
it("assigns unique seeds within a division when a late registrant has null startingBracketIdx", () => {
|
||||
const tournament = testTournament({
|
||||
ctx: {
|
||||
settings: {
|
||||
bracketProgression: [
|
||||
{
|
||||
name: "Div A",
|
||||
type: "round_robin",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
name: "Div B",
|
||||
type: "round_robin",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
teams: [
|
||||
// DB query orders by seed ASC which puts NULL seeds first in SQLite
|
||||
tournamentCtxTeam(5, {
|
||||
seed: null,
|
||||
startingBracketIdx: null,
|
||||
createdAt: 5,
|
||||
}),
|
||||
tournamentCtxTeam(1, { seed: 1, startingBracketIdx: 0 }),
|
||||
tournamentCtxTeam(2, { seed: 2, startingBracketIdx: 0 }),
|
||||
tournamentCtxTeam(3, { seed: 3, startingBracketIdx: 1 }),
|
||||
tournamentCtxTeam(4, { seed: 4, startingBracketIdx: 1 }),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const divATeamSeeds = [1, 2, 5].map(
|
||||
(teamId) => tournament.teamById(teamId)?.seed,
|
||||
);
|
||||
|
||||
expect(new Set(divATeamSeeds).size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -698,11 +698,12 @@ export class Tournament {
|
||||
teamById(id: number) {
|
||||
let result: (typeof this.ctx.teams)[number] | null = null;
|
||||
let seed = 0;
|
||||
let currStartingBracketIdx = this.ctx.teams.at(0)?.startingBracketIdx;
|
||||
let currStartingBracketIdx = this.ctx.teams.at(0)?.startingBracketIdx ?? 0;
|
||||
|
||||
for (const team of this.ctx.teams) {
|
||||
if (team.startingBracketIdx !== currStartingBracketIdx) {
|
||||
currStartingBracketIdx = team.startingBracketIdx;
|
||||
const teamStartingBracketIdx = team.startingBracketIdx ?? 0;
|
||||
if (teamStartingBracketIdx !== currStartingBracketIdx) {
|
||||
currStartingBracketIdx = teamStartingBracketIdx;
|
||||
seed = 1;
|
||||
} else {
|
||||
seed++;
|
||||
|
||||
@@ -192,6 +192,27 @@ describe("compareTeamsForOrdering", () => {
|
||||
expect(result).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startingBracketIdx null vs explicit 0", () => {
|
||||
it("orders by seed when one team has null and the other explicit 0 starting bracket", () => {
|
||||
const nullBracketTeam = createTeam(1, {
|
||||
seed: 1,
|
||||
startingBracketIdx: null,
|
||||
});
|
||||
const zeroBracketTeam = createTeam(2, {
|
||||
seed: 2,
|
||||
startingBracketIdx: 0,
|
||||
});
|
||||
|
||||
const result = compareTeamsForOrdering(
|
||||
nullBracketTeam,
|
||||
zeroBracketTeam,
|
||||
MIN_MEMBERS,
|
||||
);
|
||||
|
||||
expect(result).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortTeamsBySeeding", () => {
|
||||
@@ -210,6 +231,59 @@ describe("sortTeamsBySeeding", () => {
|
||||
expect(sorted.map((t) => t.id)).toEqual([5, 3, 4, 2, 1, 6]);
|
||||
});
|
||||
|
||||
it("keeps manually seeded teams in seed order when unseeded teams are present", () => {
|
||||
const seededSkills = [31, 18, 20, 37, 2, 46, 19, 37];
|
||||
const seededTeams = seededSkills.map((skill, i) =>
|
||||
createTeam(i + 1, { seed: i + 1, avgSeedingSkillOrdinal: skill }),
|
||||
);
|
||||
// input mirrors the DB query order (seed ASC = NULL seeds first in SQLite)
|
||||
const teams = [
|
||||
createTeam(9, { avgSeedingSkillOrdinal: 40 }),
|
||||
createTeam(10, { avgSeedingSkillOrdinal: 31 }),
|
||||
...seededTeams,
|
||||
];
|
||||
|
||||
const sorted = sortTeamsBySeeding(teams, MIN_MEMBERS);
|
||||
|
||||
expect(
|
||||
sorted.filter((team) => team.seed !== null).map((team) => team.seed),
|
||||
).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
});
|
||||
|
||||
it("returns the same order regardless of input order", () => {
|
||||
const seedOne = createTeam(1, { seed: 1, avgSeedingSkillOrdinal: 5 });
|
||||
const seedTwo = createTeam(2, { seed: 2, avgSeedingSkillOrdinal: 30 });
|
||||
const seedThree = createTeam(3, { seed: 3, avgSeedingSkillOrdinal: 20 });
|
||||
const seedFour = createTeam(4, { seed: 4, avgSeedingSkillOrdinal: 25 });
|
||||
const unseeded = createTeam(5, { avgSeedingSkillOrdinal: 28 });
|
||||
|
||||
const sortedA = sortTeamsBySeeding(
|
||||
[seedOne, seedTwo, seedThree, seedFour, unseeded],
|
||||
MIN_MEMBERS,
|
||||
);
|
||||
const sortedB = sortTeamsBySeeding(
|
||||
[seedOne, seedThree, seedFour, unseeded, seedTwo],
|
||||
MIN_MEMBERS,
|
||||
);
|
||||
|
||||
expect(sortedA.map((team) => team.id)).toEqual(
|
||||
sortedB.map((team) => team.id),
|
||||
);
|
||||
});
|
||||
|
||||
it("slots an unseeded team below every seeded team with a higher skill ordinal", () => {
|
||||
const seedOne = createTeam(1, { seed: 1, avgSeedingSkillOrdinal: 5 });
|
||||
const seedTwo = createTeam(2, { seed: 2, avgSeedingSkillOrdinal: 30 });
|
||||
const unseeded = createTeam(5, { avgSeedingSkillOrdinal: 28 });
|
||||
|
||||
const sorted = sortTeamsBySeeding(
|
||||
[unseeded, seedOne, seedTwo],
|
||||
MIN_MEMBERS,
|
||||
);
|
||||
|
||||
expect(sorted.map((team) => team.id)).toEqual([1, 2, 5]);
|
||||
});
|
||||
|
||||
it("does not mutate original array", () => {
|
||||
const teams = [
|
||||
createTeam(2, { avgSeedingSkillOrdinal: 100 }),
|
||||
|
||||
@@ -315,13 +315,22 @@ export type TeamForOrdering = {
|
||||
startingBracketIdx: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compares two teams pairwise for ordering purposes. Not a strict weak order
|
||||
* when one team has a seed and the other does not (the seed is ignored in
|
||||
* favor of the skill comparison), so it must not be used as a raw `sort`
|
||||
* comparator over a mixed seeded/unseeded field — {@link sortTeamsBySeeding}
|
||||
* handles that case.
|
||||
*/
|
||||
export function compareTeamsForOrdering(
|
||||
a: TeamForOrdering,
|
||||
b: TeamForOrdering,
|
||||
minMembersPerTeam: number,
|
||||
): number {
|
||||
if (a.startingBracketIdx !== b.startingBracketIdx) {
|
||||
return (a.startingBracketIdx ?? 0) - (b.startingBracketIdx ?? 0);
|
||||
const aStartingBracketIdx = a.startingBracketIdx ?? 0;
|
||||
const bStartingBracketIdx = b.startingBracketIdx ?? 0;
|
||||
if (aStartingBracketIdx !== bStartingBracketIdx) {
|
||||
return aStartingBracketIdx - bStartingBracketIdx;
|
||||
}
|
||||
|
||||
if (a.seed !== null && b.seed !== null) {
|
||||
@@ -355,13 +364,76 @@ export function compareTeamsForOrdering(
|
||||
return a.createdAt !== b.createdAt ? a.createdAt - b.createdAt : a.id - b.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders tournament teams into their effective seed order. Within each
|
||||
* starting bracket manually seeded teams keep the organizer's seed order,
|
||||
* while unseeded teams (e.g. registered after the seeds were last saved) are
|
||||
* slotted in by skill: below every seeded team with a higher skill ordinal,
|
||||
* above the rest. Unseeded teams that are not full or have no skill ordinal
|
||||
* go below all seeded teams. The result is deterministic regardless of the
|
||||
* input order.
|
||||
*/
|
||||
export function sortTeamsBySeeding<T extends TeamForOrdering>(
|
||||
teams: T[],
|
||||
minMembersPerTeam: number,
|
||||
): T[] {
|
||||
return [...teams].sort((a, b) =>
|
||||
compareTeamsForOrdering(a, b, minMembersPerTeam),
|
||||
const byStartingBracket = new Map<number, T[]>();
|
||||
for (const team of teams) {
|
||||
const bracketIdx = team.startingBracketIdx ?? 0;
|
||||
const group = byStartingBracket.get(bracketIdx) ?? [];
|
||||
group.push(team);
|
||||
byStartingBracket.set(bracketIdx, group);
|
||||
}
|
||||
|
||||
return [...byStartingBracket.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.flatMap(([, group]) => orderTeamsOfBracket(group, minMembersPerTeam));
|
||||
}
|
||||
|
||||
function orderTeamsOfBracket<T extends TeamForOrdering>(
|
||||
teams: T[],
|
||||
minMembersPerTeam: number,
|
||||
): T[] {
|
||||
const seeded = teams
|
||||
.filter((team) => team.seed !== null)
|
||||
.sort((a, b) => a.seed! - b.seed!);
|
||||
const unseeded = teams
|
||||
.filter((team) => team.seed === null)
|
||||
.sort((a, b) => compareTeamsForOrdering(a, b, minMembersPerTeam));
|
||||
|
||||
const interleaved = unseeded.filter(
|
||||
(team) =>
|
||||
team.memberUserIds.length >= minMembersPerTeam &&
|
||||
team.avgSeedingSkillOrdinal !== null,
|
||||
);
|
||||
const appended = unseeded.filter((team) => !interleaved.includes(team));
|
||||
|
||||
const insertionIdx = (team: T) => {
|
||||
for (let i = seeded.length - 1; i >= 0; i--) {
|
||||
const seededSkill =
|
||||
seeded[i].avgSeedingSkillOrdinal ?? Number.NEGATIVE_INFINITY;
|
||||
if (seededSkill >= team.avgSeedingSkillOrdinal!) return i + 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const result: T[] = [];
|
||||
let unseededIdx = 0;
|
||||
for (let seededIdx = 0; seededIdx <= seeded.length; seededIdx++) {
|
||||
while (
|
||||
unseededIdx < interleaved.length &&
|
||||
insertionIdx(interleaved[unseededIdx]) === seededIdx
|
||||
) {
|
||||
result.push(interleaved[unseededIdx]);
|
||||
unseededIdx++;
|
||||
}
|
||||
|
||||
if (seededIdx < seeded.length) {
|
||||
result.push(seeded[seededIdx]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...result, ...appended];
|
||||
}
|
||||
|
||||
export function findTeamInsertPosition<T extends TeamForOrdering>(
|
||||
|
||||
@@ -88,15 +88,20 @@ function generateWithInput(
|
||||
source: "TIEBREAKER" as const,
|
||||
}));
|
||||
|
||||
// tiebreaker/fallback lists at the last slot get their own key range so
|
||||
// their indices don't collide with the picked indices of the main list
|
||||
const usedStageKeyOffset = stageList === stages ? 0 : stages.length;
|
||||
|
||||
for (const [i, stage] of stageList.entries()) {
|
||||
if (!stageIsOk(stage, i)) continue;
|
||||
const usedStageKey = i + usedStageKeyOffset;
|
||||
if (!stageIsOk(stage, usedStageKey)) continue;
|
||||
mapList.push(stage);
|
||||
usedStages.add(i);
|
||||
usedStages.add(usedStageKey);
|
||||
|
||||
const continueSearch = backtrack();
|
||||
if (!continueSearch) return false;
|
||||
|
||||
usedStages.delete(i);
|
||||
usedStages.delete(usedStageKey);
|
||||
mapList.pop();
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ const generateMapsResult = ({
|
||||
tiebreakerMaps = tiebreakerPicks,
|
||||
modesIncluded = [...rankedModesShort],
|
||||
followModeOrder = false,
|
||||
recentlyPlayedMaps,
|
||||
}: Partial<TournamentMaplistInput> = {}) => {
|
||||
return generateBalancedMapList({
|
||||
count,
|
||||
@@ -85,6 +86,7 @@ const generateMapsResult = ({
|
||||
tiebreakerMaps,
|
||||
modesIncluded,
|
||||
followModeOrder,
|
||||
recentlyPlayedMaps,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -865,3 +867,25 @@ describe("Recently played maps", () => {
|
||||
expect(mapList.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tiebreaker maps vs picked maps index collision", () => {
|
||||
test("generates a full map list when team picks and tiebreakers share list indices", () => {
|
||||
const result = generateMapsResult({
|
||||
count: 3,
|
||||
teams: [
|
||||
{ id: 1, maps: new MapPool([{ mode: "SZ", stageId: 4 }]) },
|
||||
{ id: 2, maps: new MapPool([{ mode: "SZ", stageId: 5 }]) },
|
||||
],
|
||||
tiebreakerMaps: new MapPool([
|
||||
{ mode: "SZ", stageId: 6 },
|
||||
{ mode: "SZ", stageId: 7 },
|
||||
]),
|
||||
modesIncluded: ["SZ"],
|
||||
});
|
||||
|
||||
const mapList = unwrap(result);
|
||||
|
||||
expect(mapList.length).toBe(3);
|
||||
expect(mapList[2].source).toBe("TIEBREAKER");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user