Simplify tournament map list generation mode order to be cyclic

Closes #3127
This commit is contained in:
Kalle
2026-06-03 18:47:33 +03:00
parent 77c2563cab
commit 8baa57edd6
3 changed files with 58 additions and 59 deletions

View File

@@ -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", () => {

View File

@@ -52,7 +52,7 @@ export function* generate(args: {
initialWeights?: Map<string, number>;
/** 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<StageId, number>;
@@ -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(

View File

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