SQ new map algorithm fix unintended maps appearing
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
Kalle
2025-11-20 17:34:23 +02:00
parent 30e22c2b1a
commit a42778faf7
5 changed files with 129 additions and 10 deletions

View File

@@ -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<string, number>;
/** 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<ModeWithStage>, Array<ModeWithStage>, 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<StageId, number>();
const stageModeWeights = new Map<string, number>();
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);
}
}

View File

@@ -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<string, number>();
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<string, number>();
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);
}
});
});

View File

@@ -195,6 +195,7 @@ export async function matchMapList(
),
),
initialWeights: weights,
skipEnsureMinimumCandidates: true,
});
generator.next();

View File

@@ -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);
}
});

View File

@@ -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<string, number> | 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() {