Fix even more bugs

This commit is contained in:
Kalle
2026-08-05 14:41:12 +03:00
parent 4c668cc00b
commit 353d24d2ad
21 changed files with 483 additions and 90 deletions

View File

@@ -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);

View File

@@ -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: [

View 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);
}

View File

@@ -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,

View File

@@ -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";

View File

@@ -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,

View File

@@ -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 }));

View File

@@ -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 {

View File

@@ -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,

View 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 1218 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");
});
});

View File

@@ -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);

View File

@@ -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,

View 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 497501 (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);
});
});

View File

@@ -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,

View File

@@ -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"

View File

@@ -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);
});
});

View File

@@ -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++;

View File

@@ -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 }),

View File

@@ -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>(

View File

@@ -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();
}

View File

@@ -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");
});
});