SendouQ map list generation via MapList.generate (#2617)

This commit is contained in:
Kalle 2025-11-18 17:33:27 +02:00 committed by GitHub
parent 84b4c1d67f
commit 9bd8d84f20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 1153 additions and 308 deletions

View File

@ -57,14 +57,14 @@ import {
shoesGearIds,
} from "~/modules/in-game-lists/gear-ids";
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import { stagesObj as s, stageIds } from "~/modules/in-game-lists/stage-ids";
import type {
AbilityType,
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import { SENDOUQ_DEFAULT_MAPS } from "~/modules/tournament-map-list-generator/constants";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types";
import { nullFilledArray } from "~/utils/arrays";
import {
@ -91,6 +91,57 @@ import {
} from "./constants";
import placements from "./placements.json";
const SENDOUQ_DEFAULT_MAPS: Record<
ModeShort,
[StageId, StageId, StageId, StageId, StageId, StageId, StageId]
> = {
TW: [
s.EELTAIL_ALLEY,
s.HAGGLEFISH_MARKET,
s.UNDERTOW_SPILLWAY,
s.WAHOO_WORLD,
s.UM_AMI_RUINS,
s.HUMPBACK_PUMP_TRACK,
s.ROBO_ROM_EN,
],
SZ: [
s.HAGGLEFISH_MARKET,
s.MAHI_MAHI_RESORT,
s.INKBLOT_ART_ACADEMY,
s.MAKOMART,
s.HUMPBACK_PUMP_TRACK,
s.CRABLEG_CAPITAL,
s.ROBO_ROM_EN,
],
TC: [
s.ROBO_ROM_EN,
s.EELTAIL_ALLEY,
s.UNDERTOW_SPILLWAY,
s.MUSEUM_D_ALFONSINO,
s.MAKOMART,
s.MANTA_MARIA,
s.SHIPSHAPE_CARGO_CO,
],
RM: [
s.SCORCH_GORGE,
s.HAGGLEFISH_MARKET,
s.UNDERTOW_SPILLWAY,
s.MUSEUM_D_ALFONSINO,
s.FLOUNDER_HEIGHTS,
s.CRABLEG_CAPITAL,
s.MINCEMEAT_METALWORKS,
],
CB: [
s.SCORCH_GORGE,
s.INKBLOT_ART_ACADEMY,
s.BRINEWATER_SPRINGS,
s.MANTA_MARIA,
s.HUMPBACK_PUMP_TRACK,
s.UM_AMI_RUINS,
s.ROBO_ROM_EN,
],
};
const calendarEventWithToToolsRegOpen = () =>
calendarEventWithToTools("PICNIC", true);

View File

@ -53,7 +53,7 @@ describe("MapList.generate()", () => {
const maps = gen.next({ amount: 3 }).value;
expect(maps).toHaveLength(
new Set(maps.map((m) => `${m.mode}-${m.stageId}`)).size,
new Set(maps.map((m) => MapList.modeStageKey(m.mode, m.stageId))).size,
);
});
@ -522,3 +522,85 @@ describe("MapList.parsePattern()", () => {
).toBeInstanceOf(Err);
});
});
describe("MapList.generate() with initialWeights", () => {
it("accepts initialWeights parameter without errors", () => {
const mapPool = new MapPool({
SZ: [1, 2, 3],
TC: [4, 5],
RM: [],
CB: [],
TW: [],
});
const initialWeights = new Map<string, number>();
initialWeights.set("SZ-1", 100);
initialWeights.set("TC-44", -10);
const gen = MapList.generate({ mapPool, initialWeights });
gen.next();
const maps = gen.next({ amount: 3 }).value;
expect(maps).toHaveLength(3);
expect(maps.every((m) => mapPool.has(m))).toBe(true);
});
it("handles empty initialWeights", () => {
const mapPool = new MapPool({
SZ: [1, 2],
TC: [],
RM: [],
CB: [],
TW: [],
});
const gen = MapList.generate({ mapPool, initialWeights: new Map() });
gen.next();
const maps = gen.next({ amount: 2 }).value;
expect(maps).toHaveLength(2);
});
it("handles undefined initialWeights", () => {
const mapPool = new MapPool({
SZ: [1, 2],
TC: [],
RM: [],
CB: [],
TW: [],
});
const gen = MapList.generate({ mapPool });
gen.next();
const maps = gen.next({ amount: 2 }).value;
expect(maps).toHaveLength(2);
});
it("initialWeights affect stage selection", () => {
const mapPool = new MapPool({
SZ: [1, 2, 3, 4, 5],
TC: [],
RM: [],
CB: [],
TW: [],
});
const initialWeights = new Map<string, number>();
initialWeights.set("SZ-1", 1);
initialWeights.set("SZ-2", -1);
initialWeights.set("SZ-3", -1);
initialWeights.set("SZ-4", -1);
initialWeights.set("SZ-5", -1);
const gen = MapList.generate({ mapPool, initialWeights });
gen.next();
const maps = gen.next({ amount: 3 }).value;
expect(maps[0].stageId).toBe(1);
});
});

View File

