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 56fda2407..43e0fcdb4 100644 --- a/app/modules/tournament-map-list-generator/balanced-map-list.ts +++ b/app/modules/tournament-map-list-generator/balanced-map-list.ts @@ -55,7 +55,11 @@ function generateWithInput( if (validationError) return err(validationError); const { seededShuffle } = seededRandom(input.seed); - const stages = seededShuffle(resolveCommonStages()); + // trying the least recently played maps first lets the search find a good + // enough list before the recursion depth cap is hit + const stages = seededShuffle(resolveCommonStages()).sort( + (a, b) => recencyPenalty(a) - recencyPenalty(b), + ); const mapList: Array = []; const bestMapList: { maps?: Array; score: number } = { score: Number.POSITIVE_INFINITY, @@ -108,10 +112,12 @@ function generateWithInput( return true; }; - const success = backtrack(); + const searchExhausted = backtrack(); - if (!success) return err("MAX_RECURSION_DEPTH_EXCEEDED"); + // a complete list found before the depth cap was hit is still valid, + // only its optimality is unproven if (bestMapList.maps) return ok(bestMapList.maps); + if (!searchExhausted) return err("MAX_RECURSION_DEPTH_EXCEEDED"); return err("COULD_NOT_GENERATE_MAPLIST"); @@ -435,26 +441,24 @@ function generateWithInput( score += 100; } - if (input.recentlyPlayedMaps) { - for (const map of mapList) { - const recentIndex = input.recentlyPlayedMaps.findIndex( - (recent) => - recent.stageId === map.stageId && recent.mode === map.mode, - ); - - if (recentIndex !== -1) { - const recencyPenalty = Math.max( - 10 - Math.floor(recentIndex / 2) * 2, - 0, - ); - score += recencyPenalty; - } - } + for (const map of mapList) { + score += recencyPenalty(map); } return score; } + function recencyPenalty(map: Pick) { + if (!input.recentlyPlayedMaps) return 0; + + const recentIndex = input.recentlyPlayedMaps.findIndex( + (recent) => recent.stageId === map.stageId && recent.mode === map.mode, + ); + if (recentIndex === -1) return 0; + + return Math.max(10 - Math.floor(recentIndex / 2) * 2, 0); + } + function lastMapIsAGoodTieBreaker() { // guaranteed to be good if more than one mode if (!tournamentIsOneModeOnly()) return true; diff --git a/app/modules/tournament-map-list-generator/generation.test.ts b/app/modules/tournament-map-list-generator/generation.test.ts index 5b60264f6..9b75267eb 100644 --- a/app/modules/tournament-map-list-generator/generation.test.ts +++ b/app/modules/tournament-map-list-generator/generation.test.ts @@ -798,6 +798,46 @@ describe("TournamentMapListGeneratorOneMode", () => { }); describe("Recently played maps", () => { + test("One mode Bo7 avoids recently played maps when a full avoiding list exists", () => { + const team1Pool = new MapPool( + ([1, 2, 3, 4, 5, 6] as const).map((stageId) => ({ + mode: "SZ" as const, + stageId, + })), + ); + const team2Pool = new MapPool( + ([7, 8, 9, 10, 11, 12] as const).map((stageId) => ({ + mode: "SZ" as const, + stageId, + })), + ); + // the bo5 both teams played right before this match + const recentlyPlayedMaps = ([1, 7, 2, 8, 3] as const).map((stageId) => ({ + mode: "SZ" as const, + stageId, + })); + + const mapList = generateMaps({ + count: 7, + seed: "1000", + teams: [ + { id: 1, maps: team1Pool }, + { id: 2, maps: team2Pool }, + ], + tiebreakerMaps: new MapPool([]), + modesIncluded: ["SZ"], + recentlyPlayedMaps, + }); + + const recentMapsInList = mapList.filter((map) => + recentlyPlayedMaps.some( + (recent) => recent.mode === map.mode && recent.stageId === map.stageId, + ), + ); + + expect(recentMapsInList).toEqual([]); + }); + test("Avoids recently played maps when possible", () => { const recentlyPlayedMaps = [ { mode: "SZ" as const, stageId: 4 as const },