mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-26 09:20:24 -05:00
474 lines
13 KiB
TypeScript
474 lines
13 KiB
TypeScript
import type { Result } from "neverthrow";
|
|
import { err, ok } from "neverthrow";
|
|
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
|
import invariant from "~/utils/invariant";
|
|
import { logger } from "~/utils/logger";
|
|
import { seededRandom } from "~/utils/random";
|
|
import type { ModeShort, StageId } from "../in-game-lists/types";
|
|
import { DEFAULT_MAP_POOL } from "./constants";
|
|
import type {
|
|
TournamentMapListMap,
|
|
TournamentMaplistInput,
|
|
TournamentMaplistSource,
|
|
} from "./types";
|
|
|
|
type ModeWithStageAndScore = TournamentMapListMap & { score: number };
|
|
|
|
const OPTIMAL_MAPLIST_SCORE = 0;
|
|
const MAX_RECURSION_DEPTH = 5_000;
|
|
|
|
export function generateBalancedMapList(
|
|
input: TournamentMaplistInput,
|
|
): Array<TournamentMapListMap> {
|
|
const result = generateWithInput(input);
|
|
|
|
if (result.isErr()) {
|
|
if (
|
|
result.error === "MAX_RECURSION_DEPTH_EXCEEDED" &&
|
|
input.recentlyPlayedMaps
|
|
) {
|
|
logger.error(
|
|
`Failed to generate map list with recently played maps consideration. Retrying without recently played maps. Team IDs: ${input.teams.map((t) => t.id).join(", ")}`,
|
|
);
|
|
return generateWithInput({
|
|
...input,
|
|
recentlyPlayedMaps: undefined,
|
|
})._unsafeUnwrap();
|
|
}
|
|
|
|
throw new Error(`couldn't generate maplist: ${result.error}`);
|
|
}
|
|
|
|
return result.value;
|
|
}
|
|
|
|
function generateWithInput(
|
|
input: TournamentMaplistInput,
|
|
): Result<
|
|
Array<TournamentMapListMap>,
|
|
"MAX_RECURSION_DEPTH_EXCEEDED" | "COULD_NOT_GENERATE_MAPLIST"
|
|
> {
|
|
validateInput(input);
|
|
|
|
const { seededShuffle } = seededRandom(input.seed);
|
|
const stages = seededShuffle(resolveCommonStages());
|
|
const mapList: Array<ModeWithStageAndScore & { score: number }> = [];
|
|
const bestMapList: { maps?: Array<ModeWithStageAndScore>; score: number } = {
|
|
score: Number.POSITIVE_INFINITY,
|
|
};
|
|
const usedStages = new Set<number>();
|
|
let depth = 0;
|
|
|
|
const backtrack = (): boolean => {
|
|
if (++depth > MAX_RECURSION_DEPTH) {
|
|
return false;
|
|
}
|
|
invariant(mapList.length <= input.count, "mapList.length > input.count");
|
|
const mapListScore = rateMapList();
|
|
if (typeof mapListScore === "number" && mapListScore < bestMapList.score) {
|
|
bestMapList.maps = [...mapList];
|
|
bestMapList.score = mapListScore;
|
|
}
|
|
|
|
// There can't be better map list than this
|
|
if (bestMapList.score === OPTIMAL_MAPLIST_SCORE) {
|
|
return true;
|
|
}
|
|
|
|
const stageList =
|
|
mapList.length < input.count - 1 || input.tiebreakerMaps.length === 0
|
|
? resolveStages()
|
|
: input.tiebreakerMaps.stageModePairs.map((p) => ({
|
|
...p,
|
|
score: 0,
|
|
source: "TIEBREAKER" as const,
|
|
}));
|
|
|
|
for (const [i, stage] of stageList.entries()) {
|
|
if (!stageIsOk(stage, i)) continue;
|
|
mapList.push(stage);
|
|
usedStages.add(i);
|
|
|
|
const continueSearch = backtrack();
|
|
if (!continueSearch) return false;
|
|
|
|
usedStages.delete(i);
|
|
mapList.pop();
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const success = backtrack();
|
|
|
|
if (!success) return err("MAX_RECURSION_DEPTH_EXCEEDED");
|
|
if (bestMapList.maps) return ok(bestMapList.maps);
|
|
|
|
return err("COULD_NOT_GENERATE_MAPLIST");
|
|
|
|
function resolveCommonStages() {
|
|
const sorted = input.teams
|
|
.slice()
|
|
.sort((a, b) => a.id - b.id) as TournamentMaplistInput["teams"];
|
|
|
|
const result = sorted[0].maps.stageModePairs.map((pair) => ({
|
|
...pair,
|
|
score: 1,
|
|
source: sorted[0].id as TournamentMaplistSource,
|
|
}));
|
|
|
|
for (const stage of sorted[1].maps.stageModePairs) {
|
|
const alreadyIncludedStage = result.find(
|
|
(alreadyIncludedStage) =>
|
|
alreadyIncludedStage.stageId === stage.stageId &&
|
|
alreadyIncludedStage.mode === stage.mode,
|
|
);
|
|
|
|
if (alreadyIncludedStage) {
|
|
alreadyIncludedStage.score = 0;
|
|
alreadyIncludedStage.source = "BOTH";
|
|
} else {
|
|
result.push({ ...stage, score: -1, source: sorted[1].id });
|
|
}
|
|
}
|
|
|
|
if (
|
|
input.teams[0].maps.stages.length === 0 &&
|
|
input.teams[1].maps.stages.length === 0
|
|
) {
|
|
// neither team submitted map, we go default
|
|
result.push(
|
|
...getDefaultMapPool().map((pair) => ({
|
|
...pair,
|
|
score: 0,
|
|
source: "DEFAULT" as const,
|
|
})),
|
|
);
|
|
} else if (
|
|
input.teams[0].maps.stages.length === 0 ||
|
|
input.teams[1].maps.stages.length === 0
|
|
) {
|
|
// let's set it up for later that if one team doesn't have stages set
|
|
// we can make a maplist consisting of only stages from the team that did submit
|
|
for (const stageObj of result) {
|
|
stageObj.score = 0;
|
|
}
|
|
}
|
|
|
|
return result.sort((a, b) =>
|
|
`${a.stageId}-${a.mode}`.localeCompare(`${b.stageId}-${b.mode}`),
|
|
);
|
|
}
|
|
|
|
function resolveStages() {
|
|
if (utilizeOtherStageIdsWhenNoTiebreaker()) {
|
|
// no overlap so we need to use a random map for tiebreaker
|
|
|
|
if (tournamentIsOneModeOnly()) {
|
|
return seededShuffle([...stageIds])
|
|
.filter(
|
|
(stageId) =>
|
|
!input.teams[0].maps.hasStage(stageId) &&
|
|
!input.teams[1].maps.hasStage(stageId),
|
|
)
|
|
.map((stageId) => ({
|
|
stageId,
|
|
mode: input.modesIncluded[0],
|
|
score: 0,
|
|
source: "TIEBREAKER" as const,
|
|
}));
|
|
}
|
|
return DEFAULT_MAP_POOL.stageModePairs
|
|
.filter(
|
|
(pair) =>
|
|
!input.teams[0].maps.has(pair) && !input.teams[1].maps.has(pair),
|
|
)
|
|
.map((pair) => ({
|
|
stageId: pair.stageId,
|
|
mode: pair.mode,
|
|
score: 0,
|
|
source: "TIEBREAKER" as const,
|
|
}));
|
|
}
|
|
|
|
return stages;
|
|
}
|
|
|
|
function validateInput(input: TournamentMaplistInput) {
|
|
invariant(
|
|
input.teams.every((t) =>
|
|
t.maps.stageModePairs.every((pair) =>
|
|
input.modesIncluded.includes(pair.mode),
|
|
),
|
|
),
|
|
"Maps submitted for modes not included in the tournament",
|
|
);
|
|
|
|
for (const team of input.teams) {
|
|
const stringified = team.maps.stageModePairs.map(
|
|
(p) => `${p.stageId}-${p.mode}`,
|
|
);
|
|
const unique = new Set(stringified);
|
|
invariant(
|
|
unique.size === stringified.length,
|
|
`Duplicate maps in map pool (team ${team.id})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function utilizeOtherStageIdsWhenNoTiebreaker() {
|
|
if (mapList.length < input.count - 1) return false;
|
|
|
|
if (
|
|
input.teams.every((team) => !team.maps.isEmpty()) &&
|
|
!input.teams[0].maps.overlaps(input.teams[1].maps)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const teamsMapsLeftNotPicked =
|
|
[...input.teams[0].maps, ...input.teams[1].maps].filter(
|
|
(stage) =>
|
|
!mapList.some(
|
|
(map) => map.stageId === stage.stageId && map.mode === stage.mode,
|
|
),
|
|
).length > 0;
|
|
if (!teamsMapsLeftNotPicked) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
function getDefaultMapPool() {
|
|
if (tournamentIsOneModeOnly()) {
|
|
const mode = input.modesIncluded[0];
|
|
|
|
return stageIds.map((id) => ({ mode, stageId: id }));
|
|
}
|
|
|
|
return DEFAULT_MAP_POOL.stageModePairs.filter(({ mode }) =>
|
|
input.modesIncluded.includes(mode),
|
|
);
|
|
}
|
|
|
|
type StageValidatorInput = Pick<
|
|
ModeWithStageAndScore,
|
|
"score" | "stageId" | "mode" | "source"
|
|
>;
|
|
|
|
// adding rules here can achieve to things
|
|
// 1) adjust what kind of map list is generated
|
|
// 2) optimize the algorithm my eliminating subtrees from consideration
|
|
function stageIsOk(stage: StageValidatorInput, index: number) {
|
|
if (usedStages.has(index)) return false;
|
|
if (mapListAlreadyFull()) return false;
|
|
if (isEarlyModeRepeat(stage)) return false;
|
|
if (isNotFollowingModePattern(stage)) return false;
|
|
if (isMakingThingsUnfair(stage)) return false;
|
|
if (isStageRepeatWithoutBreak(stage)) return false;
|
|
if (isSecondPickBySameTeamInRow(stage)) return false;
|
|
if (wouldPreventTiebreaker(stage)) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
function tournamentIsOneModeOnly() {
|
|
return input.modesIncluded.length === 1;
|
|
}
|
|
|
|
function mapListAlreadyFull() {
|
|
return mapList.length === input.count;
|
|
}
|
|
|
|
function isEarlyModeRepeat(stage: StageValidatorInput) {
|
|
if (tournamentIsOneModeOnly()) return false;
|
|
|
|
// all modes already appeared
|
|
if (mapList.length >= input.modesIncluded.length) return false;
|
|
|
|
if (
|
|
mapList.some(
|
|
(alreadyIncludedStage) => alreadyIncludedStage.mode === stage.mode,
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function isNotFollowingModePattern(stage: StageValidatorInput) {
|
|
if (tournamentIsOneModeOnly()) return false;
|
|
|
|
if (input.followModeOrder) {
|
|
return isNotFollowingModeOrder(stage);
|
|
}
|
|
|
|
// not all modes appeared yet
|
|
if (mapList.length < input.modesIncluded.length) return false;
|
|
|
|
let previousModeShouldBe: ModeShort | undefined;
|
|
for (let i = 0; i < mapList.length; i++) {
|
|
if (mapList[i].mode === stage.mode) {
|
|
if (i === 0) {
|
|
previousModeShouldBe = mapList[mapList.length - 1].mode;
|
|
} else {
|
|
previousModeShouldBe = mapList[i - 1].mode;
|
|
}
|
|
}
|
|
}
|
|
if (!previousModeShouldBe) return false;
|
|
|
|
return mapList[mapList.length - 1].mode !== previousModeShouldBe;
|
|
}
|
|
|
|
function isNotFollowingModeOrder(stage: StageValidatorInput) {
|
|
let currentIndex = 0;
|
|
for (let i = 0; i < mapList.length; i++) {
|
|
currentIndex++;
|
|
if (currentIndex === input.modesIncluded.length) currentIndex = 0;
|
|
}
|
|
|
|
return stage.mode !== input.modesIncluded[currentIndex];
|
|
}
|
|
|
|
// don't allow making two picks from one team in row
|
|
function isMakingThingsUnfair(stage: StageValidatorInput) {
|
|
// allow to handle edge case where both teams have 100% overlap in one mode
|
|
// but not others in Bo5 for example could make impossible to make map because e.g.
|
|
// 1) overlap
|
|
// 2) team 1 pick
|
|
// 3) team 2 pick
|
|
// 4) team 1 pick
|
|
// 5) TIEBREAKER <- system has to allow
|
|
// ---
|
|
// but later we will with score make sure that we exhaust better options too
|
|
if (stage.source === "TIEBREAKER") return false;
|
|
|
|
const score = mapList.reduce((acc, cur) => acc + cur.score, 0);
|
|
const newScore = score + stage.score;
|
|
|
|
if (score !== 0 && newScore !== 0) return true;
|
|
if (newScore !== 0 && mapList.length + 1 === input.count) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
function isStageRepeatWithoutBreak(stage: StageValidatorInput) {
|
|
const lastStage = mapList[mapList.length - 1];
|
|
if (!lastStage) return false;
|
|
|
|
return lastStage.stageId === stage.stageId;
|
|
}
|
|
|
|
function isSecondPickBySameTeamInRow(stage: StageValidatorInput) {
|
|
const lastStage = mapList[mapList.length - 1];
|
|
if (!lastStage) return false;
|
|
if (stage.score === 0) return false;
|
|
|
|
return lastStage.score === stage.score;
|
|
}
|
|
|
|
function wouldPreventTiebreaker(stage: StageValidatorInput) {
|
|
// tiebreaker always guaranteed if maps are explicitly set
|
|
if (input.tiebreakerMaps.length > 0) return false;
|
|
|
|
const commonMaps = input.teams[0].maps.stageModePairs.filter(
|
|
({ stageId, mode }) =>
|
|
input.teams[1].maps.stageModePairs.some(
|
|
(pair) => pair.stageId === stageId && pair.mode === mode,
|
|
),
|
|
);
|
|
|
|
const newMapList = [...mapList, stage];
|
|
|
|
const newCommonMaps = commonMaps.filter(
|
|
({ stageId, mode }) =>
|
|
!newMapList.some(
|
|
(pair) => pair.stageId === stageId && pair.mode === mode,
|
|
),
|
|
);
|
|
|
|
// there was at least one possible common map
|
|
// to pick as tiebreaker but it (or they) got picked too early
|
|
return (
|
|
commonMaps.length > 0 &&
|
|
// handles special case where both teams have the same maps in their pool
|
|
commonMaps.length !== input.teams[0].maps.stageModePairs.length &&
|
|
newCommonMaps.length === 0 &&
|
|
newMapList.length !== input.count
|
|
);
|
|
}
|
|
|
|
function rateMapList() {
|
|
// not a full map list
|
|
if (mapList.length !== input.count) return;
|
|
|
|
let score = OPTIMAL_MAPLIST_SCORE;
|
|
|
|
const appearedMaps = new Map<StageId, number>();
|
|
for (const stage of mapList) {
|
|
const timesAppeared = appearedMaps.get(stage.stageId) ?? 0;
|
|
|
|
if (timesAppeared > 0) {
|
|
score += timesAppeared;
|
|
}
|
|
|
|
appearedMaps.set(stage.stageId, timesAppeared + 1);
|
|
}
|
|
|
|
if (!lastMapIsAGoodTieBreaker()) {
|
|
score += 1;
|
|
}
|
|
|
|
const fairnessBalance = mapList.reduce((acc, cur) => acc + cur.score, 0);
|
|
if (fairnessBalance !== 0) {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
function lastMapIsAGoodTieBreaker() {
|
|
// guaranteed to be good if more than one mode
|
|
if (!tournamentIsOneModeOnly()) return true;
|
|
|
|
// specifically made tiebreaker map is considered good
|
|
const last = mapList[mapList.length - 1];
|
|
if (last.source === "TIEBREAKER") return true;
|
|
|
|
// we can't have a map from pools of both teams if both didn't submit maps
|
|
if (input.teams.some((team) => team.maps.stageModePairs.length === 0)) {
|
|
return true;
|
|
}
|
|
|
|
const tieBreakerMap = mapList[mapList.length - 1];
|
|
|
|
let appearanceCount = 0;
|
|
|
|
for (const team of input.teams) {
|
|
for (const stage of team.maps.stages) {
|
|
if (stage === tieBreakerMap.stageId) appearanceCount++;
|
|
}
|
|
}
|
|
|
|
return appearanceCount === 2;
|
|
}
|
|
}
|