@ -25,6 +25,15 @@ interface MaplistPattern {
pattern: Array<"ANY" | ModeShort>;
}
/**
* Creates a unique key for a stage-mode combination.
* @example
* stageModeKey("SZ", 1); // "1-SZ"
*/
export function modeStageKey(mode: ModeShort, stageId: StageId): string {
return `${mode}-${stageId}`;
}
/**
* Generates map lists that avoid repeating stages and optionally allows providing mode pattern.
*
@ -39,6 +48,8 @@ export function* generate(args: {
mapPool: MapPool;
/** Should the function bias in favor of maps not played? E.g. maps 4 & 5 in a Bo5 format (not every team plays them). Should be true if generating for tournament with best of format. */
considerGuaranteed?: boolean;
/** Initial weights for specific stage-mode combinations. Key format: `${mode}-${stageId}` (generate via `MapList.stageModeKey`). Negative weights can be used to deprioritize certain maps. */
initialWeights?: Map<string, number>;
}): Generator<Array<ModeWithStage>, Array<ModeWithStage>, GenerateNext> {
if (args.mapPool.isEmpty()) {
while (true) yield [];
@ -49,6 +60,7 @@ export function* generate(args: {
const { stageWeights, stageModeWeights } = initializeWeights(
modes,
args.mapPool.parsed,
args.initialWeights,
);
const orderedModes = modeOrders(modes);
let currentOrderIndex = 0;
@ -107,7 +119,7 @@ export function* generate(args: {
const stageModeWeightPenalty = args.mapPool.modes.length > 1 ? -10 : 0;
stageWeights.set(stageId, stageWeightPenalty);
stageModeWeights.set(`${stageId}-${mode}`, stageModeWeightPenalty);
stageModeWeights.set(modeStageKey(mode, stageId), stageModeWeightPenalty);
}
currentOrderIndex++;
@ -119,7 +131,11 @@ export function* generate(args: {
}
}
function initializeWeights(modes: ModeShort[], mapPool: ReadonlyMapPoolObject) {
function initializeWeights(
modes: ModeShort[],
mapPool: ReadonlyMapPoolObject,
initialWeights?: Map<string, number>,
) {
const stageWeights = new Map<StageId, number>();
const stageModeWeights = new Map<string, number>();
@ -127,7 +143,9 @@ function initializeWeights(modes: ModeShort[], mapPool: ReadonlyMapPoolObject) {
const stageIds = mapPool[mode];
for (const stageId of stageIds) {
stageWeights.set(stageId, 0);
stageModeWeights.set(`${stageId}-${mode}`, 0);
const key = modeStageKey(mode, stageId);
const initialWeight = initialWeights?.get(key) ?? 0;
stageModeWeights.set(key, initialWeight);
}
}
@ -172,7 +190,8 @@ function selectStageWeighted({
const getCandidates = () =>
possibleStages.filter((stageId) => {
const stageWeight = stageWeights.get(stageId) ?? 0;
const stageModeWeight = stageModeWeights.get(`${stageId}-${mode}`) ?? 0;
const stageModeWeight =
stageModeWeights.get(modeStageKey(mode, stageId)) ?? 0;
return stageWeight >= 0 && stageModeWeight >= 0;
});
@ -190,7 +209,8 @@ function selectStageWeighted({
return weightedRandomSelect(candidates, (stageId) => {
const stageWeight = stageWeights.get(stageId) ?? 0;
const stageModeWeight = stageModeWeights.get(`${stageId}-${mode}`) ?? 0;
const stageModeWeight =
stageModeWeights.get(modeStageKey(mode, stageId)) ?? 0;
return stageWeight + stageModeWeight + 1;
});
}

View File

@ -1,11 +1,9 @@
import { describe, expect, test } from "vitest";
import type { UserMapModePreferences } from "~/db/tables";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import type { StageId } from "~/modules/in-game-lists/types";
import { SENDOUQ_DEFAULT_MAPS } from "~/modules/tournament-map-list-generator/constants";
import { nullFilledArray } from "~/utils/arrays";
import * as Test from "~/utils/Test";
import { mapLottery, mapModePreferencesToModeList } from "./match.server";
import {
mapModePreferencesToModeList,
normalizeAndCombineWeights,
} from "./match.server";
describe("mapModePreferencesToModeList()", () => {
test("returns default list if no preferences", () => {
@ -126,74 +124,97 @@ describe("mapModePreferencesToModeList()", () => {
});
});
const MODES_COUNT = 4;
const STAGES_PER_MODE = 7;
describe("normalizeAndCombineWeights()", () => {
test("normalizes and combines weights when both teams have weights", () => {
const teamOneWeights = new Map([
["map1-SZ", 100],
["map2-TC", 50],
]);
const teamTwoWeights = new Map([
["map1-SZ", 200],
["map2-TC", 100],
]);
describe("mapLottery()", () => {
test("returns maps even if no preferences", () => {
const mapPool = mapLottery([], rankedModesShort);
const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights);
expect(mapPool.stageModePairs.length).toBe(STAGES_PER_MODE * MODES_COUNT);
expect(result.get("map1-SZ")).toBe(400);
expect(result.get("map2-TC")).toBe(200);
});
test("returns some maps from the map pools", () => {
const memberOnePool: UserMapModePreferences["pool"] = rankedModesShort.map(
(mode) => ({
mode,
stages: nullFilledArray(7).map((_, i) => (i + 1) as StageId),
}),
);
const memberTwoPool: UserMapModePreferences["pool"] = rankedModesShort.map(
(mode) => ({
mode,
stages: nullFilledArray(7).map((_, i) => (i + 10) as StageId),
}),
);
test("normalizes correctly when team totals differ", () => {
const teamOneWeights = new Map([["map1-SZ", 100]]);
const teamTwoWeights = new Map([["map1-SZ", 50]]);
const pool = mapLottery(
[
{ modes: [], pool: memberOnePool },
{ modes: [], pool: memberTwoPool },
],
rankedModesShort,
);
const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights);
expect(pool.stageModePairs.some((p) => p.stageId <= 7)).toBe(true);
expect(pool.stageModePairs.some((p) => p.stageId > 10)).toBe(true);
expect(result.get("map1-SZ")).toBe(100);
});
test("includes modes that were given and nothing else", () => {
const memberOnePool: UserMapModePreferences["pool"] = rankedModesShort.map(
(mode) => ({
mode,
stages: nullFilledArray(7).map((_, i) => (i + 1) as StageId),
}),
);
test("includes all keys from both teams", () => {
const teamOneWeights = new Map([
["map1-SZ", 100],
["map2-TC", 50],
]);
const teamTwoWeights = new Map([
["map1-SZ", 100],
["map3-RM", 50],
]);
const pool = mapLottery([{ modes: [], pool: memberOnePool }], ["SZ", "TC"]);
const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights);
expect(
pool.stageModePairs.every((p) => p.mode === "SZ" || p.mode === "TC"),
).toBe(true);
expect(result.has("map1-SZ")).toBe(true);
expect(result.has("map2-TC")).toBe(true);
expect(result.has("map3-RM")).toBe(true);
expect(result.size).toBe(3);
});
test("excludes map preferences if mode is avoided", () => {
const memberOnePool: UserMapModePreferences["pool"] = [
{
mode: "SZ",
stages: nullFilledArray(7).map((_, i) => (i + 1) as StageId),
},
];
test("handles team one having zero weights", () => {
const teamOneWeights = new Map<string, number>();
const teamTwoWeights = new Map([["map1-SZ", 100]]);
const pool = mapLottery(
[{ modes: [{ preference: "AVOID", mode: "SZ" }], pool: memberOnePool }],
["SZ"],
);
const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights);
expect(
pool.stageModePairs.every((p) =>
SENDOUQ_DEFAULT_MAPS.SZ.some((stageId) => stageId === p.stageId),
),
).toBe(true);
expect(result.get("map1-SZ")).toBe(100);
});
test("handles team two having zero weights", () => {
const teamOneWeights = new Map([["map1-SZ", 100]]);
const teamTwoWeights = new Map<string, number>();
const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights);
expect(result.get("map1-SZ")).toBe(100);
});
test("handles both teams having zero weights", () => {
const teamOneWeights = new Map<string, number>();
const teamTwoWeights = new Map<string, number>();
const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights);
expect(result.size).toBe(0);
});
test("handles keys present in one team but not the other", () => {
const teamOneWeights = new Map([["map1-SZ", 100]]);
const teamTwoWeights = new Map([["map2-TC", 200]]);
const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights);
expect(result.get("map1-SZ")).toBe(200);
expect(result.get("map2-TC")).toBe(200);
});
test("normalizes team one weight proportionally to team two total", () => {
const teamOneWeights = new Map([["map1-SZ", 40]]);
const teamTwoWeights = new Map([
["map1-SZ", 60],
["map2-TC", 40],
]);
const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights);
expect(result.get("map1-SZ")).toBe(160);
expect(result.get("map2-TC")).toBe(40);
});
});

View File

@ -1,26 +1,161 @@
import * as R from "remeda";
import type { ParsedMemento, UserMapModePreferences } from "~/db/tables";
import {
type DbMapPoolList,
MapPool,
} from "~/features/map-list-generator/core/map-pool";
import * as MapList from "~/features/map-list-generator/core/MapList";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import * as Seasons from "~/features/mmr/core/Seasons";
import { userSkills } from "~/features/mmr/tiered.server";
import { getDefaultMapWeights } from "~/features/sendouq/core/default-maps.server";
import { addSkillsToGroups } from "~/features/sendouq/core/groups.server";
import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants";
import type { LookingGroupWithInviteCode } from "~/features/sendouq/q-types";
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import {
BANNED_MAPS,
SENDOUQ_MAP_POOL,
} from "~/features/sendouq-settings/banned-maps";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { generateBalancedMapList } from "~/modules/tournament-map-list-generator/balanced-map-list";
import { SENDOUQ_DEFAULT_MAPS } from "~/modules/tournament-map-list-generator/constants";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types";
import invariant from "~/utils/invariant";
import type { ModeShort, ModeWithStage } from "~/modules/in-game-lists/types";
import type {
TournamentMapListMap,
TournamentMaplistSource,
} from "~/modules/tournament-map-list-generator/types";
import { logger } from "~/utils/logger";
import { averageArray } from "~/utils/number";
import type { MatchById } from "../queries/findMatchById.server";
export function matchMapList(
type WeightsMap = Map<string, number>;
async function calculateMapWeights(
groupOnePreferences: UserMapModePreferences[],
groupTwoPreferences: UserMapModePreferences[],
modesIncluded: ModeShort[],
): Promise<WeightsMap> {
const teamOneVotes: WeightsMap = new Map();
const teamTwoVotes: WeightsMap = new Map();
countVotesForTeam(modesIncluded, groupOnePreferences, teamOneVotes);
countVotesForTeam(modesIncluded, groupTwoPreferences, teamTwoVotes);
const applyWeightFormula = (voteCount: number) =>
// 1, 4 or 9 (cap)
Math.min(voteCount * voteCount, 9);
const teamOneWeights: WeightsMap = new Map();
const teamTwoWeights: WeightsMap = new Map();
for (const [key, votes] of teamOneVotes) {
teamOneWeights.set(key, applyWeightFormula(votes));
}
for (const [key, votes] of teamTwoVotes) {
teamTwoWeights.set(key, applyWeightFormula(votes));
}
const combinedWeights = normalizeAndCombineWeights(
teamOneWeights,
teamTwoWeights,
);
return applyDefaultWeights(combinedWeights);
}
/**
* Normalizes and combines map weights from two teams.
*
* When both teams have weights, team one's weights are normalized to match
* team two's total before combining. This ensures fair weighting when teams
* have different numbers of preferences.
*
* @returns Combined weights map with all keys from both teams
*/
export function normalizeAndCombineWeights(
teamOneWeights: Map<string, number>,
teamTwoWeights: Map<string, number>,
): Map<string, number> {
const teamOneTotal = Array.from(teamOneWeights.values()).reduce(
(sum, w) => sum + w,
0,
);
const teamTwoTotal = Array.from(teamTwoWeights.values()).reduce(
(sum, w) => sum + w,
0,
);
const combinedWeights = new Map<string, number>();
const allKeys = new Set([...teamOneWeights.keys(), ...teamTwoWeights.keys()]);
for (const key of allKeys) {
const teamOneWeight = teamOneWeights.get(key) ?? 0;
const teamTwoWeight = teamTwoWeights.get(key) ?? 0;
if (teamOneTotal > 0 && teamTwoTotal > 0) {
const normalizedTeamOne = (teamOneWeight / teamOneTotal) * teamTwoTotal;
combinedWeights.set(key, normalizedTeamOne + teamTwoWeight);
} else {
combinedWeights.set(key, teamOneWeight + teamTwoWeight);
}
}
return combinedWeights;
}
/**
* Applies default map weights to combined weights for any maps not already weighted.
*
* Fetches global default weights and adds them to the combined weights map for any
* map-mode combinations that don't already have weights. This ensures the pool always
* has a baseline selection of maps.
*
* @returns Combined weights with defaults applied
*/
async function applyDefaultWeights(
combinedWeights: WeightsMap,
): Promise<WeightsMap> {
let defaultWeights: WeightsMap;
try {
defaultWeights = await getDefaultMapWeights();
} catch (err) {
logger.error(
`[calculateMapWeights] Failed to get default map weights: ${err}`,
);
defaultWeights = new Map();
}
for (const [key, weight] of defaultWeights) {
if (!combinedWeights.has(key)) {
combinedWeights.set(key, weight);
}
}
return combinedWeights;
}
function countVotesForTeam(
modesIncluded: ModeShort[],
preferences: UserMapModePreferences[],
votesMap: WeightsMap,
) {
for (const preference of preferences) {
for (const poolEntry of preference.pool) {
if (!modesIncluded.includes(poolEntry.mode)) continue;
const avoidedMode = preference.modes.find(
(m) => m.mode === poolEntry.mode && m.preference === "AVOID",
);
if (avoidedMode) continue;
for (const stageId of poolEntry.stages) {
if (BANNED_MAPS[poolEntry.mode].includes(stageId)) continue;
votesMap.set(
MapList.modeStageKey(poolEntry.mode, stageId),
(votesMap.get(MapList.modeStageKey(poolEntry.mode, stageId)) ?? 0) +
1,
);
}
}
}
}
export async function matchMapList(
groupOne: {
preferences: { userId: number; preferences: UserMapModePreferences }[];
id: number;
@ -31,7 +166,7 @@ export function matchMapList(
id: number;
ignoreModePreferences?: boolean;
},
) {
): Promise<TournamentMapListMap[]> {
const modesIncluded = mapModePreferencesToModeList(
groupOne.ignoreModePreferences
? []
@ -41,107 +176,64 @@ export function matchMapList(
: groupTwo.preferences.map(({ preferences }) => preferences.modes),
);
try {
return generateBalancedMapList({
count: SENDOUQ_BEST_OF,
seed: String(groupOne.id),
modesIncluded,
tiebreakerMaps: new MapPool([]),
followModeOrder: true,
teams: [
{
id: groupOne.id,
maps: mapLottery(
groupOne.preferences.map((p) => p.preferences),
modesIncluded,
),
},
{
id: groupTwo.id,
maps: mapLottery(
groupTwo.preferences.map((p) => p.preferences),
modesIncluded,
),
},
],
});
// in rare cases, the map list generator can fail
// in that case, just return a map list from our default set of maps
} catch (e) {
logger.error(e);
return generateBalancedMapList({
count: SENDOUQ_BEST_OF,
seed: String(groupOne.id),
modesIncluded,
tiebreakerMaps: new MapPool([]),
teams: [
{
id: groupOne.id,
maps: new MapPool([]),
},
{
id: groupTwo.id,
maps: new MapPool([]),
},
],
});
}
}
const weights = await calculateMapWeights(
groupOne.preferences.map((p) => p.preferences),
groupTwo.preferences.map((p) => p.preferences),
modesIncluded,
);
const MAPS_PER_MODE = 7;
logger.info(
`[matchMapList] Generated map weights: ${JSON.stringify(
Array.from(weights.entries()),
)}`,
);
export function mapLottery(
preferences: UserMapModePreferences[],
modes: ModeShort[],
) {
invariant(modes.length > 0, "mapLottery: no modes");
const generator = MapList.generate({
mapPool: new MapPool(
SENDOUQ_MAP_POOL.stageModePairs.filter((pair) =>
modesIncluded.includes(pair.mode),
),
),
initialWeights: weights,
});
generator.next();
const mapPoolList: DbMapPoolList = [];
const maps = generator.next({ amount: SENDOUQ_BEST_OF }).value;
for (const mode of modes) {
const stageIdsFromPools = R.shuffle(
preferences.flatMap((preference) => {
// if they disliked the mode don't include their maps
// they are just saved in the DB so they can be restored later
if (
preference.modes.find((mp) => mp.mode === mode)?.preference ===
"AVOID"
) {
return [];
}
return preference.pool.find((pool) => pool.mode === mode)?.stages ?? [];
}),
const resolveSource = (map: ModeWithStage): TournamentMaplistSource => {
const groupOnePrefers = groupOne.preferences.some((p) =>
p.preferences.pool.some(
(pool) => pool.mode === map.mode && pool.stages.includes(map.stageId),
),
);
const groupTwoPrefers = groupTwo.preferences.some((p) =>
p.preferences.pool.some(
(pool) => pool.mode === map.mode && pool.stages.includes(map.stageId),
),
);
const modeStageIdsForMatch: StageId[] = [];
for (const stageId of stageIdsFromPools) {
if (modeStageIdsForMatch.length === MAPS_PER_MODE) break;
if (
modeStageIdsForMatch.includes(stageId) ||
BANNED_MAPS[mode].includes(stageId)
) {
continue;
}
modeStageIdsForMatch.push(stageId);
if (groupOnePrefers && groupTwoPrefers) {
return "BOTH";
}
if (groupOnePrefers) {
return groupOne.id;
}
if (groupTwoPrefers) {
return groupTwo.id;
}
if (modeStageIdsForMatch.length === MAPS_PER_MODE) {
for (const stageId of modeStageIdsForMatch) {
mapPoolList.push({ mode, stageId });
}
// this should only happen if they made no map picks at all yet
// as when everyone avoids a mode it can't appear
// and if they select mode as neutral/prefer you need to pick 7 maps
} else {
mapPoolList.push(
...SENDOUQ_DEFAULT_MAPS[mode].map((stageId) => ({ mode, stageId })),
);
}
return "DEFAULT";
};
const result = maps.map((map) => ({ ...map, source: resolveSource(map) }));
if (result.some((m) => m.source === "DEFAULT")) {
logger.info(
`[matchMapList] Some maps were selected from DEFAULT source. groupOne: ${JSON.stringify(groupOne)}, groupTwo: ${JSON.stringify(groupTwo)}`,
);
}
return new MapPool(mapPoolList);
return result;
}
export function mapModePreferencesToModeList(

View File

@ -29,6 +29,7 @@ import { ArchiveBoxIcon } from "~/components/icons/ArchiveBox";
import { DiscordIcon } from "~/components/icons/Discord";
import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows";
import { ScaleIcon } from "~/components/icons/Scale";
import { UsersIcon } from "~/components/icons/Users";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { WeaponSelect } from "~/components/WeaponSelect";
@ -1294,18 +1295,24 @@ function MapListMapPickInfo({
const pickInfo = (source: string) => {
if (source === "TIEBREAKER") return t("tournament:pickInfo.tiebreaker");
if (source === "BOTH") return t("tournament:pickInfo.both");
if (source === "DEFAULT") return t("tournament:pickInfo.default");
if (source === String(data.match.alphaGroupId)) {
return t("tournament:pickInfo.team.specific", {
team: t("q:match.sides.alpha"),
});
}
const poolMemberIds = sourcePoolMemberIds();
const playerCount =
poolMemberIds.length > 0
? poolMemberIds.length
: (mapPreferences?.length ?? 0);
return t("tournament:pickInfo.team.specific", {
team: t("q:match.sides.bravo"),
});
return (
<div className="stack horizontal xs items-center">
<UsersIcon className="w-4" />
<span>
{t("tournament:pickInfo.votes", {
count: playerCount,
})}
</span>
</div>
);
};
const userIdToUser = (userId: number) => {
@ -1348,6 +1355,8 @@ function MapListMapPickInfo({
// legacy preference system (season 2)
if (mapPreferences && mapPreferences.length > 0) return true;
if (map.source === "DEFAULT") return true;
return sourcePoolMemberIds().length > 0;
};
@ -1365,7 +1374,11 @@ function MapListMapPickInfo({
{t(`game-misc:MODE_SHORT_${map.mode}`)}{" "}
{t(`game-misc:STAGE_${map.stageId}`)}
</div>
{sourcePoolMemberIds().length > 0 ? (
{map.source === "DEFAULT" ? (
<div className="text-sm text-center text-lighter">
{t("tournament:pickInfo.default.explanation")}
</div>
) : sourcePoolMemberIds().length > 0 ? (
<div className="stack sm">
{sourcePoolMemberIds().map((userId) => {
const user = userIdToUser(userId);

View File

@ -1,5 +1,6 @@
import { stagesObj as s } from "~/modules/in-game-lists/stage-ids";
import { stagesObj as s, stageIds } from "~/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { MapPool } from "../map-list-generator/core/map-pool";
export const BANNED_MAPS: Record<ModeShort, StageId[]> = {
TW: [],
@ -44,3 +45,11 @@ export const BANNED_MAPS: Record<ModeShort, StageId[]> = {
s.LEMURIA_HUB,
],
};
export const SENDOUQ_MAP_POOL = new MapPool({
TW: stageIds.filter((stageId) => !BANNED_MAPS.TW.includes(stageId)),
SZ: stageIds.filter((stageId) => !BANNED_MAPS.SZ.includes(stageId)),
TC: stageIds.filter((stageId) => !BANNED_MAPS.TC.includes(stageId)),
RM: stageIds.filter((stageId) => !BANNED_MAPS.RM.includes(stageId)),
CB: stageIds.filter((stageId) => !BANNED_MAPS.CB.includes(stageId)),
});

View File

@ -1,6 +1,5 @@
import { z } from "zod/v4";
import { languagesUnified } from "~/modules/i18n/config";
import { modesShort } from "~/modules/in-game-lists/modes";
import {
_action,
checkboxValueToBoolean,
@ -27,27 +26,19 @@ export const settingsActionSchema = z.union([
modes: z.array(z.object({ mode: modeShort, preference })),
pool: z.array(
z.object({
stages: z.array(stageId).length(AMOUNT_OF_MAPS_IN_POOL_PER_MODE),
stages: z.array(stageId).max(AMOUNT_OF_MAPS_IN_POOL_PER_MODE),
mode: modeShort,
}),
),
})
.refine((val) =>
val.pool.every((pool) => {
const mp = val.modes.find((m) => m.mode === pool.mode);
return mp?.preference !== "AVOID";
}, "Can't have map pool for a mode that was avoided"),
)
.refine((val) => {
for (const mode of modesShort) {
const mp = val.modes.find((m) => m.mode === mode);
if (mp?.preference === "AVOID") continue;
if (!val.pool.some((p) => p.mode === mode)) return false;
}
return true;
}, "Pool has to be picked for each mode that wasn't avoided"),
.refine(
(val) =>
val.pool.every((pool) => {
const mp = val.modes.find((m) => m.mode === pool.mode);
return mp?.preference !== "AVOID";
}),
"Can't have map pool for a mode that was avoided",
),
),
}),
z.object({

View File

@ -143,7 +143,7 @@ function MapPicker() {
if (mp?.preference === "AVOID") continue;
const pool = preferences.pool.find((p) => p.mode === mode);
if (!pool || pool.stages.length !== AMOUNT_OF_MAPS_IN_POOL_PER_MODE) {
if (pool && pool.stages.length > AMOUNT_OF_MAPS_IN_POOL_PER_MODE) {
return false;
}
}

View File

@ -1,5 +1,5 @@
import { sub } from "date-fns";
import { sql } from "kysely";
import { type NotNull, sql } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type {
@ -21,9 +21,8 @@ export function mapModePreferencesByGroupId(groupId: number) {
.select(["User.id as userId", "User.mapModePreferences as preferences"])
.where("GroupMember.groupId", "=", groupId)
.where("User.mapModePreferences", "is not", null)
.execute() as Promise<
{ userId: number; preferences: UserMapModePreferences }[]
>;
.$narrowType<{ preferences: NotNull }>()
.execute();
}
// groups visible for longer to make development easier
@ -395,3 +394,16 @@ export async function setOldGroupsAsInactive() {
.executeTakeFirst();
});
}
export async function mapModePreferencesBySeasonNth(seasonNth: number) {
return db
.selectFrom("Skill")
.innerJoin("User", "User.id", "Skill.userId")
.select("User.mapModePreferences")
.where("Skill.season", "=", seasonNth)
.where("Skill.userId", "is not", null)
.where("User.mapModePreferences", "is not", null)
.groupBy("Skill.userId")
.$narrowType<{ mapModePreferences: UserMapModePreferences }>()
.execute();
}

View File

@ -226,7 +226,7 @@ export const action: ActionFunction = async ({ request }) => {
);
const theirGroupPreferences =
await QRepository.mapModePreferencesByGroupId(theirGroup.id);
const mapList = matchMapList(
const mapList = await matchMapList(
{
id: ourGroup.id,
preferences: ourGroupPreferences,

View File

@ -160,22 +160,36 @@ export function GroupCard({
</div>
) : null}
{group.tierRange?.range ? (
<div className="stack sm items-center">
<div className="q__group__tier-diff-text">
±{group.tierRange.diff}
</div>
<div className="stack items-center">
<div className="stack sm horizontal items-center text-sm font-bold">
<TierImage tier={group.tierRange.range[0]} width={38} />
{t("q:looking.range.or")}
<TierImage tier={group.tierRange.range[1]} width={38} />
</div>
{group.isReplay ? (
<div className="text-theme-secondary text-uppercase text-xs font-bold">
{t("q:looking.replay")}
<div className="stack md items-center">
<div className="stack sm horizontal items-center justify-center">
<div className="stack xs items-center">
<TierImage tier={group.tierRange.range[0]} width={80} />
<div className="text-lighter text-sm font-bold">
(-{group.tierRange.diff})
</div>
) : null}
</div>
<SendouPopover
popoverClassName="text-main-forced"
trigger={
<SendouButton className="q__group__or-popover-button">
{t("q:looking.range.or")}
</SendouButton>
}
>
{t("q:looking.range.or.explanation")}
</SendouPopover>
<div className="stack xs items-center">
<TierImage tier={group.tierRange.range[1]} width={80} />
<div className="text-lighter text-sm font-bold">
(+{group.tierRange.diff})
</div>
</div>
</div>
{group.isReplay ? (
<div className="text-theme-secondary text-uppercase text-xs font-bold">
{t("q:looking.replay")}
</div>
) : null}
</div>
) : null}
{group.skillDifference ? (

View File

@ -0,0 +1,436 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { db } from "~/db/sql";
import type { UserMapModePreferences } from "~/db/tables";
import type { StageId } from "~/modules/in-game-lists/types";
import { dbInsertUsers, dbReset } from "~/utils/Test";
import {
clearCacheForTesting,
getDefaultMapWeights,
} from "./default-maps.server";
const { mockSeasonCurrent, mockSeasonPrevious } = vi.hoisted(() => ({
mockSeasonCurrent: vi.fn(),
mockSeasonPrevious: vi.fn(),
}));
vi.mock("~/features/mmr/core/Seasons", () => ({
current: mockSeasonCurrent,
previous: mockSeasonPrevious,
}));
describe("getDefaultMapWeights()", () => {
beforeEach(async () => {
vi.clearAllMocks();
clearCacheForTesting();
await dbInsertUsers(10);
});
afterEach(() => {
vi.restoreAllMocks();
dbReset();
});
test("returns empty map when no season is found", async () => {
mockSeasonCurrent.mockReturnValue(null);
mockSeasonPrevious.mockReturnValue(null);
const result = await getDefaultMapWeights();
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
test("uses previous season when current season is less than 7 days old", async () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
mockSeasonCurrent.mockReturnValue({
nth: 2,
starts: yesterday,
ends: new Date("2030-12-31"),
});
mockSeasonPrevious.mockReturnValue({
nth: 1,
starts: new Date("2023-01-01"),
ends: new Date("2023-12-31"),
});
await insertUserMapModePreferencesForSeason({
seasonNth: 1,
userPreferences: [
{
userId: 1,
mapModePreferences: {
modes: [],
pool: [{ mode: "SZ", stages: [0] }],
},
},
],
});
const result = await getDefaultMapWeights();
expect(result.size).toBeGreaterThan(0);
});
test("uses current season when it is 7 or more days old", async () => {
const eightDaysAgo = new Date();
eightDaysAgo.setDate(eightDaysAgo.getDate() - 8);
mockSeasonCurrent.mockReturnValue({
nth: 2,
starts: eightDaysAgo,
ends: new Date("2030-12-31"),
});
mockSeasonPrevious.mockReturnValue({
nth: 1,
starts: new Date("2023-01-01"),
ends: new Date("2023-12-31"),
});
await insertUserMapModePreferencesForSeason({
seasonNth: 2,
userPreferences: [
{
userId: 1,
mapModePreferences: {
modes: [],
pool: [{ mode: "SZ", stages: [0] }],
},
},
],
});
const result = await getDefaultMapWeights();
expect(result.size).toBeGreaterThan(0);
});
test("uses previous season when current season does not exist", async () => {
mockSeasonCurrent.mockReturnValue(null);
mockSeasonPrevious.mockReturnValue({
nth: 1,
starts: new Date("2023-01-01"),
ends: new Date("2023-12-31"),
});
await insertUserMapModePreferencesForSeason({
seasonNth: 1,
userPreferences: [
{
userId: 1,
mapModePreferences: {
modes: [],
pool: [{ mode: "SZ", stages: [0] }],
},
},
],
});
const result = await getDefaultMapWeights();
expect(result.size).toBeGreaterThan(0);
});
test("returns weights for top 7 maps per mode", async () => {
mockSeasonCurrent.mockReturnValue({
nth: 1,
starts: new Date("2023-01-01"),
ends: new Date("2023-12-31"),
});
await insertUserMapModePreferencesForSeason({
seasonNth: 1,
userPreferences: [
{
userId: 1,
mapModePreferences: {
modes: [],
pool: [
{ mode: "SZ", stages: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] },
{ mode: "TC", stages: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] },
],
},
},
{
userId: 2,
mapModePreferences: {
modes: [],
pool: [
{ mode: "SZ", stages: [0, 1, 2, 3, 4, 5, 6] },
{ mode: "TC", stages: [1, 2, 3, 4, 5, 6, 7] },
],
},
},
],
});
const result = await getDefaultMapWeights();
const szMaps = Array.from(result.keys()).filter((key) =>
key.startsWith("SZ-"),
);
const tcMaps = Array.from(result.keys()).filter((key) =>
key.startsWith("TC-"),
);
expect(szMaps.length).toBe(7);
expect(tcMaps.length).toBe(7);
});
test("assigns weight of -1 to all selected maps", async () => {
mockSeasonCurrent.mockReturnValue({
nth: 1,
starts: new Date("2023-01-01"),
ends: new Date("2023-12-31"),
});
await insertUserMapModePreferencesForSeason({
seasonNth: 1,
userPreferences: [
{
userId: 1,
mapModePreferences: {
modes: [],
pool: [{ mode: "SZ", stages: [0, 1, 2] }],
},
},
],
});
const result = await getDefaultMapWeights();
for (const weight of result.values()) {
expect(weight).toBe(-1);
}
});
test("does not count preferences for avoided modes", async () => {
mockSeasonCurrent.mockReturnValue({
nth: 1,
starts: new Date("2023-01-01"),
ends: new Date("2023-12-31"),
});
await insertUserMapModePreferencesForSeason({
seasonNth: 1,
userPreferences: [
{
userId: 1,
mapModePreferences: {
modes: [{ mode: "SZ", preference: "AVOID" }],
pool: [
{ mode: "SZ", stages: [0, 1, 2] },
{ mode: "TC", stages: [0, 1, 2] },
],
},
},
{
userId: 2,
mapModePreferences: {
modes: [{ mode: "SZ", preference: "AVOID" }],
pool: [
{ mode: "SZ", stages: [0, 1, 2] },
{ mode: "TC", stages: [0, 1, 2] },
],
},
},
{
userId: 3,
mapModePreferences: {
modes: [],
pool: [
{ mode: "SZ", stages: [3, 4, 5] },
{ mode: "TC", stages: [0, 1, 2] },
],
},
},
],
});
const result = await getDefaultMapWeights();
const szMaps = Array.from(result.keys()).filter((key) =>
key.startsWith("SZ-"),
);
const tcMaps = Array.from(result.keys()).filter((key) =>
key.startsWith("TC-"),
);
expect(tcMaps.length).toBe(3);
expect(szMaps.length).toBe(3);
});
test("handles users with no pool preferences", async () => {
mockSeasonCurrent.mockReturnValue({
nth: 1,
starts: new Date("2023-01-01"),
ends: new Date("2023-12-31"),
});
await insertUserMapModePreferencesForSeason({
seasonNth: 1,
userPreferences: [
{
userId: 1,
mapModePreferences: {
modes: [],
pool: undefined as any,
},
},
{
userId: 2,
mapModePreferences: {
modes: [],
pool: [{ mode: "SZ", stages: [0, 1, 2] }],
},
},
],
});
const result = await getDefaultMapWeights();
expect(result.size).toBeGreaterThan(0);
});
test("selects most popular maps across users", async () => {
mockSeasonCurrent.mockReturnValue({
nth: 1,
starts: new Date("2023-01-01"),
ends: new Date("2023-12-31"),
});
const FROM_ZERO_TO_SIX = Array.from({ length: 7 }, (_, i) => i as StageId);
await insertUserMapModePreferencesForSeason({
seasonNth: 1,
userPreferences: [
{
userId: 1,
mapModePreferences: {
modes: [
{
mode: "SZ",
preference: "PREFER",
},
],
pool: [{ mode: "SZ", stages: [1, 2, 3, 4, 5, 6, 7] }],
},
},
{
userId: 2,
mapModePreferences: {
modes: [
{
mode: "SZ",
preference: "PREFER",
},
],
pool: [{ mode: "SZ", stages: FROM_ZERO_TO_SIX }],
},
},
{
userId: 3,
mapModePreferences: {
modes: [
{
mode: "SZ",
preference: "PREFER",
},
],
pool: [{ mode: "SZ", stages: FROM_ZERO_TO_SIX }],
},
},
],
});
const result = await getDefaultMapWeights();
expect(result.has("SZ-0")).toBe(true);
expect(result.has("SZ-7")).toBe(false);
});
test("handles empty preferences array", async () => {
mockSeasonCurrent.mockReturnValue({
nth: 1,
starts: new Date("2023-01-01"),
ends: new Date("2023-12-31"),
});
const result = await getDefaultMapWeights();
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
test("processes all ranked modes (SZ, TC, RM, CB)", async () => {
mockSeasonCurrent.mockReturnValue({
nth: 1,
starts: new Date("2023-01-01"),
ends: new Date("2023-12-31"),
});
await insertUserMapModePreferencesForSeason({
seasonNth: 1,
userPreferences: [
{
userId: 1,
mapModePreferences: {
modes: [],
pool: [
{ mode: "SZ", stages: [0, 1, 2, 3, 4, 5, 6, 7] },
{ mode: "TC", stages: [0, 1, 2, 3, 4, 5, 6, 7] },
{ mode: "RM", stages: [0, 1, 2, 3, 4, 5, 6, 7] },
{ mode: "CB", stages: [0, 1, 2, 3, 4, 5, 6, 7] },
],
},
},
],
});
const result = await getDefaultMapWeights();
const modes = new Set<string>();
for (const key of result.keys()) {
const mode = key.split("-")[0];
modes.add(mode);
}
expect(modes.has("SZ")).toBe(true);
expect(modes.has("TC")).toBe(true);
expect(modes.has("RM")).toBe(true);
expect(modes.has("CB")).toBe(true);
});
});
// using db directly here instead of repositories as inserting skills would be too much of a hassle
async function insertUserMapModePreferencesForSeason({
seasonNth,
userPreferences,
}: {
seasonNth: number;
userPreferences: Array<{
userId: number;
mapModePreferences: UserMapModePreferences;
}>;
}) {
for (const { userId, mapModePreferences } of userPreferences) {
await db
.updateTable("User")
.set({ mapModePreferences: JSON.stringify(mapModePreferences) })
.where("id", "=", userId)
.execute();
await db
.insertInto("Skill")
.values({
userId,
season: seasonNth,
mu: 25,
sigma: 8.333,
ordinal: 0,
matchesCount: 10,
})
.execute();
}
}

View File

@ -0,0 +1,98 @@
import { differenceInDays } from "date-fns";
import * as MapList from "~/features/map-list-generator/core/MapList";
import * as Seasons from "~/features/mmr/core/Seasons";
import { modesShort } from "~/modules/in-game-lists/modes";
import { logger } from "~/utils/logger";
import * as QRepository from "../QRepository.server";
let cachedDefaults: Map<string, number> | null = null;
const ONE_WEEK_IN_DAYS = 7;
const DEFAULT_MAP_WEIGHT = -1;
const TOP_MAPS_PER_MODE = 7;
export function clearCacheForTesting() {
cachedDefaults = null;
}
export async function getDefaultMapWeights(): Promise<Map<string, number>> {
const season = resolveSeasonForDefaults();
if (!season) {
logger.warn(
"[getDefaultMapWeights] No season found for default map weights",
);
return new Map();
}
if (cachedDefaults) {
return cachedDefaults;
}
const weights = await calculateSeasonDefaultMaps(season.nth);
logger.info(
`[getDefaultMapWeights] Calculated default map weights: ${JSON.stringify(Object.fromEntries(weights))}`,
);
cachedDefaults = weights;
return weights;
}
function resolveSeasonForDefaults() {
const currentSeason = Seasons.current();
if (!currentSeason) {
return Seasons.previous();
}
const daysSinceSeasonStart = differenceInDays(
new Date(),
currentSeason.starts,
);
if (daysSinceSeasonStart < ONE_WEEK_IN_DAYS) {
return Seasons.previous();
}
return currentSeason;
}
async function calculateSeasonDefaultMaps(
seasonNth: number,
): Promise<Map<string, number>> {
const activeUsersWithPreferences =
await QRepository.mapModePreferencesBySeasonNth(seasonNth);
const mapModeCounts = new Map<string, number>();
for (const row of activeUsersWithPreferences) {
const preferences = row.mapModePreferences;
if (!preferences?.pool) continue;
for (const poolEntry of preferences.pool) {
const avoidedMode = preferences.modes.find(
(m) => m.mode === poolEntry.mode && m.preference === "AVOID",
);
if (avoidedMode) continue;
for (const stageId of poolEntry.stages) {
mapModeCounts.set(
MapList.modeStageKey(poolEntry.mode, stageId),
(mapModeCounts.get(MapList.modeStageKey(poolEntry.mode, stageId)) ??
0) + 1,
);
}
}
}
const weights = new Map<string, number>();
for (const mode of modesShort) {
const mapsForMode = Array.from(mapModeCounts.entries())
.filter(([key]) => key.includes(mode))
.sort((a, b) => b[1] - a[1])
.slice(0, TOP_MAPS_PER_MODE);
for (const [key] of mapsForMode) {
weights.set(key, DEFAULT_MAP_WEIGHT);
}
}
return weights;
}

View File

@ -316,8 +316,16 @@
transform: translate(-50%, -50%);
}
.q__group__tier-diff-text {
font-size: 3rem;
.q__group__or-popover-button {
background-color: transparent;
color: var(--text-lighter);
font-size: var(--fonts-xs);
padding: 0;
border: none;
text-decoration: underline;
text-decoration-style: dotted;
font-weight: var(--bold);
height: 19.8281px;
}
.q__member-adder__input {

View File

@ -1,75 +1,4 @@
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import invariant from "~/utils/invariant";
import { modesShort } from "../in-game-lists/modes";
import { stagesObj as s } from "../in-game-lists/stage-ids";
import type { ModeShort, StageId } from "../in-game-lists/types";
export const SENDOUQ_DEFAULT_MAPS: Record<
ModeShort,
[StageId, StageId, StageId, StageId, StageId, StageId, StageId]
> = {
TW: [
s.EELTAIL_ALLEY,
s.HAGGLEFISH_MARKET,
s.UNDERTOW_SPILLWAY,
s.WAHOO_WORLD,
s.UM_AMI_RUINS,
s.HUMPBACK_PUMP_TRACK,
s.ROBO_ROM_EN,
],
SZ: [
s.HAGGLEFISH_MARKET,
s.MAHI_MAHI_RESORT,
s.INKBLOT_ART_ACADEMY,
s.MAKOMART,
s.HUMPBACK_PUMP_TRACK,
s.CRABLEG_CAPITAL,
s.ROBO_ROM_EN,
],
TC: [
s.ROBO_ROM_EN,
s.EELTAIL_ALLEY,
s.UNDERTOW_SPILLWAY,
s.MUSEUM_D_ALFONSINO,
s.MAKOMART,
s.MANTA_MARIA,
s.SHIPSHAPE_CARGO_CO,
],
RM: [
s.SCORCH_GORGE,
s.HAGGLEFISH_MARKET,
s.UNDERTOW_SPILLWAY,
s.MUSEUM_D_ALFONSINO,
s.FLOUNDER_HEIGHTS,
s.CRABLEG_CAPITAL,
s.MINCEMEAT_METALWORKS,
],
CB: [
s.SCORCH_GORGE,
s.INKBLOT_ART_ACADEMY,
s.BRINEWATER_SPRINGS,
s.MANTA_MARIA,
s.HUMPBACK_PUMP_TRACK,
s.UM_AMI_RUINS,
s.ROBO_ROM_EN,
],
};
for (const mode of modesShort) {
invariant(
SENDOUQ_DEFAULT_MAPS[mode].length ===
new Set(SENDOUQ_DEFAULT_MAPS[mode]).size,
"Duplicate maps in SENDOUQ_DEFAULT_MAPS",
);
invariant(
BANNED_MAPS[mode].every(
(stageId) => !SENDOUQ_DEFAULT_MAPS[mode].includes(stageId),
),
`Banned maps in the default map pool of ${mode}`,
);
}
export const sourceTypes = [
"DEFAULT",

View File

@ -120,6 +120,7 @@
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.range.or": "",
"looking.range.or.explanation": "",
"match.header": "",
"match.spInfo": "",
"match.dispute.button": "",

View File

@ -47,7 +47,10 @@
"pickInfo.team.specific": "{{team}} valgte",
"pickInfo.tiebreaker": "Tiebreak",
"pickInfo.both": "begge hold valgte",
"pickInfo.default": "Turneringsspecifik standardbane valgt",
"pickInfo.default": "",
"pickInfo.default.explanation": "",
"pickInfo.votes_one": "",
"pickInfo.votes_other": "",
"pickInfo.counterpick": "Modvalg",
"generator.error": "Ændringer, som du har lavet, blev ikke gemt, da turneringen nu er begyndt. ",
"teams.mapsPickedStatus": "Status for banevalg",

View File

@ -120,6 +120,7 @@
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.range.or": "",
"looking.range.or.explanation": "",
"match.header": "",
"match.spInfo": "",
"match.dispute.button": "",

View File

@ -47,7 +47,10 @@
"pickInfo.team.specific": "Von {{team}} ausgewählt",
"pickInfo.tiebreaker": "Tiebreaker",
"pickInfo.both": "Von beiden ausgewählt",
"pickInfo.default": "Standardmäßige Arena",
"pickInfo.default": "",
"pickInfo.default.explanation": "",
"pickInfo.votes_one": "",
"pickInfo.votes_other": "",
"pickInfo.counterpick": "Counterpick",
"generator.error": "Die Änderungen wurden nicht gespeichert, weil das Turnier bereits begonnen hat",
"teams.mapsPickedStatus": "Status ausgewählter Arenen",

View File

@ -120,6 +120,7 @@
"looking.allTiers": "All tiers",
"looking.joinQPrompt": "Join the queue to find a group",
"looking.range.or": "or",
"looking.range.or.explanation": "You will be matched with a group whose average rank is one of these. Exact is not shown to avoid behavior where groups only play against tiers lower than themselves.",
"match.header": "Match #{{number}}",
"match.spInfo": "SP will be adjusted after both teams report the same result",
"match.dispute.button": "Dispute?",

View File

@ -47,7 +47,10 @@
"pickInfo.team.specific": "{{team}} picked",
"pickInfo.tiebreaker": "Tiebreaker",
"pickInfo.both": "Both picked",
"pickInfo.default": "Default map",
"pickInfo.default": "Community's choice",
"pickInfo.default.explanation": "A suitable map was not available from the participants' pools. This map was selected from all popular maps instead.",
"pickInfo.votes_one": "{{count}} vote",
"pickInfo.votes_other": "{{count}} votes",
"pickInfo.counterpick": "Counterpick",
"generator.error": "Changes you made weren't saved since the tournament started",
"teams.mapsPickedStatus": "Maps picked status",

View File

@ -120,6 +120,7 @@
"looking.allTiers": "Todos los niveles",
"looking.joinQPrompt": "Únete a la fila para encontrar un grupo",
"looking.range.or": "o",
"looking.range.or.explanation": "",
"match.header": "Partido #{{number}}",
"match.spInfo": "Fuerza Sendou será ajustada despues de que ambos equipos informen el mismo resultado",
"match.dispute.button": "¿Disputar?",

View File

@ -47,7 +47,11 @@
"pickInfo.team.specific": "{{team}} eligió",
"pickInfo.tiebreaker": "Desempate",
"pickInfo.both": "Ambos eligieron",
"pickInfo.default": "Mapa por defacto",
"pickInfo.default": "",
"pickInfo.default.explanation": "",
"pickInfo.votes_one": "",
"pickInfo.votes_many": "",
"pickInfo.votes_other": "",
"pickInfo.counterpick": "Contraselección",
"generator.error": "Cambios no fueron guardados ya que el torneo comenzó",
"teams.mapsPickedStatus": "Estado de mapas elegidos",

View File

@ -120,6 +120,7 @@
"looking.allTiers": "Todos los niveles",
"looking.joinQPrompt": "Únete a la fila para encontrar un grupo",
"looking.range.or": "o",
"looking.range.or.explanation": "",
"match.header": "Partido #{{number}}",
"match.spInfo": "Fuerza Sendou será ajustada despues de que ambos equipos informen el mismo resultado",
"match.dispute.button": "¿Disputar?",

View File

@ -47,7 +47,11 @@
"pickInfo.team.specific": "{{team}} eligió",
"pickInfo.tiebreaker": "Desempate",
"pickInfo.both": "Ambos eligieron",
"pickInfo.default": "Mapa por defacto",
"pickInfo.default": "",
"pickInfo.default.explanation": "",
"pickInfo.votes_one": "",
"pickInfo.votes_many": "",
"pickInfo.votes_other": "",
"pickInfo.counterpick": "Contraselección",
"generator.error": "Cambios no fueron guardados ya que el torneo comenzó",
"teams.mapsPickedStatus": "Estado de escenarios elegidos",

View File

@ -120,6 +120,7 @@
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.range.or": "",
"looking.range.or.explanation": "",
"match.header": "",
"match.spInfo": "",
"match.dispute.button": "",

View File

@ -47,7 +47,11 @@
"pickInfo.team.specific": "{{team}} a fait son choix.",
"pickInfo.tiebreaker": "Tiebreaker",
"pickInfo.both": "Les deux ont choisi",
"pickInfo.default": "Stages par défaut",
"pickInfo.default": "",
"pickInfo.default.explanation": "",
"pickInfo.votes_one": "",
"pickInfo.votes_many": "",
"pickInfo.votes_other": "",
"pickInfo.counterpick": "",
"generator.error": "Les modifications apportées n'ont pas été enregistrées depuis le début du tournoi",
"teams.mapsPickedStatus": "Statut des stages sélectionnés",

View File

@ -120,6 +120,7 @@
"looking.allTiers": "Tout les ranks",
"looking.joinQPrompt": "Rejoindre la queue pour trouvez un groupe",
"looking.range.or": "ou",
"looking.range.or.explanation": "",
"match.header": "Match #{{number}}",
"match.spInfo": "Les SP après que les deux teams est reportées le même résultat",
"match.dispute.button": "Dispute?",

View File

@ -47,7 +47,11 @@
"pickInfo.team.specific": "{{team}} a fait son choix.",
"pickInfo.tiebreaker": "Manche décisive",
"pickInfo.both": "Les deux ont choisi",
"pickInfo.default": "Stages par défaut",
"pickInfo.default": "",
"pickInfo.default.explanation": "",
"pickInfo.votes_one": "",
"pickInfo.votes_many": "",
"pickInfo.votes_other": "",
"pickInfo.counterpick": "Counterpick",
"generator.error": "Les modifications apportées n'ont pas été enregistrées depuis le début du tournoi",
"teams.mapsPickedStatus": "Statut des stages sélectionnés",

View File

@ -120,6 +120,7 @@
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.range.or": "",
"looking.range.or.explanation": "",
"match.header": "",
"match.spInfo": "",
"match.dispute.button": "",

View File

@ -47,7 +47,11 @@
"pickInfo.team.specific": "{{team}} נבחר",
"pickInfo.tiebreaker": "שובר שיוויון",
"pickInfo.both": "שניהם בחרו",
"pickInfo.default": "מפת ברירת מחדל",
"pickInfo.default": "",
"pickInfo.default.explanation": "",
"pickInfo.votes_one": "",
"pickInfo.votes_two": "",
"pickInfo.votes_other": "",
"pickInfo.counterpick": "",
"generator.error": "שינויים שביצעת לא נשמרו מאז תחילת הטורניר",
"teams.mapsPickedStatus": "סטטוס בחירת המפות",

View File

@ -120,6 +120,7 @@
"looking.allTiers": "Tutti i tier",
"looking.joinQPrompt": "Unisciti alla coda o trova un gruppo",
"looking.range.or": "o",
"looking.range.or.explanation": "",
"match.header": "Match #{{number}}",
"match.spInfo": "Gli SP verranno sistemati una volta che entrambi i team riporteranno lo stesso punteggio",
"match.dispute.button": "Disputa?",

View File

@ -47,7 +47,11 @@
"pickInfo.team.specific": "{{team}} ha scelto",
"pickInfo.tiebreaker": "Spareggio",
"pickInfo.both": "Scelto da entrambi",
"pickInfo.default": "Scenario di default",
"pickInfo.default": "",
"pickInfo.default.explanation": "",
"pickInfo.votes_one": "",
"pickInfo.votes_many": "",
"pickInfo.votes_other": "",
"pickInfo.counterpick": "Counterpick",
"generator.error": "I cambiamenti che hai fatto non sono stati salvati visto che il torneo è già cominciato.",
"teams.mapsPickedStatus": "Stato scenari selezionati",

View File

@ -120,6 +120,7 @@
"looking.allTiers": "全てのティア",
"looking.joinQPrompt": "グループを見つけるには列に入ってください",
"looking.range.or": "か",
"looking.range.or.explanation": "",
"match.header": "マッチ #{{number}}番",
"match.spInfo": "SP は両チームが同じ結果を報告してからアップデートされます。",
"match.dispute.button": "異議を唱えますか?",

View File

@ -47,7 +47,8 @@
"pickInfo.team.specific": "{{team}} picked",
"pickInfo.tiebreaker": "タイブレイカー",
"pickInfo.both": "両者選択",
"pickInfo.default": "デフォルトマップ",
"pickInfo.default": "",
"pickInfo.default.explanation": "",
"pickInfo.counterpick": "順番に選ぶ",
"generator.error": "トーナメントが開始済みなので変更は保存されませんでした。",
"teams.mapsPickedStatus": "マップの選択状況",

View File

@ -120,6 +120,7 @@
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.range.or": "",
"looking.range.or.explanation": "",
"match.header": "",
"match.spInfo": "",
"match.dispute.button": "",

View File

@ -48,6 +48,7 @@
"pickInfo.tiebreaker": "",
"pickInfo.both": "",
"pickInfo.default": "",
"pickInfo.default.explanation": "",
"pickInfo.counterpick": "",
"generator.error": "",
"teams.mapsPickedStatus": "",

View File

@ -120,6 +120,7 @@
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.range.or": "",
"looking.range.or.explanation": "",
"match.header": "",
"match.spInfo": "",
"match.dispute.button": "",

View File

@ -48,6 +48,9 @@
"pickInfo.tiebreaker": "",
"pickInfo.both": "",
"pickInfo.default": "",
"pickInfo.default.explanation": "",
"pickInfo.votes_one": "",
"pickInfo.votes_other": "",
"pickInfo.counterpick": "",
"generator.error": "",
"teams.mapsPickedStatus": "",

View File

@ -120,6 +120,7 @@
"looking.allTiers": "",
"looking.joinQPrompt": "",
"looking.range.or": "",
"looking.range.or.explanation": "",
"match.header": "",
"match.spInfo": "",
"match.dispute.button": "",

View File

@ -47,7 +47,12 @@
"pickInfo.team.specific": "",
"pickInfo.tiebreaker": "Tiebreaker",
"pickInfo.both": "Oba wybrane",
"pickInfo.default": "Domyślna mapa",
"pickInfo.default": "",
"pickInfo.default.explanation": "",
"pickInfo.votes_one": "",
"pickInfo.votes_few": "",
"pickInfo.votes_many": "",
"pickInfo.votes_other": "",
"pickInfo.counterpick": "",
"generator.error": "Zmiany nie zostały zapisane z powodu rozpoczęcia turnieju",
"teams.mapsPickedStatus": "Status wybranych map",

View File

@ -120,6 +120,7 @@
"looking.allTiers": "Todas as tiers",
"looking.joinQPrompt": "Entre na fila para achar um grupo",
"looking.range.or": "",
"looking.range.or.explanation": "",
"match.header": "Partida #{{number}}",
"match.spInfo": "SP será ajustado depois que ambos os times declarem o mesmo resultado",
"match.dispute.button": "Disputar?",

View File

@ -47,7 +47,11 @@
"pickInfo.team.specific": "{{team}} escolheu",
"pickInfo.tiebreaker": "Desempate",
"pickInfo.both": "Ambos escolheram",
"pickInfo.default": "Mapa padrão",
"pickInfo.default": "",
"pickInfo.default.explanation": "",
"pickInfo.votes_one": "",
"pickInfo.votes_many": "",
"pickInfo.votes_other": "",
"pickInfo.counterpick": "Counterpick",
"generator.error": "Mudanças que você fez não foram salvas uma vez que o torneio já começou",
"teams.mapsPickedStatus": "Status dos mapas escolhidos",

View File

@ -120,6 +120,7 @@
"looking.allTiers": "Все ранги",
"looking.joinQPrompt": "Присоединитесь к очереди, чтобы найти группу",
"looking.range.or": "или",
"looking.range.or.explanation": "",
"match.header": "Матч #{{number}}",
"match.spInfo": "SP будет изменено после того, как обе команды сообщат одинаковый счёт.",
"match.dispute.button": "Оспорить?",

View File

@ -47,7 +47,12 @@
"pickInfo.team.specific": "",
"pickInfo.tiebreaker": "Тай-брейк",
"pickInfo.both": "Выбрано обеими",
"pickInfo.default": "Карта по умолчанию",
"pickInfo.default": "",
"pickInfo.default.explanation": "",
"pickInfo.votes_one": "",
"pickInfo.votes_few": "",
"pickInfo.votes_many": "",
"pickInfo.votes_other": "",
"pickInfo.counterpick": "Контрвыбор",
"generator.error": "Ваши изменения не были сохранены так как турнир начался",
"teams.mapsPickedStatus": "Статус выбранных карт",

View File

@ -120,6 +120,7 @@
"looking.allTiers": "所有段位",
"looking.joinQPrompt": "参加匹配以寻找小队",
"looking.range.or": "或",
"looking.range.or.explanation": "",
"match.header": "对战 #{{number}}",
"match.spInfo": "SP会在双方提交相同的比分后改变",
"match.dispute.button": "分歧?",

View File

@ -47,7 +47,8 @@
"pickInfo.team.specific": "{{team}} 选择",
"pickInfo.tiebreaker": "决胜局",
"pickInfo.both": "双方都选择",
"pickInfo.default": "默认地图",
"pickInfo.default": "",
"pickInfo.default.explanation": "",
"pickInfo.counterpick": "反选",
"generator.error": "由于比赛已经开始,您所更改的内容无法保存",
"teams.mapsPickedStatus": "地图的选择情况",