From a42778faf7e86f0c41b581779cd0faa1d03bf216 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 20 Nov 2025 17:34:23 +0200 Subject: [PATCH] SQ new map algorithm fix unintended maps appearing --- .../map-list-generator/core/MapList.ts | 19 ++- .../sendouq-match/core/match.server.test.ts | 111 +++++++++++++++++- .../sendouq-match/core/match.server.ts | 1 + .../sendouq/core/default-maps.server.test.ts | 5 +- .../sendouq/core/default-maps.server.ts | 3 +- 5 files changed, 129 insertions(+), 10 deletions(-) diff --git a/app/features/map-list-generator/core/MapList.ts b/app/features/map-list-generator/core/MapList.ts index acdab7dda..2df7c589b 100644 --- a/app/features/map-list-generator/core/MapList.ts +++ b/app/features/map-list-generator/core/MapList.ts @@ -50,6 +50,8 @@ export function* generate(args: { 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; + /** Skip the ensureMinimumCandidates check that inflates weights to ensure half the pool is available. Useful when initial weights already define the desired selection. */ + skipEnsureMinimumCandidates?: boolean; }): Generator, Array, GenerateNext> { if (args.mapPool.isEmpty()) { while (true) yield []; @@ -84,11 +86,13 @@ export function* generate(args: { ); } - ensureMinimumCandidates({ - mapPool: args.mapPool, - stageWeights, - stageModeWeights, - }); + if (!args.skipEnsureMinimumCandidates) { + ensureMinimumCandidates({ + mapPool: args.mapPool, + stageWeights, + stageModeWeights, + }); + } for (let i = 0; i < amount; i++) { const mode = currentModeOrder[i % currentModeOrder.length]; @@ -139,12 +143,15 @@ function initializeWeights( const stageWeights = new Map(); const stageModeWeights = new Map(); + const hasInitialWeights = initialWeights && initialWeights.size > 0; + for (const mode of modes) { const stageIds = mapPool[mode]; for (const stageId of stageIds) { stageWeights.set(stageId, 0); const key = modeStageKey(mode, stageId); - const initialWeight = initialWeights?.get(key) ?? 0; + const initialWeight = + initialWeights?.get(key) ?? (hasInitialWeights ? -1000 : 0); stageModeWeights.set(key, initialWeight); } } diff --git a/app/features/sendouq-match/core/match.server.test.ts b/app/features/sendouq-match/core/match.server.test.ts index f8f6d798c..edbb490ad 100644 --- a/app/features/sendouq-match/core/match.server.test.ts +++ b/app/features/sendouq-match/core/match.server.test.ts @@ -1,10 +1,19 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { StageId } from "~/modules/in-game-lists/types"; import * as Test from "~/utils/Test"; import { mapModePreferencesToModeList, + matchMapList, normalizeAndCombineWeights, } from "./match.server"; +vi.mock("~/features/sendouq/core/default-maps.server", () => ({ + getDefaultMapWeights: vi.fn(), +})); + +import { getDefaultMapWeights } from "~/features/sendouq/core/default-maps.server"; +import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants"; + describe("mapModePreferencesToModeList()", () => { test("returns default list if no preferences", () => { const modeList = mapModePreferencesToModeList([], []); @@ -218,3 +227,103 @@ describe("normalizeAndCombineWeights()", () => { expect(result.get("map2-TC")).toBe(40); }); }); + +describe("matchMapList()", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test("maps not in team preferences or defaults should not be preferred over default maps", async () => { + // Note stage 23 (Lemuria Hub) is NOT in defaults + const defaultStageIds: StageId[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + const mockDefaults = new Map(); + + for (const stageId of defaultStageIds) { + mockDefaults.set(`SZ-${stageId}`, -1); + } + + vi.mocked(getDefaultMapWeights).mockResolvedValue(mockDefaults); + + // Both teams have empty preferences (no map pools set) + // This forces the system to rely entirely on defaults + const emptyPreferences = { + modes: [{ mode: "SZ" as const, preference: "PREFER" as const }], + pool: [], + }; + + const result = await matchMapList( + { + preferences: [{ userId: 1, preferences: emptyPreferences }], + id: 1, + }, + { + preferences: [{ userId: 2, preferences: emptyPreferences }], + id: 2, + }, + ); + + const szMaps = result.filter((m) => m.mode === "SZ"); + + for (const map of szMaps) { + expect(defaultStageIds).toContain(map.stageId); + } + }); + + test("user selected maps should be preferred over default maps even with small pool", async () => { + // User selected stages - just 7 stages per mode (less than half of 25) + // Note: stages 1 (EELTAIL_ALLEY) and 9 (STURGEON_SHIPYARD) are banned for SZ + const userSelectedStageIds: StageId[] = [0, 2, 3, 4, 5, 6, 7]; + + // Default stages include many more maps that users did NOT select + const defaultStageIds: StageId[] = [ + 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + ]; + + const mockDefaults = new Map(); + for (const stageId of defaultStageIds) { + mockDefaults.set(`SZ-${stageId}`, -SENDOUQ_BEST_OF); + } + + vi.mocked(getDefaultMapWeights).mockResolvedValue(mockDefaults); + + // Both teams have selected the same 5 stages + const teamPreferences = { + modes: [{ mode: "SZ" as const, preference: "PREFER" as const }], + pool: [ + { + mode: "SZ" as const, + stages: userSelectedStageIds, + }, + ], + }; + + const result = await matchMapList( + { + preferences: [ + { userId: 1, preferences: teamPreferences }, + { userId: 2, preferences: teamPreferences }, + { userId: 3, preferences: teamPreferences }, + { userId: 4, preferences: teamPreferences }, + ], + id: 1, + }, + { + preferences: [ + { userId: 5, preferences: teamPreferences }, + { userId: 6, preferences: teamPreferences }, + { userId: 7, preferences: teamPreferences }, + { userId: 8, preferences: teamPreferences }, + ], + id: 2, + }, + ); + + const szMaps = result.filter((m) => m.mode === "SZ"); + + // All selected maps should come from user preferences, not defaults + for (const map of szMaps) { + expect(userSelectedStageIds).toContain(map.stageId); + } + }); +}); diff --git a/app/features/sendouq-match/core/match.server.ts b/app/features/sendouq-match/core/match.server.ts index be51e5cc4..647279c30 100644 --- a/app/features/sendouq-match/core/match.server.ts +++ b/app/features/sendouq-match/core/match.server.ts @@ -195,6 +195,7 @@ export async function matchMapList( ), ), initialWeights: weights, + skipEnsureMinimumCandidates: true, }); generator.next(); diff --git a/app/features/sendouq/core/default-maps.server.test.ts b/app/features/sendouq/core/default-maps.server.test.ts index 1a3e60cba..c9df6e21b 100644 --- a/app/features/sendouq/core/default-maps.server.test.ts +++ b/app/features/sendouq/core/default-maps.server.test.ts @@ -3,6 +3,7 @@ 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 { SENDOUQ_BEST_OF } from "../q-constants"; import { clearCacheForTesting, getDefaultMapWeights, @@ -178,7 +179,7 @@ describe("getDefaultMapWeights()", () => { expect(tcMaps.length).toBe(7); }); - test("assigns weight of -1 to all selected maps", async () => { + test("assigns weight of -SENDOUQ_BEST_OF to all selected maps", async () => { mockSeasonCurrent.mockReturnValue({ nth: 1, starts: new Date("2023-01-01"), @@ -201,7 +202,7 @@ describe("getDefaultMapWeights()", () => { const result = await getDefaultMapWeights(); for (const weight of result.values()) { - expect(weight).toBe(-1); + expect(weight).toBe(-SENDOUQ_BEST_OF); } }); diff --git a/app/features/sendouq/core/default-maps.server.ts b/app/features/sendouq/core/default-maps.server.ts index d82f96611..1b0f9501e 100644 --- a/app/features/sendouq/core/default-maps.server.ts +++ b/app/features/sendouq/core/default-maps.server.ts @@ -4,11 +4,12 @@ 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"; +import { SENDOUQ_BEST_OF } from "../q-constants"; let cachedDefaults: Map | null = null; const ONE_WEEK_IN_DAYS = 7; -const DEFAULT_MAP_WEIGHT = -1; +const DEFAULT_MAP_WEIGHT = -SENDOUQ_BEST_OF; const TOP_MAPS_PER_MODE = 7; export function clearCacheForTesting() {