diff --git a/app/features/map-list-generator/core/MapList.test.ts b/app/features/map-list-generator/core/MapList.test.ts index 164894251..a2aaa7a63 100644 --- a/app/features/map-list-generator/core/MapList.test.ts +++ b/app/features/map-list-generator/core/MapList.test.ts @@ -286,28 +286,43 @@ describe("MapList.generate()", () => { expect(stagesSeen.size).toBeGreaterThan(1); }); - it("rotates the mode order", () => { + it("cycles a single mode order continuously across sets", () => { + // 5 modes, Bo3 sets -> the order keeps rolling without resetting const gen = initGenerator(); - const first = gen.next({ amount: 5 }).value; - const second = gen.next({ amount: 5 }).value; + const first = gen.next({ amount: 3 }).value!.map((m) => m.mode); + const second = gen.next({ amount: 3 }).value!.map((m) => m.mode); - const firstModes = first!.map((m) => m.mode); - const secondModes = second!.map((m) => m.mode); - - expect(firstModes).not.toEqual(secondModes); + // set 2 continues where set 1 left off (positions 3, 4, 0) + expect(second[2]).toBe(first[0]); }); - it("starts with a different mode each time modes are rotated", () => { - for (let i = 0; i < 10; i++) { - const gen = initGenerator(); - const first = gen.next({ amount: 5 }).value; - const second = gen.next({ amount: 5 }).value; + it("uses the same mode order when a set spans the whole rotation", () => { + // 5 modes, Bo5 sets -> each set is exactly one full rotation + const gen = initGenerator(); + const first = gen.next({ amount: 5 }).value!.map((m) => m.mode); + const second = gen.next({ amount: 5 }).value!.map((m) => m.mode); - const firstModes = first!.map((m) => m.mode); - const secondModes = second!.map((m) => m.mode); + expect(second).toEqual(first); + }); - expect(firstModes[0]).not.toEqual(secondModes[0]); + it("keeps cycling other modes across sets when a must-include pattern is set", () => { + // A single generator drives every bracket round. With a `[SZ]` + // must-include pattern the non-SZ slots should keep advancing through + // the mode order across rounds instead of replaying the order's prefix + // every set, otherwise modes in the order's tail (here RM) are starved. + const gen = MapList.generate({ + mapPool: ALL_MODES_TEST_MAP_POOL, + modeOrder: ["SZ", "TC", "CB", "RM", "TW"], + }); + gen.next(); + + const modesSeen: string[] = []; + for (let round = 0; round < 4; round++) { + const maps = gen.next({ amount: 3, pattern: "[SZ]" }).value; + modesSeen.push(...maps.map((m) => m.mode)); } + + expect(modesSeen).toContain("RM"); }); it("replenishes the stage id pool with different order", () => { diff --git a/app/features/map-list-generator/core/MapList.ts b/app/features/map-list-generator/core/MapList.ts index 69ce9bea6..1209a7670 100644 --- a/app/features/map-list-generator/core/MapList.ts +++ b/app/features/map-list-generator/core/MapList.ts @@ -52,7 +52,7 @@ export function* generate(args: { initialWeights?: Map; /** Skip the ensureMinimumCandidates check that inflates weights to ensure half the pool is available. Useful when initial weights already define the desired selection. */ skipEnsureMinimumCandidates?: boolean; - /** Fixed mode order — when set, skips the random `modeOrders` shuffle and uses only this order. Intended for `resume`. */ + /** Fixed mode order — when set, skips the random shuffle and uses only this order. Intended for `resume`. */ modeOrder?: ModeShort[]; /** Initial weights for stages (mode-agnostic). Used by `resume` to carry over stage-level penalties from history. */ initialStageWeights?: Map; @@ -69,8 +69,8 @@ export function* generate(args: { args.initialWeights, args.initialStageWeights, ); - const orderedModes = args.modeOrder ? [args.modeOrder] : modeOrders(modes); - let currentOrderIndex = 0; + const modeOrder = args.modeOrder ?? R.shuffle(modes); + let modePosition = 0; const firstArgs = yield []; let amount = firstArgs.amount; @@ -81,15 +81,12 @@ export function* generate(args: { while (true) { const result: ModeWithStage[] = []; - let currentModeOrder = - orderedModes[currentOrderIndex % orderedModes.length]; - if (pattern) { - currentModeOrder = modifyModeOrderByPattern( - currentModeOrder, - pattern, - amount, - ); - } + const currentModeOrder = pattern + ? modifyModeOrderByPattern(modeOrder, pattern, amount, modePosition) + : Array.from( + { length: amount }, + (_, i) => modeOrder[(modePosition + i) % modeOrder.length], + ); if (!args.skipEnsureMinimumCandidates) { ensureMinimumCandidates({ @@ -131,7 +128,7 @@ export function* generate(args: { stageModeWeights.set(modeStageKey(mode, stageId), stageModeWeightPenalty); } - currentOrderIndex++; + modePosition += amount; const nextArgs = yield result; amount = nextArgs.amount; pattern = nextArgs.pattern @@ -310,35 +307,11 @@ function ensureMinimumCandidates({ } } -const MAX_MODE_ORDERS_ITERATIONS = 100; - -function modeOrders(modes: ModeShort[]) { - if (modes.length === 1) return [[modes[0]]] as ModeShort[][]; - - const result: ModeShort[][] = []; - - const shuffledModes = R.shuffle(modes); - - for (let i = 0; i < MAX_MODE_ORDERS_ITERATIONS; i++) { - const startingMode = shuffledModes[i % shuffledModes.length]; - const rest = R.shuffle(shuffledModes.filter((m) => m !== startingMode)); - - const candidate = [startingMode, ...rest]; - - if (!result.some((r) => R.isShallowEqual(r, candidate))) { - result.push(candidate); - } - - if (result.length === 10) break; - } - - return result; -} - function modifyModeOrderByPattern( modeOrder: ModeShort[], pattern: MaplistPattern, amount: number, + offset: number, ) { const filteredModes = modeOrder.filter( (mode) => !pattern.pattern.includes(mode), @@ -346,7 +319,7 @@ function modifyModeOrderByPattern( const modesToUse = filteredModes.length > 0 ? filteredModes : modeOrder; const result: ModeShort[] = Array.from( { length: amount }, - (_, i) => modesToUse[i % modesToUse.length], + (_, i) => modesToUse[(offset + i) % modesToUse.length], ); const expandedPattern = Array.from( diff --git a/app/features/tournament-bracket/core/toMapList.ts b/app/features/tournament-bracket/core/toMapList.ts index 712bb3378..cc5a64d1c 100644 --- a/app/features/tournament-bracket/core/toMapList.ts +++ b/app/features/tournament-bracket/core/toMapList.ts @@ -103,12 +103,23 @@ function getFilteredRounds( } function sortRounds(rounds: Round[], type: Tables["TournamentStage"]["type"]) { - return rounds.slice().sort((a, b) => { + const groupIds = rounds.map((x) => x.group_id); + const minGroupId = Math.min(...groupIds); + const maxGroupId = Math.max(...groupIds); + + // winners bracket first, then grands, then losers bracket + const doubleEliminationGroupRank = (groupId: number) => { + if (groupId === minGroupId) return 0; + if (groupId === maxGroupId) return 1; + return 2; + }; + + return rounds.toSorted((a, b) => { if (type === "double_elimination") { - // grands last - const maxGroupId = Math.max(...rounds.map((x) => x.group_id)); - if (a.group_id === maxGroupId && b.group_id !== maxGroupId) return 1; - if (a.group_id !== maxGroupId && b.group_id === maxGroupId) return -1; + const rankDiff = + doubleEliminationGroupRank(a.group_id) - + doubleEliminationGroupRank(b.group_id); + if (rankDiff !== 0) return rankDiff; } if (type === "single_elimination") { // finals and 3rd place match last