From 353d24d2ad3cdb4cfd35a5f9a5d5beb7a130ef12 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:41:12 +0300 Subject: [PATCH] Fix even more bugs --- app/features/build-analyzer/analyzer-hooks.ts | 3 +- .../{utils.test.ts => ability-points.test.ts} | 2 +- .../build-analyzer/core/ability-points.ts | 58 ++++++++++++++ app/features/build-analyzer/core/utils.ts | 45 ----------- .../build-analyzer/routes/analyzer.tsx | 8 +- app/features/build-stats/build-stats-utils.ts | 2 +- .../builds/BuildRepository.server.test.ts | 21 +++++ app/features/builds/BuildRepository.server.ts | 24 +++--- app/features/builds/core/filter.server.ts | 2 +- .../calendar/loaders/calendar.server.test.ts | 60 ++++++++++++++ .../calendar/loaders/calendar.server.ts | 8 +- .../img-export/components/BuildGraphic.tsx | 6 +- .../core/leaderboards.server.test.ts | 68 ++++++++++++++++ .../leaderboards/core/leaderboards.server.ts | 14 ++++ .../loaders/leaderboards.server.ts | 13 ++- .../core/Tournament.test.ts | 43 ++++++++++ .../tournament-bracket/core/Tournament.ts | 7 +- .../tournament/tournament-utils.test.ts | 74 +++++++++++++++++ app/features/tournament/tournament-utils.ts | 80 ++++++++++++++++++- .../balanced-map-list.ts | 11 ++- .../generation.test.ts | 24 ++++++ 21 files changed, 483 insertions(+), 90 deletions(-) rename app/features/build-analyzer/core/{utils.test.ts => ability-points.test.ts} (94%) create mode 100644 app/features/build-analyzer/core/ability-points.ts create mode 100644 app/features/calendar/loaders/calendar.server.test.ts create mode 100644 app/features/leaderboards/core/leaderboards.server.test.ts diff --git a/app/features/build-analyzer/analyzer-hooks.ts b/app/features/build-analyzer/analyzer-hooks.ts index 8f62b127b..2626bfb24 100644 --- a/app/features/build-analyzer/analyzer-hooks.ts +++ b/app/features/build-analyzer/analyzer-hooks.ts @@ -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); diff --git a/app/features/build-analyzer/core/utils.test.ts b/app/features/build-analyzer/core/ability-points.test.ts similarity index 94% rename from app/features/build-analyzer/core/utils.test.ts rename to app/features/build-analyzer/core/ability-points.test.ts index 4c7a684dc..db3c6dbb6 100644 --- a/app/features/build-analyzer/core/utils.test.ts +++ b/app/features/build-analyzer/core/ability-points.test.ts @@ -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: [ diff --git a/app/features/build-analyzer/core/ability-points.ts b/app/features/build-analyzer/core/ability-points.ts new file mode 100644 index 000000000..dc298336d --- /dev/null +++ b/app/features/build-analyzer/core/ability-points.ts @@ -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); +} diff --git a/app/features/build-analyzer/core/utils.ts b/app/features/build-analyzer/core/utils.ts index 1f3f53a98..7398a30fb 100644 --- a/app/features/build-analyzer/core/utils.ts +++ b/app/features/build-analyzer/core/utils.ts @@ -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, diff --git a/app/features/build-analyzer/routes/analyzer.tsx b/app/features/build-analyzer/routes/analyzer.tsx index 7a83bc2b2..3c12787e9 100644 --- a/app/features/build-analyzer/routes/analyzer.tsx +++ b/app/features/build-analyzer/routes/analyzer.tsx @@ -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"; diff --git a/app/features/build-stats/build-stats-utils.ts b/app/features/build-stats/build-stats-utils.ts index 18ff60e9c..31c7a0165 100644 --- a/app/features/build-stats/build-stats-utils.ts +++ b/app/features/build-stats/build-stats-utils.ts @@ -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, diff --git a/app/features/builds/BuildRepository.server.test.ts b/app/features/builds/BuildRepository.server.test.ts index d9636f472..c1f7b564e 100644 --- a/app/features/builds/BuildRepository.server.test.ts +++ b/app/features/builds/BuildRepository.server.test.ts @@ -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 })); diff --git a/app/features/builds/BuildRepository.server.ts b/app/features/builds/BuildRepository.server.ts index 9e5995d34..a79324b5a 100644 --- a/app/features/builds/BuildRepository.server.ts +++ b/app/features/builds/BuildRepository.server.ts @@ -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(); + 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 { diff --git a/app/features/builds/core/filter.server.ts b/app/features/builds/core/filter.server.ts index fe48c1b5e..d379ec24f 100644 --- a/app/features/builds/core/filter.server.ts +++ b/app/features/builds/core/filter.server.ts @@ -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, diff --git a/app/features/calendar/loaders/calendar.server.test.ts b/app/features/calendar/loaders/calendar.server.test.ts new file mode 100644 index 000000000..3586c10e1 --- /dev/null +++ b/app/features/calendar/loaders/calendar.server.test.ts @@ -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({ 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"); + }); +}); diff --git a/app/features/calendar/loaders/calendar.server.ts b/app/features/calendar/loaders/calendar.server.ts index dad6e0e6b..a5dc52d96 100644 --- a/app/features/calendar/loaders/calendar.server.ts +++ b/app/features/calendar/loaders/calendar.server.ts @@ -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); diff --git a/app/features/img-export/components/BuildGraphic.tsx b/app/features/img-export/components/BuildGraphic.tsx index c0ce779a9..993ecf2e3 100644 --- a/app/features/img-export/components/BuildGraphic.tsx +++ b/app/features/img-export/components/BuildGraphic.tsx @@ -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, diff --git a/app/features/leaderboards/core/leaderboards.server.test.ts b/app/features/leaderboards/core/leaderboards.server.test.ts new file mode 100644 index 000000000..09044866d --- /dev/null +++ b/app/features/leaderboards/core/leaderboards.server.test.ts @@ -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); + }); +}); diff --git a/app/features/leaderboards/core/leaderboards.server.ts b/app/features/leaderboards/core/leaderboards.server.ts index 1ad8449ad..6cd5ced9a 100644 --- a/app/features/leaderboards/core/leaderboards.server.ts +++ b/app/features/leaderboards/core/leaderboards.server.ts @@ -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, diff --git a/app/features/leaderboards/loaders/leaderboards.server.ts b/app/features/leaderboards/loaders/leaderboards.server.ts index 7410a7e96..9e577ee9d 100644 --- a/app/features/leaderboards/loaders/leaderboards.server.ts +++ b/app/features/leaderboards/loaders/leaderboards.server.ts @@ -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" diff --git a/app/features/tournament-bracket/core/Tournament.test.ts b/app/features/tournament-bracket/core/Tournament.test.ts index 6bb93f529..93df0c697 100644 --- a/app/features/tournament-bracket/core/Tournament.test.ts +++ b/app/features/tournament-bracket/core/Tournament.test.ts @@ -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); + }); +}); diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 4d23b974d..083e76e92 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -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++; diff --git a/app/features/tournament/tournament-utils.test.ts b/app/features/tournament/tournament-utils.test.ts index f6dd39ea7..4bca8fa0d 100644 --- a/app/features/tournament/tournament-utils.test.ts +++ b/app/features/tournament/tournament-utils.test.ts @@ -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 }), diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index 1d207b471..ae4b98e1a 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -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( teams: T[], minMembersPerTeam: number, ): T[] { - return [...teams].sort((a, b) => - compareTeamsForOrdering(a, b, minMembersPerTeam), + const byStartingBracket = new Map(); + 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( + 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( diff --git a/app/modules/tournament-map-list-generator/balanced-map-list.ts b/app/modules/tournament-map-list-generator/balanced-map-list.ts index 63a6e5ed5..56fda2407 100644 --- a/app/modules/tournament-map-list-generator/balanced-map-list.ts +++ b/app/modules/tournament-map-list-generator/balanced-map-list.ts @@ -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(); } diff --git a/app/modules/tournament-map-list-generator/generation.test.ts b/app/modules/tournament-map-list-generator/generation.test.ts index e7c98d0a7..5b60264f6 100644 --- a/app/modules/tournament-map-list-generator/generation.test.ts +++ b/app/modules/tournament-map-list-generator/generation.test.ts @@ -77,6 +77,7 @@ const generateMapsResult = ({ tiebreakerMaps = tiebreakerPicks, modesIncluded = [...rankedModesShort], followModeOrder = false, + recentlyPlayedMaps, }: Partial = {}) => { 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"); + }); +});