diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index b1c1b9b10..e15362a5e 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -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); diff --git a/app/features/map-list-generator/core/MapList.test.ts b/app/features/map-list-generator/core/MapList.test.ts index 6e3bfb61a..7c86f696f 100644 --- a/app/features/map-list-generator/core/MapList.test.ts +++ b/app/features/map-list-generator/core/MapList.test.ts @@ -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(); + 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(); + 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); + }); +}); diff --git a/app/features/map-list-generator/core/MapList.ts b/app/features/map-list-generator/core/MapList.ts index 85536f068..acdab7dda 100644 --- a/app/features/map-list-generator/core/MapList.ts +++ b/app/features/map-list-generator/core/MapList.ts @@ -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; }): Generator, Array, 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, +) { const stageWeights = new Map(); const stageModeWeights = new Map(); @@ -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; }); } diff --git a/app/features/sendouq-match/core/match.server.test.ts b/app/features/sendouq-match/core/match.server.test.ts index a17a3e9e5..f8f6d798c 100644 --- a/app/features/sendouq-match/core/match.server.test.ts +++ b/app/features/sendouq-match/core/match.server.test.ts @@ -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(); + 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(); + + const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights); + + expect(result.get("map1-SZ")).toBe(100); + }); + + test("handles both teams having zero weights", () => { + const teamOneWeights = new Map(); + const teamTwoWeights = new Map(); + + 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); }); }); diff --git a/app/features/sendouq-match/core/match.server.ts b/app/features/sendouq-match/core/match.server.ts index cd0997e21..be51e5cc4 100644 --- a/app/features/sendouq-match/core/match.server.ts +++ b/app/features/sendouq-match/core/match.server.ts @@ -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; + +async function calculateMapWeights( + groupOnePreferences: UserMapModePreferences[], + groupTwoPreferences: UserMapModePreferences[], + modesIncluded: ModeShort[], +): Promise { + 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, + teamTwoWeights: Map, +): Map { + 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(); + 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 { + 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 { 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( diff --git a/app/features/sendouq-match/routes/q.match.$id.tsx b/app/features/sendouq-match/routes/q.match.$id.tsx index 7af64af59..ef30d5aed 100644 --- a/app/features/sendouq-match/routes/q.match.$id.tsx +++ b/app/features/sendouq-match/routes/q.match.$id.tsx @@ -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 ( +
+ + + {t("tournament:pickInfo.votes", { + count: playerCount, + })} + +
+ ); }; 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}`)} - {sourcePoolMemberIds().length > 0 ? ( + {map.source === "DEFAULT" ? ( +
+ {t("tournament:pickInfo.default.explanation")} +
+ ) : sourcePoolMemberIds().length > 0 ? (
{sourcePoolMemberIds().map((userId) => { const user = userIdToUser(userId); diff --git a/app/features/sendouq-settings/banned-maps.ts b/app/features/sendouq-settings/banned-maps.ts index 2d22f5124..7b8f2f770 100644 --- a/app/features/sendouq-settings/banned-maps.ts +++ b/app/features/sendouq-settings/banned-maps.ts @@ -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 = { TW: [], @@ -44,3 +45,11 @@ export const BANNED_MAPS: Record = { 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)), +}); diff --git a/app/features/sendouq-settings/q-settings-schemas.server.ts b/app/features/sendouq-settings/q-settings-schemas.server.ts index a1bad3133..7c316e3c4 100644 --- a/app/features/sendouq-settings/q-settings-schemas.server.ts +++ b/app/features/sendouq-settings/q-settings-schemas.server.ts @@ -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({ diff --git a/app/features/sendouq-settings/routes/q.settings.tsx b/app/features/sendouq-settings/routes/q.settings.tsx index 9fb736e12..87c0c9ec3 100644 --- a/app/features/sendouq-settings/routes/q.settings.tsx +++ b/app/features/sendouq-settings/routes/q.settings.tsx @@ -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; } } diff --git a/app/features/sendouq/QRepository.server.ts b/app/features/sendouq/QRepository.server.ts index 38d005cc3..ea0ccfa56 100644 --- a/app/features/sendouq/QRepository.server.ts +++ b/app/features/sendouq/QRepository.server.ts @@ -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(); +} diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts index 7273160df..e99ee7940 100644 --- a/app/features/sendouq/actions/q.looking.server.ts +++ b/app/features/sendouq/actions/q.looking.server.ts @@ -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, diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index d25f78d51..dc54782f0 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -160,22 +160,36 @@ export function GroupCard({
) : null} {group.tierRange?.range ? ( -
-
- ±{group.tierRange.diff} -
-
-
- - {t("q:looking.range.or")} - -
- {group.isReplay ? ( -
- {t("q:looking.replay")} +
+
+
+ +
+ (-{group.tierRange.diff})
- ) : null} +
+ + {t("q:looking.range.or")} + + } + > + {t("q:looking.range.or.explanation")} + +
+ +
+ (+{group.tierRange.diff}) +
+
+ {group.isReplay ? ( +
+ {t("q:looking.replay")} +
+ ) : null}
) : null} {group.skillDifference ? ( diff --git a/app/features/sendouq/core/default-maps.server.test.ts b/app/features/sendouq/core/default-maps.server.test.ts new file mode 100644 index 000000000..1a3e60cba --- /dev/null +++ b/app/features/sendouq/core/default-maps.server.test.ts @@ -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(); + 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(); + } +} diff --git a/app/features/sendouq/core/default-maps.server.ts b/app/features/sendouq/core/default-maps.server.ts new file mode 100644 index 000000000..d82f96611 --- /dev/null +++ b/app/features/sendouq/core/default-maps.server.ts @@ -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 | 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> { + 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> { + const activeUsersWithPreferences = + await QRepository.mapModePreferencesBySeasonNth(seasonNth); + + const mapModeCounts = new Map(); + + 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(); + 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; +} diff --git a/app/features/sendouq/q.css b/app/features/sendouq/q.css index f8c5f4f1e..744bb75c4 100644 --- a/app/features/sendouq/q.css +++ b/app/features/sendouq/q.css @@ -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 { diff --git a/app/modules/tournament-map-list-generator/constants.ts b/app/modules/tournament-map-list-generator/constants.ts index b30391f75..15ee52e23 100644 --- a/app/modules/tournament-map-list-generator/constants.ts +++ b/app/modules/tournament-map-list-generator/constants.ts @@ -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", diff --git a/locales/da/q.json b/locales/da/q.json index 9c622e442..1a7f7ea3f 100644 --- a/locales/da/q.json +++ b/locales/da/q.json @@ -120,6 +120,7 @@ "looking.allTiers": "", "looking.joinQPrompt": "", "looking.range.or": "", + "looking.range.or.explanation": "", "match.header": "", "match.spInfo": "", "match.dispute.button": "", diff --git a/locales/da/tournament.json b/locales/da/tournament.json index 6c71a9ff9..cefae76af 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -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", diff --git a/locales/de/q.json b/locales/de/q.json index 8cc283fe2..10feb1a79 100644 --- a/locales/de/q.json +++ b/locales/de/q.json @@ -120,6 +120,7 @@ "looking.allTiers": "", "looking.joinQPrompt": "", "looking.range.or": "", + "looking.range.or.explanation": "", "match.header": "", "match.spInfo": "", "match.dispute.button": "", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index a4ecadba1..aa29ba48c 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -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", diff --git a/locales/en/q.json b/locales/en/q.json index 8e920d68c..35b016f57 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -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?", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index 675d9932e..a851a2e22 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -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", diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index e4c2144ca..13836e717 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -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?", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index 1c10d6727..59624adc9 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -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", diff --git a/locales/es-US/q.json b/locales/es-US/q.json index e0d3e9bb5..6dde703f3 100644 --- a/locales/es-US/q.json +++ b/locales/es-US/q.json @@ -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?", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index ca2f1bb0f..b9e965ec0 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -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", diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json index 1a643a335..024bd9620 100644 --- a/locales/fr-CA/q.json +++ b/locales/fr-CA/q.json @@ -120,6 +120,7 @@ "looking.allTiers": "", "looking.joinQPrompt": "", "looking.range.or": "", + "looking.range.or.explanation": "", "match.header": "", "match.spInfo": "", "match.dispute.button": "", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index 5d4ffe951..515000694 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -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", diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json index aee9f0b0d..f41139575 100644 --- a/locales/fr-EU/q.json +++ b/locales/fr-EU/q.json @@ -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?", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index 42f66f212..2197620a0 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -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", diff --git a/locales/he/q.json b/locales/he/q.json index 792d420eb..252233974 100644 --- a/locales/he/q.json +++ b/locales/he/q.json @@ -120,6 +120,7 @@ "looking.allTiers": "", "looking.joinQPrompt": "", "looking.range.or": "", + "looking.range.or.explanation": "", "match.header": "", "match.spInfo": "", "match.dispute.button": "", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index 6440704cd..4d416d9be 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -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": "סטטוס בחירת המפות", diff --git a/locales/it/q.json b/locales/it/q.json index f7b4f2b59..a26247f36 100644 --- a/locales/it/q.json +++ b/locales/it/q.json @@ -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?", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index aebe51b5e..f7d03f423 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -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", diff --git a/locales/ja/q.json b/locales/ja/q.json index 4c5cf48ac..7bfc13311 100644 --- a/locales/ja/q.json +++ b/locales/ja/q.json @@ -120,6 +120,7 @@ "looking.allTiers": "全てのティア", "looking.joinQPrompt": "グループを見つけるには列に入ってください", "looking.range.or": "か", + "looking.range.or.explanation": "", "match.header": "マッチ #{{number}}番", "match.spInfo": "SP は両チームが同じ結果を報告してからアップデートされます。", "match.dispute.button": "異議を唱えますか?", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index 5bf71c687..4dff91676 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -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": "マップの選択状況", diff --git a/locales/ko/q.json b/locales/ko/q.json index 8cc283fe2..10feb1a79 100644 --- a/locales/ko/q.json +++ b/locales/ko/q.json @@ -120,6 +120,7 @@ "looking.allTiers": "", "looking.joinQPrompt": "", "looking.range.or": "", + "looking.range.or.explanation": "", "match.header": "", "match.spInfo": "", "match.dispute.button": "", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index cd98348b8..3d23b90c9 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -48,6 +48,7 @@ "pickInfo.tiebreaker": "", "pickInfo.both": "", "pickInfo.default": "", + "pickInfo.default.explanation": "", "pickInfo.counterpick": "", "generator.error": "", "teams.mapsPickedStatus": "", diff --git a/locales/nl/q.json b/locales/nl/q.json index 8cc283fe2..10feb1a79 100644 --- a/locales/nl/q.json +++ b/locales/nl/q.json @@ -120,6 +120,7 @@ "looking.allTiers": "", "looking.joinQPrompt": "", "looking.range.or": "", + "looking.range.or.explanation": "", "match.header": "", "match.spInfo": "", "match.dispute.button": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 2285db729..b5f208409 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -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": "", diff --git a/locales/pl/q.json b/locales/pl/q.json index 8cc283fe2..10feb1a79 100644 --- a/locales/pl/q.json +++ b/locales/pl/q.json @@ -120,6 +120,7 @@ "looking.allTiers": "", "looking.joinQPrompt": "", "looking.range.or": "", + "looking.range.or.explanation": "", "match.header": "", "match.spInfo": "", "match.dispute.button": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index 801437e63..79b067677 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -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", diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json index 20d1c5561..caf748c3f 100644 --- a/locales/pt-BR/q.json +++ b/locales/pt-BR/q.json @@ -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?", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 837be682e..070847e6e 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -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", diff --git a/locales/ru/q.json b/locales/ru/q.json index 764c6ce23..c50103609 100644 --- a/locales/ru/q.json +++ b/locales/ru/q.json @@ -120,6 +120,7 @@ "looking.allTiers": "Все ранги", "looking.joinQPrompt": "Присоединитесь к очереди, чтобы найти группу", "looking.range.or": "или", + "looking.range.or.explanation": "", "match.header": "Матч #{{number}}", "match.spInfo": "SP будет изменено после того, как обе команды сообщат одинаковый счёт.", "match.dispute.button": "Оспорить?", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index f83eb8f20..3a09150c9 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -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": "Статус выбранных карт", diff --git a/locales/zh/q.json b/locales/zh/q.json index ecfb03ebf..797d21055 100644 --- a/locales/zh/q.json +++ b/locales/zh/q.json @@ -120,6 +120,7 @@ "looking.allTiers": "所有段位", "looking.joinQPrompt": "参加匹配以寻找小队", "looking.range.or": "或", + "looking.range.or.explanation": "", "match.header": "对战 #{{number}}", "match.spInfo": "SP会在双方提交相同的比分后改变", "match.dispute.button": "分歧?", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 4086cae6e..e5ecb7d26 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -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": "地图的选择情况",