mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-09 12:46:10 -05:00
Simplify tournament map list generation mode order to be cyclic
Closes #3127
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user