mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
New tournament map list generation with patterns (#2557)
This commit is contained in:
parent
87b1e95649
commit
5d8be56d6f
|
|
@ -5,17 +5,24 @@ import { SendouPopover } from "./elements/Popover";
|
|||
export function InfoPopover({
|
||||
children,
|
||||
tiny = false,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
tiny?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<Button
|
||||
className={clsx("react-aria-Button", "info-popover__trigger", {
|
||||
"info-popover__trigger__tiny": tiny,
|
||||
})}
|
||||
className={clsx(
|
||||
"react-aria-Button",
|
||||
"info-popover__trigger",
|
||||
className,
|
||||
{
|
||||
"info-popover__trigger__tiny": tiny,
|
||||
},
|
||||
)}
|
||||
>
|
||||
?
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -105,7 +105,8 @@
|
|||
}
|
||||
|
||||
.buttonIcon {
|
||||
width: 1.25rem;
|
||||
min-width: 1.25rem;
|
||||
max-width: 1.25rem;
|
||||
margin-inline-end: var(--s-1-5);
|
||||
}
|
||||
|
||||
|
|
@ -114,11 +115,13 @@
|
|||
}
|
||||
|
||||
.small > .buttonIcon {
|
||||
width: 1rem;
|
||||
min-width: 1rem;
|
||||
max-width: 1rem;
|
||||
margin-inline-end: var(--s-1);
|
||||
}
|
||||
|
||||
.miniscule > .buttonIcon {
|
||||
width: 0.857rem;
|
||||
min-width: 0.857rem;
|
||||
max-width: 0.857rem;
|
||||
margin-inline-end: var(--s-1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,8 +64,8 @@ import type {
|
|||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
|
||||
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 { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import { cors } from "remix-utils/cors";
|
|||
import { z } from "zod/v4";
|
||||
import { db } from "~/db/sql";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import { resolveMapList } from "~/features/tournament-bracket/core/mapList.server";
|
||||
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import i18next from "~/modules/i18n/i18next.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import {
|
||||
|
|
@ -127,6 +129,13 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
|||
mapPickingStyle: match.mapPickingStyle,
|
||||
maps: match.maps,
|
||||
pickBanEvents,
|
||||
recentlyPlayedMaps:
|
||||
await TournamentTeamRepository.findRecentlyPlayedMapsByIds({
|
||||
teamIds: [match.opponentOne.id, match.opponentTwo.id],
|
||||
}).catch((error) => {
|
||||
logger.error("Failed to fetch recently played maps", error);
|
||||
return [];
|
||||
}),
|
||||
}).map((mapListMap) => {
|
||||
return {
|
||||
map: {
|
||||
|
|
|
|||
498
app/features/map-list-generator/core/MapList.test.ts
Normal file
498
app/features/map-list-generator/core/MapList.test.ts
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
import { Err } from "neverthrow";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import type { StageId } from "~/modules/in-game-lists/types";
|
||||
import * as MapList from "./MapList";
|
||||
import { MapPool } from "./map-pool";
|
||||
|
||||
const ALL_MODES_TEST_MAP_POOL = new MapPool({
|
||||
TW: [1, 2, 3],
|
||||
SZ: [4, 5, 6],
|
||||
TC: [7, 8, 9],
|
||||
RM: [10, 11, 12],
|
||||
CB: [13, 14, 15],
|
||||
});
|
||||
|
||||
const ALL_MAPS_TEST_MAP_POOL = new MapPool({
|
||||
TW: [...stageIds],
|
||||
SZ: [...stageIds],
|
||||
TC: [...stageIds],
|
||||
RM: [...stageIds],
|
||||
CB: [...stageIds],
|
||||
});
|
||||
|
||||
describe("MapList.generate()", () => {
|
||||
function initGenerator(mapPool: MapPool = ALL_MODES_TEST_MAP_POOL) {
|
||||
const gen = MapList.generate({ mapPool });
|
||||
gen.next();
|
||||
return gen;
|
||||
}
|
||||
|
||||
describe("singular map list", () => {
|
||||
it("returns an array with given amount of items", () => {
|
||||
const gen = initGenerator();
|
||||
expect(gen.next({ amount: 3 }).value).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("returns an array with only one item", () => {
|
||||
const gen = initGenerator();
|
||||
expect(gen.next({ amount: 1 }).value).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("includes only maps from the given map pool", () => {
|
||||
const gen = initGenerator();
|
||||
const maps = gen.next({ amount: 3 }).value;
|
||||
|
||||
for (const map of maps) {
|
||||
expect(ALL_MODES_TEST_MAP_POOL.has(map)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("contains only unique maps, when possible", () => {
|
||||
const gen = initGenerator();
|
||||
const maps = gen.next({ amount: 3 }).value;
|
||||
|
||||
expect(maps).toHaveLength(
|
||||
new Set(maps.map((m) => `${m.mode}-${m.stageId}`)).size,
|
||||
);
|
||||
});
|
||||
|
||||
it("repeats maps when amount is larger than pool size", () => {
|
||||
const gen = initGenerator(
|
||||
new MapPool({
|
||||
TW: [1],
|
||||
SZ: [],
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
}),
|
||||
);
|
||||
const maps = gen.next({ amount: 3 }).value;
|
||||
|
||||
expect(maps).toHaveLength(3);
|
||||
for (const map of maps) {
|
||||
expect(map).toEqual({ mode: "TW", stageId: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
it("contains every mode once before repeating", () => {
|
||||
const gen = initGenerator();
|
||||
const maps = gen.next({ amount: 5 }).value;
|
||||
const modes = maps.map((m) => m.mode);
|
||||
|
||||
for (const modeShort of ["TW", "SZ", "TC", "RM", "CB"] as const) {
|
||||
expect(modes).toContain(modeShort);
|
||||
}
|
||||
});
|
||||
|
||||
it("repeats a mode following the pattern when amount bigger than mode count", () => {
|
||||
const gen = initGenerator();
|
||||
const maps = gen.next({ amount: 6 }).value;
|
||||
|
||||
expect(maps[0].mode).toBe(maps[5].mode);
|
||||
});
|
||||
|
||||
it("handles empty map pool", () => {
|
||||
const gen = initGenerator(MapPool.EMPTY);
|
||||
const maps = gen.next({ amount: 3 }).value;
|
||||
|
||||
expect(maps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("follows a pattern", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const gen = initGenerator();
|
||||
const maps = gen.next({ amount: 3, pattern: "*SZ*" }).value;
|
||||
|
||||
expect(maps).toHaveLength(3);
|
||||
expect(maps[1].mode).toBe("SZ");
|
||||
}
|
||||
});
|
||||
|
||||
it("follows and repeats a pattern", () => {
|
||||
const gen = initGenerator();
|
||||
const maps = gen.next({ amount: 5, pattern: "*SZ*" }).value;
|
||||
|
||||
expect(maps).toHaveLength(5);
|
||||
expect(maps[1].mode).toBe("SZ");
|
||||
expect(maps[3].mode).toBe("SZ");
|
||||
});
|
||||
|
||||
it("follows a one mode only pattern", () => {
|
||||
const gen = initGenerator();
|
||||
const maps = gen.next({ amount: 3, pattern: "SZ" }).value;
|
||||
|
||||
expect(maps[0].mode).toBe("SZ");
|
||||
expect(maps[1].mode).toBe("SZ");
|
||||
expect(maps[2].mode).toBe("SZ");
|
||||
});
|
||||
|
||||
it("follows a pattern where starting and ending mode is the same", () => {
|
||||
const gen = initGenerator(
|
||||
new MapPool({
|
||||
...ALL_MODES_TEST_MAP_POOL.getClonedObject(),
|
||||
TW: [] as StageId[],
|
||||
}),
|
||||
);
|
||||
const maps = gen.next({ amount: 5, pattern: "SZ***SZ" }).value;
|
||||
|
||||
expect(maps[0].mode, "Map 0 is not SZ").toBe("SZ");
|
||||
// 1, 2 and 3 indexes would be any order of TC/RM/CB
|
||||
expect(maps[4].mode, "Map 5 is not SZ").toBe("SZ");
|
||||
});
|
||||
|
||||
it("follows a one mode only pattern (Bo9)", () => {
|
||||
const gen = initGenerator();
|
||||
const maps = gen.next({ amount: 9, pattern: "SZ" }).value;
|
||||
|
||||
for (const [idx, map] of maps.entries()) {
|
||||
expect(map.mode, `Map ${idx} is not SZ`).toBe("SZ");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes a mustInclude mode", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const gen = initGenerator();
|
||||
const maps = gen.next({ amount: 1, pattern: "[SZ]" }).value;
|
||||
|
||||
expect(maps[0].mode).toBe("SZ");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes a mustInclude mode (guaranteed)", () => {
|
||||
const gen = initGenerator();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const maps = gen.next({ amount: 5, pattern: "[SZ!]" }).value;
|
||||
|
||||
expect([maps[0].mode, maps[1].mode, maps[2].mode]).toContain("SZ");
|
||||
}
|
||||
});
|
||||
|
||||
it("includes a mustInclude mode with pattern", () => {
|
||||
const gen = initGenerator();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const maps = gen.next({ amount: 3, pattern: "[SZ]*TC*" }).value;
|
||||
|
||||
expect([maps[0].mode, maps[2].mode]).toContain("SZ");
|
||||
}
|
||||
});
|
||||
|
||||
it("follows a pattern with multiple specific modes", () => {
|
||||
const gen = initGenerator();
|
||||
const maps = gen.next({ amount: 5, pattern: "SZ*TC" }).value;
|
||||
|
||||
expect(maps).toHaveLength(5);
|
||||
expect(maps[0].mode, "missing SZ (must include mode)").toBe("SZ");
|
||||
expect(maps[2].mode, "missign TC (required by pattern)").toBe("TC");
|
||||
});
|
||||
|
||||
it("handles a conflict between pattern and must include", () => {
|
||||
const gen = initGenerator();
|
||||
const maps = gen.next({ amount: 1, pattern: "TW[SZ]" }).value;
|
||||
|
||||
expect(maps).toHaveLength(1);
|
||||
expect(maps[0].mode).toBe("TW"); // pattern has priority
|
||||
});
|
||||
|
||||
it("handles more must include modes than amount", () => {
|
||||
const gen = initGenerator();
|
||||
const maps = gen.next({ amount: 1, pattern: "[TW][SZ]" }).value;
|
||||
|
||||
expect(maps).toHaveLength(1);
|
||||
expect(["TW", "SZ"]).toContain(maps[0].mode);
|
||||
});
|
||||
|
||||
it("ignores a mode in the pattern not in the map pool", () => {
|
||||
const gen = initGenerator(
|
||||
new MapPool({
|
||||
TW: [1, 2, 3],
|
||||
SZ: [],
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
}),
|
||||
);
|
||||
const maps = gen.next({ amount: 3, pattern: "*SZ*" }).value;
|
||||
|
||||
expect(maps).toHaveLength(3);
|
||||
expect(maps[0].mode).toBe("TW");
|
||||
expect(maps[1].mode).toBe("TW");
|
||||
expect(maps[2].mode).toBe("TW");
|
||||
});
|
||||
|
||||
it("ignores a must include mode not in the map pool", () => {
|
||||
const gen = initGenerator(
|
||||
new MapPool({
|
||||
TW: [1, 2, 3],
|
||||
SZ: [],
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
}),
|
||||
);
|
||||
const maps = gen.next({ amount: 1, pattern: "[SZ]" }).value;
|
||||
|
||||
expect(maps).toHaveLength(1);
|
||||
expect(maps[0].mode).toBe("TW");
|
||||
});
|
||||
});
|
||||
|
||||
describe("many map lists", () => {
|
||||
it("generates many map lists", () => {
|
||||
const gen = initGenerator();
|
||||
const first = gen.next({ amount: 3 }).value;
|
||||
const second = gen.next({ amount: 3 }).value;
|
||||
|
||||
expect(first).toBeInstanceOf(Array);
|
||||
expect(second).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
it("has different maps in each list", () => {
|
||||
// TW, SZ & TC with 3 maps each
|
||||
const mapPool = new MapPool({
|
||||
TW: [1, 2, 3],
|
||||
SZ: [4, 5, 6],
|
||||
TC: [7, 8, 9],
|
||||
RM: [],
|
||||
CB: [],
|
||||
});
|
||||
|
||||
const gen = initGenerator(mapPool);
|
||||
const first = gen.next({ amount: 3 }).value;
|
||||
const second = gen.next({ amount: 3 }).value;
|
||||
const third = gen.next({ amount: 3 }).value;
|
||||
const all = [...first, ...second, ...third];
|
||||
|
||||
expect(all).toContainEqual({ mode: "TW", stageId: 1 });
|
||||
expect(all).toContainEqual({ mode: "TW", stageId: 2 });
|
||||
expect(all).toContainEqual({ mode: "TW", stageId: 3 });
|
||||
expect(all).toContainEqual({ mode: "SZ", stageId: 4 });
|
||||
expect(all).toContainEqual({ mode: "SZ", stageId: 5 });
|
||||
expect(all).toContainEqual({ mode: "SZ", stageId: 6 });
|
||||
expect(all).toContainEqual({ mode: "TC", stageId: 7 });
|
||||
expect(all).toContainEqual({ mode: "TC", stageId: 8 });
|
||||
expect(all).toContainEqual({ mode: "TC", stageId: 9 });
|
||||
});
|
||||
|
||||
it("randomizes the stage order", () => {
|
||||
const stagesSeen = new Set<number>();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const gen = initGenerator(ALL_MAPS_TEST_MAP_POOL);
|
||||
const maps = gen.next({ amount: 5 }).value;
|
||||
|
||||
stagesSeen.add(maps[0].stageId);
|
||||
}
|
||||
|
||||
expect(stagesSeen.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("rotates the mode order", () => {
|
||||
const gen = initGenerator();
|
||||
const first = gen.next({ amount: 5 }).value;
|
||||
const second = gen.next({ amount: 5 }).value;
|
||||
|
||||
const firstModes = first!.map((m) => m.mode);
|
||||
const secondModes = second!.map((m) => m.mode);
|
||||
|
||||
expect(firstModes).not.toEqual(secondModes);
|
||||
});
|
||||
|
||||
it("starts with a different mode each time modes are rotated", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const gen = initGenerator();
|
||||
const first = gen.next({ amount: 5 }).value;
|
||||
const second = gen.next({ amount: 5 }).value;
|
||||
|
||||
const firstModes = first!.map((m) => m.mode);
|
||||
const secondModes = second!.map((m) => m.mode);
|
||||
|
||||
expect(firstModes[0]).not.toEqual(secondModes[0]);
|
||||
}
|
||||
});
|
||||
|
||||
it("replenishes the stage id pool when exhausted", { retry: 10 }, () => {
|
||||
const gen = initGenerator(
|
||||
new MapPool({
|
||||
TW: [],
|
||||
SZ: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
}),
|
||||
);
|
||||
const first = gen.next({ amount: 5 }).value.map((m) => m.stageId);
|
||||
gen.next({ amount: 5 });
|
||||
const third = gen.next({ amount: 5 }).value.map((m) => m.stageId);
|
||||
|
||||
for (const stageId of third) {
|
||||
expect(first).toContainEqual(stageId);
|
||||
}
|
||||
});
|
||||
|
||||
it("replenishes the stage id pool with different order", () => {
|
||||
const gen = initGenerator(
|
||||
new MapPool({
|
||||
TW: [],
|
||||
SZ: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
}),
|
||||
);
|
||||
const first = gen.next({ amount: 5 }).value.map((m) => m.stageId);
|
||||
gen.next({ amount: 5 });
|
||||
const third = gen.next({ amount: 5 }).value.map((m) => m.stageId);
|
||||
|
||||
let someDifferent = false;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (first[i] !== third[i]) {
|
||||
someDifferent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(someDifferent).toBe(true);
|
||||
});
|
||||
|
||||
it("replenishes accordingly if considerGuaranteed is true (Bo3)", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const gen = MapList.generate({
|
||||
mapPool: new MapPool({
|
||||
TW: [1, 2, 3],
|
||||
SZ: [],
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
}),
|
||||
considerGuaranteed: true,
|
||||
});
|
||||
gen.next();
|
||||
const maps1 = gen.next({ amount: 3 }).value;
|
||||
|
||||
const notGuaranteedToBePlayed = maps1[2].stageId;
|
||||
|
||||
const maps2 = gen.next({ amount: 3 }).value;
|
||||
|
||||
expect([maps2[0].stageId, maps2[1].stageId]).toContain(
|
||||
notGuaranteedToBePlayed,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("replenishes accordingly if considerGuaranteed is true (Bo5)", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const gen = MapList.generate({
|
||||
mapPool: new MapPool({
|
||||
TW: [1, 2, 3, 4, 5],
|
||||
SZ: [],
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
}),
|
||||
considerGuaranteed: true,
|
||||
});
|
||||
gen.next();
|
||||
const maps1 = gen.next({ amount: 5 }).value;
|
||||
|
||||
const notGuaranteedToBePlayed = [maps1[3].stageId, maps1[4].stageId];
|
||||
|
||||
const maps2 = gen.next({ amount: 5 }).value;
|
||||
|
||||
expect([
|
||||
maps2[0].stageId,
|
||||
maps2[1].stageId,
|
||||
maps2[2].stageId,
|
||||
]).toContain(notGuaranteedToBePlayed[0]);
|
||||
expect([
|
||||
maps2[0].stageId,
|
||||
maps2[1].stageId,
|
||||
maps2[2].stageId,
|
||||
]).toContain(notGuaranteedToBePlayed[1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MapList.parsePattern()", () => {
|
||||
it("parses a simple pattern", () => {
|
||||
expect(MapList.parsePattern("SZ*TC")._unsafeUnwrap()).toEqual({
|
||||
pattern: ["SZ", "ANY", "TC"],
|
||||
});
|
||||
});
|
||||
|
||||
it("handles extra spaces", () => {
|
||||
expect(MapList.parsePattern(" * SZ ")._unsafeUnwrap()).toEqual({
|
||||
pattern: ["ANY", "SZ"],
|
||||
});
|
||||
});
|
||||
|
||||
it("handles the same mode twice in pattern", () => {
|
||||
expect(MapList.parsePattern("SZ*SZ")._unsafeUnwrap()).toEqual({
|
||||
pattern: ["SZ", "ANY", "SZ"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns error on invalid mode", () => {
|
||||
expect(MapList.parsePattern("*INVALID*")).toBeInstanceOf(Err);
|
||||
});
|
||||
|
||||
it("if starts and ends with ANY, the ending ANY is dropped", () => {
|
||||
expect(MapList.parsePattern("*SZ*")._unsafeUnwrap()).toEqual({
|
||||
pattern: ["ANY", "SZ"],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a mustInclude mode", () => {
|
||||
expect(MapList.parsePattern("[SZ]")._unsafeUnwrap()).toEqual({
|
||||
mustInclude: [{ mode: "SZ", isGuaranteed: false }],
|
||||
pattern: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a guaranteed mustInclude mode", () => {
|
||||
expect(MapList.parsePattern("[SZ!]")._unsafeUnwrap()).toEqual({
|
||||
mustInclude: [{ mode: "SZ", isGuaranteed: true }],
|
||||
pattern: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a complex pattern", () => {
|
||||
expect(MapList.parsePattern(" * [SZ] * TC [TW]")._unsafeUnwrap()).toEqual({
|
||||
mustInclude: [
|
||||
{ mode: "TW", isGuaranteed: false },
|
||||
{ mode: "SZ", isGuaranteed: false },
|
||||
],
|
||||
pattern: ["ANY", "ANY", "TC"],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores repeated must include mode", () => {
|
||||
expect(MapList.parsePattern("[SZ][SZ]")._unsafeUnwrap()).toEqual({
|
||||
mustInclude: [{ mode: "SZ", isGuaranteed: false }],
|
||||
pattern: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses an empty pattern", () => {
|
||||
expect(MapList.parsePattern("")._unsafeUnwrap()).toEqual({
|
||||
pattern: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns error when pattern is too long", () => {
|
||||
const longPattern = "a".repeat(51);
|
||||
const result = MapList.parsePattern(longPattern);
|
||||
expect(result.isErr()).toBe(true);
|
||||
if (result.isErr()) {
|
||||
expect(result.error).toBe("pattern too long");
|
||||
}
|
||||
});
|
||||
|
||||
it("return error on lorem ipsum", () => {
|
||||
expect(
|
||||
MapList.parsePattern(
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi ut varius velit. Ut egestas lacus dolor, sit amet iaculis justo dictum sed. Fusce aliquet sed nunc sit amet ullamcorper. Interdum et malesuada fames ac ante ipsum primis in faucibus. Integer leo ex, congue eu porta nec, imperdiet sed neque.",
|
||||
),
|
||||
).toBeInstanceOf(Err);
|
||||
});
|
||||
});
|
||||
310
app/features/map-list-generator/core/MapList.ts
Normal file
310
app/features/map-list-generator/core/MapList.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { err, ok } from "neverthrow";
|
||||
import * as R from "remeda";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import type {
|
||||
ModeShort,
|
||||
ModeWithStage,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { MapPool } from "./map-pool";
|
||||
import type { ReadonlyMapPoolObject } from "./map-pool-serializer/types";
|
||||
|
||||
interface GenerateNext {
|
||||
/** How many maps to return? E.g. for a Bo5 set, amount should be 5 */
|
||||
amount: number;
|
||||
pattern?: string;
|
||||
}
|
||||
|
||||
interface MaplistPattern {
|
||||
mustInclude?: Array<{
|
||||
mode: ModeShort;
|
||||
/** Should the mode appear in the guaranteed to be played maps of a Best of set e.g. first 3 of a Bo5? */
|
||||
isGuaranteed: boolean;
|
||||
}>;
|
||||
pattern: Array<"ANY" | ModeShort>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates map lists that avoid repeating stages and optionally allows providing mode pattern.
|
||||
*
|
||||
* @example
|
||||
* const generator = generate({ mapPool: new MapPool(pool) });
|
||||
* generator.next();
|
||||
* const firstSet = generator.next({ amount: 5 }).value;
|
||||
* const secondSet = generator.next({ amount: 3, pattern: "SZ*TC" }).value; // remembers stages used in firstSet
|
||||
*/
|
||||
export function* generate(args: {
|
||||
/** The map pool to use for generating map lists (MapPool class) */
|
||||
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;
|
||||
}): Generator<Array<ModeWithStage>, Array<ModeWithStage>, GenerateNext> {
|
||||
if (args.mapPool.isEmpty()) {
|
||||
while (true) yield [];
|
||||
}
|
||||
|
||||
const modes = args.mapPool.modes;
|
||||
|
||||
const stageCounts = initializeStageCounts(modes, args.mapPool.parsed);
|
||||
const orderedModes = modeOrders(modes);
|
||||
const stageUsageTracker = new Map<StageId, number>();
|
||||
let currentOrderIndex = 0;
|
||||
|
||||
const firstArgs = yield [];
|
||||
let amount = firstArgs.amount;
|
||||
let pattern = firstArgs.pattern
|
||||
? parsePattern(firstArgs.pattern).unwrapOr(null)
|
||||
: null;
|
||||
|
||||
while (true) {
|
||||
const result: ModeWithStage[] = [];
|
||||
|
||||
let currentModeOrder =
|
||||
orderedModes[currentOrderIndex % orderedModes.length];
|
||||
if (pattern) {
|
||||
currentModeOrder = modifyModeOrderByPattern(
|
||||
currentModeOrder,
|
||||
pattern,
|
||||
amount,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < amount; i++) {
|
||||
const mode = currentModeOrder[i % currentModeOrder.length];
|
||||
const possibleStages = R.shuffle(args.mapPool.parsed[mode]);
|
||||
const isNotGuaranteedToBePlayed = args.considerGuaranteed
|
||||
? Math.ceil(amount / 2) <= i
|
||||
: false;
|
||||
|
||||
replenishStageIds({ possibleStages, stageCounts, stageUsageTracker });
|
||||
const stageId = mostRarelySeenStage(possibleStages, stageCounts);
|
||||
|
||||
result.push({ mode, stageId });
|
||||
stageCounts.set(
|
||||
stageId,
|
||||
stageCounts.get(stageId)! + (isNotGuaranteedToBePlayed ? 0.5 : 1),
|
||||
);
|
||||
stageUsageTracker.set(stageId, currentOrderIndex);
|
||||
}
|
||||
|
||||
currentOrderIndex++;
|
||||
const nextArgs = yield result;
|
||||
amount = nextArgs.amount;
|
||||
pattern = nextArgs.pattern
|
||||
? parsePattern(nextArgs.pattern).unwrapOr(null)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
function initializeStageCounts(
|
||||
modes: ModeShort[],
|
||||
mapPool: ReadonlyMapPoolObject,
|
||||
) {
|
||||
const counts = new Map<StageId, number>();
|
||||
const stageIds = modes.flatMap((mode) => mapPool[mode]);
|
||||
|
||||
for (const stageId of stageIds) {
|
||||
counts.set(stageId, 0);
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** This function is used for controlling in which order we start reusing the stage ids */
|
||||
function replenishStageIds({
|
||||
possibleStages,
|
||||
stageCounts,
|
||||
stageUsageTracker,
|
||||
}: {
|
||||
possibleStages: StageId[];
|
||||
stageCounts: Map<StageId, number>;
|
||||
stageUsageTracker: Map<StageId, number>;
|
||||
}) {
|
||||
const allOptionsEqual = possibleStages.every(
|
||||
(stageId) =>
|
||||
stageCounts.get(stageId) === stageCounts.get(possibleStages[0]),
|
||||
);
|
||||
if (!allOptionsEqual) return;
|
||||
|
||||
const relevantStageUsage = Array.from(stageUsageTracker.entries())
|
||||
.filter(([stageId]) => possibleStages.includes(stageId))
|
||||
.sort((a, b) => a[1] - b[1]);
|
||||
|
||||
const stagesToReplenish: StageId[] = [];
|
||||
|
||||
for (const [stageId] of relevantStageUsage) {
|
||||
stagesToReplenish.push(stageId);
|
||||
if (stagesToReplenish.length >= possibleStages.length / 2) break;
|
||||
}
|
||||
|
||||
for (const stageId of stagesToReplenish) {
|
||||
stageCounts.set(stageId, (stageCounts.get(stageId) ?? 0) - 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
function mostRarelySeenStage(
|
||||
possibleStages: StageId[],
|
||||
stageCounts: Map<StageId, number>,
|
||||
) {
|
||||
const result = possibleStages.toSorted(
|
||||
(a, b) => stageCounts.get(a)! - stageCounts.get(b)!,
|
||||
)[0];
|
||||
invariant(
|
||||
typeof result === "number",
|
||||
"Expected to find at least one stage ID",
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const MAX_MODE_ORDERS_ITERATIONS = 100;
|
||||
|
||||
function modeOrders(modes: ModeShort[]) {
|
||||
if (modes.length === 1) return [[modes[0]]] as ModeShort[][];
|
||||
|
||||
const result: ModeShort[][] = [];
|
||||
|
||||
const shuffledModes = R.shuffle(modes);
|
||||
|
||||
for (let i = 0; i < MAX_MODE_ORDERS_ITERATIONS; i++) {
|
||||
const startingMode = shuffledModes[i % shuffledModes.length];
|
||||
const rest = R.shuffle(shuffledModes.filter((m) => m !== startingMode));
|
||||
|
||||
const candidate = [startingMode, ...rest];
|
||||
|
||||
if (!result.some((r) => R.isShallowEqual(r, candidate))) {
|
||||
result.push(candidate);
|
||||
}
|
||||
|
||||
if (result.length === 10) break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function modifyModeOrderByPattern(
|
||||
modeOrder: ModeShort[],
|
||||
pattern: MaplistPattern,
|
||||
amount: number,
|
||||
) {
|
||||
const filteredModes = modeOrder.filter(
|
||||
(mode) => !pattern.pattern.includes(mode),
|
||||
);
|
||||
const modesToUse = filteredModes.length > 0 ? filteredModes : modeOrder;
|
||||
const result: ModeShort[] = Array.from(
|
||||
{ length: amount },
|
||||
(_, i) => modesToUse[i % modesToUse.length],
|
||||
);
|
||||
|
||||
const expandedPattern = Array.from(
|
||||
{ length: amount },
|
||||
(_, i) => pattern.pattern[i % pattern.pattern.length],
|
||||
);
|
||||
|
||||
if (pattern.mustInclude) {
|
||||
for (const { mode, isGuaranteed } of pattern.mustInclude) {
|
||||
// impossible must include, mode is not in the pool
|
||||
if (!modeOrder.includes(mode)) continue;
|
||||
|
||||
let possibleIndices = expandedPattern.every((part) => part !== "ANY")
|
||||
? // inflexible pattern fallback
|
||||
expandedPattern.map((_, idx) => idx)
|
||||
: expandedPattern.flatMap((part, idx) => (part === "ANY" ? [idx] : []));
|
||||
|
||||
if (isGuaranteed) {
|
||||
const guaranteedPositions = Math.ceil(amount / 2);
|
||||
possibleIndices = possibleIndices.filter(
|
||||
(idx) => idx < guaranteedPositions,
|
||||
);
|
||||
}
|
||||
|
||||
const isAlreadyIncluded = result.includes(mode);
|
||||
// "good spot" means a spot where the pattern allows ANY mode
|
||||
const isInGoodSpot = possibleIndices.includes(result.indexOf(mode));
|
||||
|
||||
if (!isAlreadyIncluded) {
|
||||
const randomIndex = R.sample(possibleIndices, 1)[0];
|
||||
invariant(typeof randomIndex === "number");
|
||||
result[randomIndex] = mode;
|
||||
} else if (!isInGoodSpot) {
|
||||
const currentIndex = result.indexOf(mode);
|
||||
const targetIndex = R.sample(
|
||||
possibleIndices.filter((idx) => idx !== currentIndex),
|
||||
1,
|
||||
)[0];
|
||||
invariant(typeof targetIndex === "number");
|
||||
|
||||
[result[currentIndex], result[targetIndex]] = [
|
||||
result[targetIndex],
|
||||
result[currentIndex],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pattern.pattern.every((part) => part === "ANY")) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const [idx, mode] of expandedPattern.entries()) {
|
||||
if (mode === "ANY") continue;
|
||||
|
||||
if (modeOrder.includes(mode)) {
|
||||
result[idx] = mode;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const validPatternParts = new Set(["*", ...modesShort] as const);
|
||||
|
||||
/**
|
||||
* Parses a pattern string into structured pattern data for map list generation.
|
||||
*
|
||||
* @example
|
||||
* parsePattern("SZ*TC").unwrapOr(null); // { pattern: ["SZ", "ANY", "TC"] }
|
||||
* parsePattern("[RM!]*SZ").unwrapOr(null); // { pattern: ["ANY", "SZ"], mustInclude: [{ mode: "RM", isGuaranteed: true }] }
|
||||
*/
|
||||
export function parsePattern(pattern: string) {
|
||||
if (pattern.length > 50) {
|
||||
return err("pattern too long");
|
||||
}
|
||||
|
||||
const mustInclude: Array<{ mode: ModeShort; isGuaranteed: boolean }> = [];
|
||||
let mutablePattern = pattern;
|
||||
for (const mode of modesShort) {
|
||||
if (mutablePattern.includes(`[${mode}!]`)) {
|
||||
mustInclude.push({ mode, isGuaranteed: true });
|
||||
mutablePattern = mutablePattern.replaceAll(`[${mode}!]`, "");
|
||||
} else if (mutablePattern.includes(`[${mode}]`)) {
|
||||
mustInclude.push({ mode, isGuaranteed: false });
|
||||
mutablePattern = mutablePattern.replaceAll(`[${mode}]`, "");
|
||||
}
|
||||
}
|
||||
|
||||
for (const part of validPatternParts) {
|
||||
mutablePattern = mutablePattern.replaceAll(part, `${part},`);
|
||||
}
|
||||
|
||||
const parts = mutablePattern
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0);
|
||||
|
||||
if (parts.some((part) => !validPatternParts.has(part as ModeShort | "*"))) {
|
||||
return err("invalid mode in pattern");
|
||||
}
|
||||
|
||||
if (parts.length > 0 && parts[0] === "*" && parts.at(-1) === "*") {
|
||||
parts.pop();
|
||||
}
|
||||
|
||||
return ok({
|
||||
pattern: parts.map((part) =>
|
||||
modesShort.includes(part as ModeShort) ? (part as ModeShort) : "ANY",
|
||||
),
|
||||
mustInclude: mustInclude.length > 0 ? mustInclude : undefined,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
// Original version by Lean
|
||||
|
||||
import type {
|
||||
ModeShort,
|
||||
ModeWithStage,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { MapPool } from "../map-pool";
|
||||
import type { MapPoolObject } from "../map-pool-serializer/types";
|
||||
|
||||
const BACKLOG = 2;
|
||||
|
||||
export type Popularity = Map<ModeShort, Map<StageId, number>>;
|
||||
|
||||
type MapBucket = Map<number, MapPoolObject>;
|
||||
|
||||
/**
|
||||
* @param mapPool Map pool to work with as dictionary
|
||||
* @param modeList List of modes that define the order of modes
|
||||
* @param games list of ints. Each entry is one round of x maps.
|
||||
* @param popularity Popularity List, dict of [mode][map] -> votes
|
||||
* @returns List of maps and mode combinations
|
||||
*/
|
||||
export function generateMapList(
|
||||
mapPool: MapPool,
|
||||
modeList: ModeShort[],
|
||||
games: number[],
|
||||
popularity?: Popularity,
|
||||
) {
|
||||
let modeIndex = 0;
|
||||
const mapList: ModeWithStage[][] = [];
|
||||
const buckets: MapBucket = new Map();
|
||||
const mapHistory: StageId[] = [];
|
||||
let newMap: StageId | null = null;
|
||||
|
||||
for (let i = 0; i < games.length; i++) {
|
||||
const roundMapList: ModeWithStage[] = [];
|
||||
|
||||
for (let j = 0; j < games[i]; j++) {
|
||||
const mode = modeList[modeIndex];
|
||||
invariant(mode, "Mode is missing");
|
||||
|
||||
if (!popularity) {
|
||||
newMap = getMap(mapPool, mode, buckets, mapHistory);
|
||||
} else {
|
||||
newMap = getMapPopular(mapPool, mode, popularity, mapHistory);
|
||||
}
|
||||
|
||||
mapHistory.push(newMap);
|
||||
roundMapList.push({ mode, stageId: newMap });
|
||||
modeIndex = (modeIndex + 1) % modeList.length;
|
||||
}
|
||||
|
||||
mapList.push(roundMapList);
|
||||
}
|
||||
|
||||
return mapList;
|
||||
}
|
||||
|
||||
function isValid(stageId: StageId, mapHistory: StageId[]) {
|
||||
// [1,2,3,4,5,6,7,8,9,10].slice(-2)
|
||||
// > (2) [9, 10]
|
||||
return !mapHistory.slice(-BACKLOG).includes(stageId);
|
||||
}
|
||||
|
||||
function addAndReturnMap(
|
||||
stageId: StageId,
|
||||
mode: ModeShort,
|
||||
buckets: MapBucket,
|
||||
bucketNum: number,
|
||||
) {
|
||||
// if next bucket doesnt exists then create it
|
||||
const nextBucket = bucketNum + 1;
|
||||
if (!buckets.has(nextBucket)) {
|
||||
buckets.set(nextBucket, {
|
||||
TW: [],
|
||||
SZ: [],
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
} as MapPoolObject);
|
||||
}
|
||||
|
||||
buckets.get(bucketNum)![mode] = buckets
|
||||
.get(bucketNum)!
|
||||
[mode].filter((map) => map !== stageId);
|
||||
|
||||
buckets.get(nextBucket)![mode].push(stageId);
|
||||
return stageId;
|
||||
}
|
||||
|
||||
function getMapPopular(
|
||||
mapPool: MapPool,
|
||||
mode: ModeShort,
|
||||
popularity: Popularity,
|
||||
mapHistory: StageId[],
|
||||
): StageId {
|
||||
const popularity_map_pool = new Map();
|
||||
for (const [stageId, votes] of popularity.get(mode)!.entries()) {
|
||||
if (mapPool.parsed[mode].includes(stageId)) {
|
||||
popularity_map_pool.set(stageId, votes);
|
||||
}
|
||||
}
|
||||
let stageId = randomMap(popularity_map_pool);
|
||||
while (!isValid(stageId, mapHistory)) {
|
||||
stageId = randomMap(popularity_map_pool);
|
||||
}
|
||||
return stageId;
|
||||
}
|
||||
|
||||
function randomMap(popularityList: Map<StageId, number>) {
|
||||
const maxNumber = Array.from(popularityList.values()).reduce(
|
||||
(a, b) => a + b,
|
||||
0,
|
||||
);
|
||||
const randInt = Math.floor(Math.random() * maxNumber);
|
||||
let counter = 0;
|
||||
let lastStageId: StageId | null = null;
|
||||
for (const [stageId, votes] of popularityList) {
|
||||
counter += votes;
|
||||
if (counter >= randInt) {
|
||||
return stageId;
|
||||
}
|
||||
lastStageId = stageId;
|
||||
}
|
||||
|
||||
invariant(lastStageId, "Last stage id is missing");
|
||||
return lastStageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffles array in place.
|
||||
* @param {Array} a items An array containing the items.
|
||||
*/
|
||||
function shuffle<T>(a: Array<T>) {
|
||||
let j: number;
|
||||
let x: T;
|
||||
let i: number;
|
||||
for (i = a.length - 1; i > 0; i--) {
|
||||
j = Math.floor(Math.random() * (i + 1));
|
||||
x = a[i];
|
||||
a[i] = a[j]!;
|
||||
a[j] = x!;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function getMap(
|
||||
mapPool: MapPool,
|
||||
mode: ModeShort,
|
||||
buckets: MapBucket,
|
||||
mapHistory: StageId[],
|
||||
) {
|
||||
if (!buckets.size) {
|
||||
buckets.set(0, mapPool.getClonedObject());
|
||||
}
|
||||
|
||||
for (let bucketNum = 0; bucketNum < buckets.size; bucketNum++) {
|
||||
const item = buckets.get(bucketNum);
|
||||
shuffle(item![mode]);
|
||||
|
||||
for (const [i, stageId] of item![mode].entries()) {
|
||||
// fallback solution, might happen if map pool is small
|
||||
const isLast = () => {
|
||||
// is actually last
|
||||
if (bucketNum === buckets.size - 1 && i === item![mode].length - 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// is last in bucket and next is empty
|
||||
const nextBucket = buckets.get(bucketNum + 1);
|
||||
if (
|
||||
i === item![mode].length - 1 &&
|
||||
nextBucket &&
|
||||
nextBucket[mode].length === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
if (isLast() || isValid(stageId, mapHistory)) {
|
||||
return addAndReturnMap(stageId, mode, buckets, bucketNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw Error("Invalid bucket configuration");
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import * as R from "remeda";
|
||||
import type { ModeShort } from "../../../../modules/in-game-lists/types";
|
||||
|
||||
export function modesOrder(
|
||||
type: "EQUAL" | "SZ_EVERY_OTHER",
|
||||
modes: ModeShort[],
|
||||
): ModeShort[] {
|
||||
if (type === "EQUAL") {
|
||||
return R.shuffle(modes);
|
||||
}
|
||||
|
||||
const withoutSZ = R.shuffle(modes.filter((mode) => mode !== "SZ"));
|
||||
|
||||
return withoutSZ.flatMap((mode) => [mode, "SZ"]);
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import type { Tables } from "~/db/tables";
|
||||
import type { ModeShort } from "../../../../modules/in-game-lists/types";
|
||||
import type { MapPool } from "../map-pool";
|
||||
import type { MapPoolObject } from "../map-pool-serializer/types";
|
||||
|
||||
export function mapPoolToNonEmptyModes(mapPool: MapPool) {
|
||||
const result: ModeShort[] = [];
|
||||
|
||||
for (const [key, stages] of Object.entries(mapPool.parsed)) {
|
||||
if (stages.length === 0) continue;
|
||||
|
||||
result.push(key as ModeShort);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mapPoolListToMapPoolObject(
|
||||
mapPoolList: Array<Pick<Tables["MapPoolMap"], "stageId" | "mode">>,
|
||||
) {
|
||||
const result: MapPoolObject = {
|
||||
TW: [],
|
||||
SZ: [],
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
};
|
||||
|
||||
for (const { stageId, mode } of mapPoolList) {
|
||||
result[mode].push(stageId);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import type { Tables } from "~/db/tables";
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { mapPoolListToMapPoolObject } from "./map-list-generator/utils";
|
||||
import {
|
||||
mapPoolToSerializedString,
|
||||
serializedStringToMapPool,
|
||||
|
|
@ -21,7 +20,9 @@ export class MapPool {
|
|||
private asObject?: ReadonlyMapPoolObject;
|
||||
|
||||
constructor(init: ReadonlyMapPoolObject | string | DbMapPoolList) {
|
||||
this.source = Array.isArray(init) ? mapPoolListToMapPoolObject(init) : init;
|
||||
this.source = Array.isArray(init)
|
||||
? this.mapPoolListToMapPoolObject(init)
|
||||
: init;
|
||||
}
|
||||
|
||||
static serialize(init: ReadonlyMapPoolObject | string | DbMapPoolList) {
|
||||
|
|
@ -70,6 +71,12 @@ export class MapPool {
|
|||
return Object.values(this.parsed).flat();
|
||||
}
|
||||
|
||||
get modes() {
|
||||
return Object.keys(this.parsed).filter(
|
||||
(key) => this.parsed[key as ModeShort].length > 0,
|
||||
) as ModeShort[];
|
||||
}
|
||||
|
||||
get stageModePairs() {
|
||||
return Object.entries(this.parsed).flatMap(([mode, stages]) =>
|
||||
stages.map((stageId) => ({ mode: mode as ModeShort, stageId })),
|
||||
|
|
@ -127,6 +134,24 @@ export class MapPool {
|
|||
};
|
||||
}
|
||||
|
||||
private mapPoolListToMapPoolObject(
|
||||
mapPoolList: Array<Pick<Tables["MapPoolMap"], "stageId" | "mode">>,
|
||||
) {
|
||||
const result: MapPoolObject = {
|
||||
TW: [],
|
||||
SZ: [],
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
};
|
||||
|
||||
for (const { stageId, mode } of mapPoolList) {
|
||||
result[mode].push(stageId);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static EMPTY = new MapPool({
|
||||
SZ: [],
|
||||
TC: [],
|
||||
|
|
|
|||
|
|
@ -16,9 +16,7 @@ import invariant from "~/utils/invariant";
|
|||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { ipLabsMaps, MAPS_URL, navIconUrl } from "~/utils/urls";
|
||||
import { generateMapList } from "../core/map-list-generator/map-list";
|
||||
import { modesOrder } from "../core/map-list-generator/modes";
|
||||
import { mapPoolToNonEmptyModes } from "../core/map-list-generator/utils";
|
||||
import * as MapList from "../core/MapList";
|
||||
import { MapPool } from "../core/map-pool";
|
||||
|
||||
import styles from "./maps.module.css";
|
||||
|
|
@ -121,14 +119,13 @@ function MapListCreator({ mapPool }: { mapPool: MapPool }) {
|
|||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const handleCreateMaplist = () => {
|
||||
const [list] = generateMapList(
|
||||
mapPool,
|
||||
modesOrder(
|
||||
szEveryOther ? "SZ_EVERY_OTHER" : "EQUAL",
|
||||
mapPoolToNonEmptyModes(mapPool),
|
||||
),
|
||||
[AMOUNT_OF_MAPS_IN_MAP_LIST],
|
||||
);
|
||||
const generator = MapList.generate({ mapPool });
|
||||
generator.next();
|
||||
|
||||
const list = generator.next({
|
||||
amount: AMOUNT_OF_MAPS_IN_MAP_LIST,
|
||||
pattern: szEveryOther ? (Math.random() > 0.5 ? "SZ*" : "*SZ") : undefined,
|
||||
}).value;
|
||||
|
||||
invariant(list);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,9 @@ import type { LookingGroupWithInviteCode } from "~/features/sendouq/q-types";
|
|||
import { BANNED_MAPS } 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 {
|
||||
createTournamentMapList,
|
||||
type TournamentMapListMap,
|
||||
} from "~/modules/tournament-map-list-generator";
|
||||
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 { logger } from "~/utils/logger";
|
||||
import { averageArray } from "~/utils/number";
|
||||
|
|
@ -44,7 +42,7 @@ export function matchMapList(
|
|||
);
|
||||
|
||||
try {
|
||||
return createTournamentMapList({
|
||||
return generateBalancedMapList({
|
||||
count: SENDOUQ_BEST_OF,
|
||||
seed: String(groupOne.id),
|
||||
modesIncluded,
|
||||
|
|
@ -71,7 +69,7 @@ export function matchMapList(
|
|||
// in that case, just return a map list from our default set of maps
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
return createTournamentMapList({
|
||||
return generateBalancedMapList({
|
||||
count: SENDOUQ_BEST_OF,
|
||||
seed: String(groupOne.id),
|
||||
modesIncluded,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import type { ParsedMemento, Tables } from "~/db/tables";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
import { syncGroupTeamId } from "./syncGroupTeamId.server";
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,13 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
mapPickingStyle: match.mapPickingStyle,
|
||||
maps: match.roundMaps,
|
||||
pickBanEvents,
|
||||
recentlyPlayedMaps:
|
||||
await TournamentTeamRepository.findRecentlyPlayedMapsByIds({
|
||||
teamIds: [match.opponentOne.id, match.opponentTwo.id],
|
||||
}).catch((error) => {
|
||||
logger.error("Failed to fetch recently played maps", error);
|
||||
return [];
|
||||
}),
|
||||
})
|
||||
: null;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-5);
|
||||
}
|
||||
|
||||
.pickBanSelect {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.roundsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.roundControls {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
margin-block-start: var(--s-2);
|
||||
margin-block-end: var(--s-3);
|
||||
}
|
||||
|
||||
.roundButton {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg-lightest-solid);
|
||||
background-color: var(--bg-lighter);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--fonts-sm);
|
||||
font-weight: var(--semi-bold);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.roundButton:hover:not(:disabled) {
|
||||
background-color: var(--bg-lightest);
|
||||
}
|
||||
|
||||
.roundButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.roundButton:focus-visible {
|
||||
outline: 2px solid var(--theme);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.roundButton svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.roundButtonActive {
|
||||
background-color: var(--theme);
|
||||
color: var(--button-text);
|
||||
border-color: var(--theme);
|
||||
}
|
||||
|
||||
.roundButtonActive:hover {
|
||||
background-color: var(--theme-transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.roundButtonNumber {
|
||||
cursor: default;
|
||||
background-color: var(--bg-lightest);
|
||||
}
|
||||
|
||||
.roundControlsDivider {
|
||||
width: 3px;
|
||||
height: 2rem;
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--bg-lightest-solid);
|
||||
margin-inline: var(--s-1);
|
||||
}
|
||||
|
||||
.mapListRow {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
margin-block: var(--s-2);
|
||||
list-style: none;
|
||||
min-width: 275px;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.mapSelectContainer {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
font-size: var(--fonts-sm);
|
||||
font-weight: var(--semi-bold);
|
||||
align-items: center;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mapSelect {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mapSelectIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--text-lighter);
|
||||
margin-left: auto;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.infoPopover {
|
||||
margin-block-start: -6px;
|
||||
}
|
||||
|
||||
.patternInput {
|
||||
max-width: 10rem;
|
||||
}
|
||||
|
|
@ -3,8 +3,11 @@ import clsx from "clsx";
|
|||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { ModeImage, StageImage } from "~/components/Image";
|
||||
import { InfoPopover } from "~/components/InfoPopover";
|
||||
import { Input } from "~/components/Input";
|
||||
import { MinusIcon } from "~/components/icons/Minus";
|
||||
import { PlusIcon } from "~/components/icons/Plus";
|
||||
import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows";
|
||||
import { Label } from "~/components/Label";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
|
|
@ -16,6 +19,7 @@ import {
|
|||
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
||||
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
|
||||
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { nullFilledArray } from "~/utils/arrays";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
|
|
@ -23,7 +27,9 @@ import invariant from "~/utils/invariant";
|
|||
import { assertUnreachable } from "~/utils/types";
|
||||
import { calendarEditPage } from "~/utils/urls";
|
||||
import { SendouButton } from "../../../components/elements/Button";
|
||||
import { ChevronUpDownIcon } from "../../../components/icons/ChevronUpDown";
|
||||
import { LinkIcon } from "../../../components/icons/Link";
|
||||
import { PickIcon } from "../../../components/icons/Pick";
|
||||
import { UnlinkIcon } from "../../../components/icons/Unlink";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import type { Bracket } from "../core/Bracket";
|
||||
|
|
@ -35,6 +41,7 @@ import {
|
|||
generateTournamentRoundMaplist,
|
||||
type TournamentRoundMapList,
|
||||
} from "../core/toMapList";
|
||||
import styles from "./BracketMapListDialog.module.css";
|
||||
|
||||
export function BracketMapListDialog({
|
||||
isOpen,
|
||||
|
|
@ -49,6 +56,7 @@ export function BracketMapListDialog({
|
|||
bracketIdx: number;
|
||||
isPreparing?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const fetcher = useFetcher();
|
||||
const tournament = useTournament();
|
||||
const untrimmedPreparedMaps = useBracketPreparedMaps(bracketIdx);
|
||||
|
|
@ -69,7 +77,6 @@ export function BracketMapListDialog({
|
|||
})
|
||||
: untrimmedPreparedMaps;
|
||||
|
||||
const [szFirst, setSzFirst] = React.useState(false);
|
||||
const [eliminationTeamCount, setEliminationTeamCount] = React.useState<
|
||||
number | null
|
||||
>(() => {
|
||||
|
|
@ -124,6 +131,7 @@ export function BracketMapListDialog({
|
|||
);
|
||||
},
|
||||
);
|
||||
const [patterns, setPatterns] = React.useState(new Map<number, string>());
|
||||
|
||||
const bracketData = isPreparing
|
||||
? teamCountAdjustedBracketData({
|
||||
|
|
@ -138,8 +146,6 @@ export function BracketMapListDialog({
|
|||
preparedMaps?.maps[0].type ?? "BEST_OF",
|
||||
);
|
||||
|
||||
const flavor = szFirst ? "SZ_FIRST" : null;
|
||||
|
||||
const [maps, setMaps] = React.useState(() => {
|
||||
if (preparedMaps) {
|
||||
return new Map(preparedMaps.maps.map((map) => [map.roundId, map]));
|
||||
|
|
@ -152,11 +158,13 @@ export function BracketMapListDialog({
|
|||
rounds,
|
||||
type: bracket.type,
|
||||
pickBanStyle: null,
|
||||
flavor,
|
||||
patterns,
|
||||
countType,
|
||||
});
|
||||
});
|
||||
const [pickBanStyle, setPickBanStyle] = React.useState(
|
||||
Array.from(maps.values()).find((round) => round.pickBan)?.pickBan,
|
||||
Array.from(maps.values()).find((round) => round.pickBan)?.pickBan ??
|
||||
"COUNTERPICK",
|
||||
);
|
||||
const [hoveredMap, setHoveredMap] = React.useState<string | null>(null);
|
||||
|
||||
|
|
@ -242,7 +250,6 @@ export function BracketMapListDialog({
|
|||
return newRoundsWithPickBan;
|
||||
};
|
||||
|
||||
// TODO: could also validate you aren't going up from winners finals to grands etc. (different groups)
|
||||
const validateNoDecreasingCount = () => {
|
||||
for (const groupCounts of mapCounts.values()) {
|
||||
let roundPreviousValue = 0;
|
||||
|
|
@ -258,6 +265,17 @@ export function BracketMapListDialog({
|
|||
}
|
||||
}
|
||||
|
||||
// check grands have at least as many maps as winners final (different groups)
|
||||
if (bracket.type === "double_elimination") {
|
||||
const grandsCounts = Array.from(mapCounts.get(2)?.values() ?? []);
|
||||
const winnersCounts = Array.from(mapCounts.get(0)?.values() ?? []);
|
||||
const maxWinnersCount = Math.max(...winnersCounts.map((c) => c.count));
|
||||
|
||||
if (grandsCounts.some(({ count }) => count < maxWinnersCount)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
|
|
@ -280,7 +298,7 @@ export function BracketMapListDialog({
|
|||
onClose={close}
|
||||
isFullScreen
|
||||
>
|
||||
<fetcher.Form method="post" className="map-list-dialog__container">
|
||||
<fetcher.Form method="post" className={styles.container}>
|
||||
<input type="hidden" name="bracketIdx" value={bracketIdx} />
|
||||
<input
|
||||
type="hidden"
|
||||
|
|
@ -332,30 +350,6 @@ export function BracketMapListDialog({
|
|||
<>
|
||||
<div className="stack horizontal items-center justify-between">
|
||||
<div className="stack horizontal lg flex-wrap">
|
||||
<PickBanSelect
|
||||
pickBanStyle={pickBanStyle}
|
||||
isOneModeOnly={tournament.modesIncluded.length === 1}
|
||||
onPickBanStyleChange={(pickBanStyle) => {
|
||||
let newRoundsWithPickBan = roundsWithPickBan;
|
||||
if (globalSelections) {
|
||||
newRoundsWithPickBan =
|
||||
mapCountsWithGlobalPickBanStyle(pickBanStyle);
|
||||
}
|
||||
|
||||
setPickBanStyle(pickBanStyle);
|
||||
setMaps(
|
||||
generateTournamentRoundMaplist({
|
||||
mapCounts,
|
||||
pool: tournament.ctx.toSetMapPool,
|
||||
rounds,
|
||||
type: bracket.type,
|
||||
roundsWithPickBan: newRoundsWithPickBan,
|
||||
pickBanStyle,
|
||||
flavor,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{isPreparing &&
|
||||
(bracket.type === "single_elimination" ||
|
||||
bracket.type === "double_elimination") ? (
|
||||
|
|
@ -382,32 +376,52 @@ export function BracketMapListDialog({
|
|||
type: bracket.type,
|
||||
roundsWithPickBan,
|
||||
pickBanStyle,
|
||||
flavor,
|
||||
patterns,
|
||||
countType,
|
||||
}),
|
||||
);
|
||||
setEliminationTeamCount(newCount);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{globalSelections ? (
|
||||
<GlobalMapCountInput
|
||||
defaultValue={
|
||||
// beautiful 🥹
|
||||
mapCounts.values().next().value?.values().next().value
|
||||
?.count
|
||||
}
|
||||
onSetCount={(newCount) => {
|
||||
const newMapCounts = mapCountsWithGlobalCount(newCount);
|
||||
const newMaps = generateTournamentRoundMaplist({
|
||||
mapCounts: newMapCounts,
|
||||
pool: tournament.ctx.toSetMapPool,
|
||||
rounds,
|
||||
type: bracket.type,
|
||||
roundsWithPickBan,
|
||||
pickBanStyle,
|
||||
flavor,
|
||||
});
|
||||
setMaps(newMaps);
|
||||
{!needsToPickEliminationTeamCount ? (
|
||||
<PickBanSelect
|
||||
pickBanStyle={pickBanStyle}
|
||||
isOneModeOnly={tournament.modesIncluded.length === 1}
|
||||
onPickBanStyleChange={(newPickBanStyle) => {
|
||||
let newRoundsWithPickBan = roundsWithPickBan;
|
||||
if (globalSelections) {
|
||||
newRoundsWithPickBan =
|
||||
mapCountsWithGlobalPickBanStyle(newPickBanStyle);
|
||||
}
|
||||
|
||||
setPickBanStyle(newPickBanStyle);
|
||||
|
||||
const noPickBanSetBeforeOrAfter =
|
||||
!roundsWithPickBan.size && !newRoundsWithPickBan.size;
|
||||
const switchedFromCounterpickToAnother =
|
||||
(pickBanStyle === "COUNTERPICK" &&
|
||||
newPickBanStyle === "COUNTERPICK_MODE_REPEAT_OK") ||
|
||||
(pickBanStyle === "COUNTERPICK_MODE_REPEAT_OK" &&
|
||||
newPickBanStyle === "COUNTERPICK");
|
||||
const shouldSkipRegenerateMaps =
|
||||
noPickBanSetBeforeOrAfter ||
|
||||
switchedFromCounterpickToAnother;
|
||||
|
||||
if (!shouldSkipRegenerateMaps) {
|
||||
setMaps(
|
||||
generateTournamentRoundMaplist({
|
||||
mapCounts,
|
||||
pool: tournament.ctx.toSetMapPool,
|
||||
rounds,
|
||||
type: bracket.type,
|
||||
roundsWithPickBan: newRoundsWithPickBan,
|
||||
pickBanStyle: newPickBanStyle,
|
||||
patterns,
|
||||
countType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -417,27 +431,18 @@ export function BracketMapListDialog({
|
|||
onSetCountType={setCountType}
|
||||
/>
|
||||
) : null}
|
||||
{tournament.ctx.mapPickingStyle === "TO" ? (
|
||||
<SZFirstToggle
|
||||
szFirst={szFirst}
|
||||
setSzFirst={(newSzFirst) => {
|
||||
setSzFirst(newSzFirst);
|
||||
setMaps(
|
||||
generateTournamentRoundMaplist({
|
||||
mapCounts,
|
||||
pool: tournament.ctx.toSetMapPool,
|
||||
rounds,
|
||||
type: bracket.type,
|
||||
roundsWithPickBan,
|
||||
pickBanStyle,
|
||||
flavor: newSzFirst ? "SZ_FIRST" : null,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
{tournament.ctx.mapPickingStyle === "TO" &&
|
||||
tournament.modesIncluded.length > 1 &&
|
||||
!needsToPickEliminationTeamCount ? (
|
||||
<PatternInputs
|
||||
patterns={patterns}
|
||||
mapCounts={mapCounts}
|
||||
onPatternsChange={setPatterns}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{tournament.ctx.toSetMapPool.length > 0 ? (
|
||||
{tournament.ctx.toSetMapPool.length > 0 &&
|
||||
!needsToPickEliminationTeamCount ? (
|
||||
<SendouButton
|
||||
size="small"
|
||||
icon={<RefreshArrowsIcon />}
|
||||
|
|
@ -451,7 +456,8 @@ export function BracketMapListDialog({
|
|||
type: bracket.type,
|
||||
roundsWithPickBan,
|
||||
pickBanStyle,
|
||||
flavor,
|
||||
patterns,
|
||||
countType,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -464,13 +470,14 @@ export function BracketMapListDialog({
|
|||
<div className="text-center text-lg font-bold my-24">
|
||||
Pick the expected teams count above to prepare maps
|
||||
<div className="text-lighter text-sm">
|
||||
For SE/DE formats team count affects the amount of rounds
|
||||
played
|
||||
Tip: if uncertain, overestimate the team count. <br /> The
|
||||
system can remove unnecessary rounds, but if you choose too
|
||||
few, you'll need to repick all the maps.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="stack horizontal md flex-wrap justify-center">
|
||||
<div className={styles.roundsGrid}>
|
||||
{roundsWithNames.map((round) => {
|
||||
const roundMaps = maps.get(round.id);
|
||||
invariant(roundMaps, "Expected maps to be defined");
|
||||
|
|
@ -502,10 +509,25 @@ export function BracketMapListDialog({
|
|||
: undefined
|
||||
}
|
||||
hoveredMap={hoveredMap}
|
||||
includeRoundSpecificSelections={
|
||||
bracket.type !== "round_robin"
|
||||
}
|
||||
onCountChange={(newCount) => {
|
||||
if (globalSelections) {
|
||||
const newMapCounts =
|
||||
mapCountsWithGlobalCount(newCount);
|
||||
setMaps(
|
||||
generateTournamentRoundMaplist({
|
||||
mapCounts: newMapCounts,
|
||||
pool: tournament.ctx.toSetMapPool,
|
||||
rounds,
|
||||
type: bracket.type,
|
||||
roundsWithPickBan,
|
||||
pickBanStyle,
|
||||
patterns,
|
||||
countType,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const newMapCounts = new Map(mapCounts);
|
||||
const bracketRound = rounds.find(
|
||||
(r) => r.id === round.id,
|
||||
|
|
@ -535,47 +557,65 @@ export function BracketMapListDialog({
|
|||
count: newCount,
|
||||
});
|
||||
|
||||
const newMaps = generateTournamentRoundMaplist({
|
||||
const newMap = generateTournamentRoundMaplist({
|
||||
mapCounts: newMapCounts,
|
||||
pool: tournament.ctx.toSetMapPool,
|
||||
rounds,
|
||||
type: bracket.type,
|
||||
roundsWithPickBan,
|
||||
pickBanStyle,
|
||||
flavor,
|
||||
});
|
||||
setMaps(newMaps);
|
||||
}}
|
||||
onPickBanChange={
|
||||
pickBanStyle
|
||||
? (hasPickBan) => {
|
||||
const newRoundsWithPickBan = new Set(
|
||||
roundsWithPickBan,
|
||||
);
|
||||
if (hasPickBan) {
|
||||
newRoundsWithPickBan.add(round.id);
|
||||
} else {
|
||||
newRoundsWithPickBan.delete(round.id);
|
||||
}
|
||||
patterns,
|
||||
countType,
|
||||
}).get(round.id);
|
||||
|
||||
setMaps(
|
||||
generateTournamentRoundMaplist({
|
||||
mapCounts,
|
||||
pool: tournament.ctx.toSetMapPool,
|
||||
rounds,
|
||||
type: bracket.type,
|
||||
roundsWithPickBan: newRoundsWithPickBan,
|
||||
pickBanStyle,
|
||||
flavor,
|
||||
}),
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
setMaps(new Map(maps).set(round.id, newMap!));
|
||||
}}
|
||||
onPickBanChange={(hasPickBan) => {
|
||||
if (globalSelections) {
|
||||
const newRoundsWithPickBan = hasPickBan
|
||||
? mapCountsWithGlobalPickBanStyle(pickBanStyle)
|
||||
: new Set<number>();
|
||||
|
||||
setMaps(
|
||||
generateTournamentRoundMaplist({
|
||||
mapCounts,
|
||||
pool: tournament.ctx.toSetMapPool,
|
||||
rounds,
|
||||
type: bracket.type,
|
||||
roundsWithPickBan: newRoundsWithPickBan,
|
||||
pickBanStyle,
|
||||
patterns,
|
||||
countType,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const newRoundsWithPickBan = new Set(
|
||||
roundsWithPickBan,
|
||||
);
|
||||
if (hasPickBan) {
|
||||
newRoundsWithPickBan.add(round.id);
|
||||
} else {
|
||||
newRoundsWithPickBan.delete(round.id);
|
||||
}
|
||||
|
||||
const newMap = generateTournamentRoundMaplist({
|
||||
mapCounts,
|
||||
pool: tournament.ctx.toSetMapPool,
|
||||
rounds,
|
||||
type: bracket.type,
|
||||
roundsWithPickBan: newRoundsWithPickBan,
|
||||
pickBanStyle,
|
||||
patterns,
|
||||
countType,
|
||||
}).get(round.id);
|
||||
|
||||
setMaps(new Map(maps).set(round.id, newMap!));
|
||||
}}
|
||||
onRoundMapListChange={(newRoundMaps) => {
|
||||
const newMaps = new Map(maps);
|
||||
newMaps.set(round.id, newRoundMaps);
|
||||
|
||||
setMaps(newMaps);
|
||||
}}
|
||||
/>
|
||||
|
|
@ -587,20 +627,15 @@ export function BracketMapListDialog({
|
|||
Invalid selection: tournament progression decreases in map
|
||||
count
|
||||
</div>
|
||||
) : pickBanStyle && roundsWithPickBan.size === 0 ? (
|
||||
<div className="text-warning text-center">
|
||||
Invalid selection: pick/ban style selected but no rounds
|
||||
have it enabled
|
||||
</div>
|
||||
) : (
|
||||
<SubmitButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
testId="confirm-finalize-bracket-button"
|
||||
_action={isPreparing ? "PREPARE_MAPS" : "START_BRACKET"}
|
||||
className="mx-auto"
|
||||
className="mx-auto mt-4"
|
||||
>
|
||||
{isPreparing ? "Save the maps" : "Start the bracket"}
|
||||
{isPreparing
|
||||
? t("common:actions.save")
|
||||
: "Start the bracket"}
|
||||
</SubmitButton>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -756,31 +791,6 @@ function EliminationTeamCountSelect({
|
|||
);
|
||||
}
|
||||
|
||||
function GlobalMapCountInput({
|
||||
defaultValue = 3,
|
||||
onSetCount,
|
||||
}: {
|
||||
defaultValue?: number;
|
||||
onSetCount: (bestOf: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor="count">Count</Label>
|
||||
<select
|
||||
id="count"
|
||||
onChange={(e) => onSetCount(Number(e.target.value))}
|
||||
defaultValue={defaultValue}
|
||||
>
|
||||
{TOURNAMENT.AVAILABLE_BEST_OF.map((count) => (
|
||||
<option key={count} value={count}>
|
||||
{count}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalCountTypeSelect({
|
||||
defaultValue,
|
||||
onSetCountType,
|
||||
|
|
@ -810,9 +820,11 @@ function PickBanSelect({
|
|||
isOneModeOnly,
|
||||
onPickBanStyleChange,
|
||||
}: {
|
||||
pickBanStyle: TournamentRoundMaps["pickBan"];
|
||||
pickBanStyle: NonNullable<TournamentRoundMaps["pickBan"]>;
|
||||
isOneModeOnly: boolean;
|
||||
onPickBanStyleChange: (pickBanStyle: TournamentRoundMaps["pickBan"]) => void;
|
||||
onPickBanStyleChange: (
|
||||
pickBanStyle: NonNullable<TournamentRoundMaps["pickBan"]>,
|
||||
) => void;
|
||||
}) {
|
||||
const pickBanSelectText: Record<PickBan.Type, string> = {
|
||||
COUNTERPICK: "Counterpick",
|
||||
|
|
@ -820,51 +832,37 @@ function PickBanSelect({
|
|||
BAN_2: "Ban 2",
|
||||
};
|
||||
|
||||
// selection doesn't make sense for one mode only tournaments as you have to repeat the mode
|
||||
const availableTypes = PickBan.types.filter(
|
||||
(type) => !isOneModeOnly || type !== "COUNTERPICK_MODE_REPEAT_OK",
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor="pick-ban-style">Pick/ban</Label>
|
||||
<div className="stack horizontal xs items-center">
|
||||
<PickIcon className="w-4" />
|
||||
<Label htmlFor="pick-ban-style">Pick/ban style</Label>
|
||||
</div>
|
||||
<select
|
||||
className="map-list-dialog__pick-ban-select"
|
||||
className={styles.pickBanSelect}
|
||||
id="pick-ban-style"
|
||||
value={pickBanStyle ?? "NONE"}
|
||||
value={pickBanStyle}
|
||||
onChange={(e) =>
|
||||
onPickBanStyleChange(
|
||||
e.target.value === "NONE"
|
||||
? undefined
|
||||
: (e.target.value as TournamentRoundMaps["pickBan"]),
|
||||
e.target.value as NonNullable<TournamentRoundMaps["pickBan"]>,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="NONE">None</option>
|
||||
{PickBan.types
|
||||
.filter(
|
||||
(type) => !isOneModeOnly || type !== "COUNTERPICK_MODE_REPEAT_OK",
|
||||
)
|
||||
.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{pickBanSelectText[type]}
|
||||
</option>
|
||||
))}
|
||||
{availableTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{pickBanSelectText[type]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SZFirstToggle({
|
||||
szFirst,
|
||||
setSzFirst,
|
||||
}: {
|
||||
szFirst: boolean;
|
||||
setSzFirst: (szFirst: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="stack items-center">
|
||||
<Label htmlFor="sz-first">SZ first</Label>
|
||||
<SendouSwitch id="sz-first" isSelected={szFirst} onChange={setSzFirst} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const serializedMapMode = (
|
||||
map: NonNullable<TournamentRoundMaps["list"]>[number],
|
||||
) => `${map.mode}-${map.stageId}`;
|
||||
|
|
@ -872,91 +870,87 @@ const serializedMapMode = (
|
|||
function RoundMapList({
|
||||
name,
|
||||
maps,
|
||||
onRoundMapListChange,
|
||||
onHoverMap,
|
||||
onCountChange,
|
||||
onPickBanChange,
|
||||
onRoundMapListChange,
|
||||
unlink,
|
||||
link,
|
||||
hoveredMap,
|
||||
includeRoundSpecificSelections,
|
||||
}: {
|
||||
name: string;
|
||||
maps: Omit<TournamentRoundMaps, "type">;
|
||||
onRoundMapListChange: (maps: Omit<TournamentRoundMaps, "type">) => void;
|
||||
onHoverMap: (map: string | null) => void;
|
||||
onCountChange: (count: number) => void;
|
||||
onPickBanChange?: (hasPickBan: boolean) => void;
|
||||
onPickBanChange: (hasPickBan: boolean) => void;
|
||||
onRoundMapListChange: (maps: Omit<TournamentRoundMaps, "type">) => void;
|
||||
unlink?: () => void;
|
||||
link?: () => void;
|
||||
hoveredMap: string | null;
|
||||
includeRoundSpecificSelections: boolean;
|
||||
}) {
|
||||
const id = React.useId();
|
||||
const [editing, setEditing] = React.useState(false);
|
||||
const tournament = useTournament();
|
||||
|
||||
const minCount = TOURNAMENT.AVAILABLE_BEST_OF[0];
|
||||
const maxCount = TOURNAMENT.AVAILABLE_BEST_OF.at(-1)!;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="stack horizontal sm">
|
||||
<div>{name}</div>{" "}
|
||||
<SendouButton
|
||||
variant={editing ? "minimal-success" : "minimal"}
|
||||
onPress={() => setEditing(!editing)}
|
||||
data-testid="edit-round-maps-button"
|
||||
<h3>{name}</h3>
|
||||
<div className={styles.roundControls}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.roundButton}
|
||||
onClick={() => onCountChange(Math.max(minCount, maps.count - 2))}
|
||||
disabled={maps.count <= minCount}
|
||||
>
|
||||
{editing ? "Save" : "Edit"}
|
||||
</SendouButton>
|
||||
</h3>
|
||||
{unlink ? (
|
||||
<SendouButton
|
||||
size="miniscule"
|
||||
variant="outlined"
|
||||
className="mt-1"
|
||||
icon={<UnlinkIcon />}
|
||||
onPress={unlink}
|
||||
>
|
||||
Unlink
|
||||
</SendouButton>
|
||||
) : null}
|
||||
{link ? (
|
||||
<SendouButton
|
||||
size="miniscule"
|
||||
variant="outlined"
|
||||
className="mt-1"
|
||||
icon={<LinkIcon />}
|
||||
onPress={link}
|
||||
>
|
||||
Link
|
||||
</SendouButton>
|
||||
) : null}
|
||||
{editing && includeRoundSpecificSelections ? (
|
||||
<div className="stack xs horizontal">
|
||||
{TOURNAMENT.AVAILABLE_BEST_OF.map((count) => (
|
||||
<div key={count}>
|
||||
<Label htmlFor={`bo-${count}-${id}`}>Bo{count}</Label>
|
||||
<input
|
||||
id={`bo-${count}-${id}`}
|
||||
type="radio"
|
||||
value={count}
|
||||
checked={maps.count === count}
|
||||
onChange={() => onCountChange(count)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{onPickBanChange ? (
|
||||
<div>
|
||||
<Label htmlFor={`pick-ban-${id}`}>Pick/ban</Label>
|
||||
<SendouSwitch
|
||||
size="small"
|
||||
isSelected={Boolean(maps.pickBan)}
|
||||
onChange={onPickBanChange}
|
||||
id={`pick-ban-${id}`}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<MinusIcon />
|
||||
</button>
|
||||
<div className={clsx(styles.roundButton, styles.roundButtonNumber)}>
|
||||
{maps.count}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.roundButton}
|
||||
onClick={() => onCountChange(Math.min(maxCount, maps.count + 2))}
|
||||
disabled={maps.count >= maxCount}
|
||||
data-testid="increase-map-count-button"
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
<div className={styles.roundControlsDivider} />
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.roundButton, {
|
||||
[styles.roundButtonActive]: maps.pickBan,
|
||||
})}
|
||||
onClick={() => onPickBanChange(!maps.pickBan)}
|
||||
title="Toggle counterpick/ban"
|
||||
>
|
||||
<PickIcon />
|
||||
</button>
|
||||
{unlink ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.roundButton}
|
||||
onClick={unlink}
|
||||
title="Enter finals and 3rd place match separately"
|
||||
data-testid="unlink-finals-3rd-place-match-button"
|
||||
>
|
||||
<UnlinkIcon />
|
||||
</button>
|
||||
) : null}
|
||||
{link ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.roundButton}
|
||||
onClick={link}
|
||||
title="Link finals and 3rd place match to use the same maps"
|
||||
data-testid="link-finals-3rd-place-match-button"
|
||||
>
|
||||
<LinkIcon />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<ol className="pl-0">
|
||||
{nullFilledArray(
|
||||
maps.pickBan === "BAN_2" ? maps.count + 2 : maps.count,
|
||||
|
|
@ -969,7 +963,6 @@ function RoundMapList({
|
|||
key={i}
|
||||
map={map}
|
||||
number={i + 1}
|
||||
editing={editing}
|
||||
onHoverMap={onHoverMap}
|
||||
hoveredMap={hoveredMap}
|
||||
onMapChange={(map) => {
|
||||
|
|
@ -983,7 +976,8 @@ function RoundMapList({
|
|||
}
|
||||
|
||||
const isTeamsPick = !maps.list && i === 0;
|
||||
const isLast = i === maps.count - 1;
|
||||
const isLast =
|
||||
i === (maps.pickBan === "BAN_2" ? maps.count + 2 : maps.count) - 1;
|
||||
|
||||
return (
|
||||
<MysteryRow
|
||||
|
|
@ -1004,63 +998,65 @@ function RoundMapList({
|
|||
function MapListRow({
|
||||
map,
|
||||
number,
|
||||
editing,
|
||||
onMapChange,
|
||||
onHoverMap,
|
||||
hoveredMap,
|
||||
onMapChange,
|
||||
}: {
|
||||
map: NonNullable<TournamentRoundMaps["list"]>[number];
|
||||
number: number;
|
||||
editing: boolean;
|
||||
onMapChange: (map: NonNullable<TournamentRoundMaps["list"]>[number]) => void;
|
||||
onHoverMap: (map: string | null) => void;
|
||||
hoveredMap: string | null;
|
||||
onMapChange: (map: NonNullable<TournamentRoundMaps["list"]>[number]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
const tournament = useTournament();
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<li className="map-list-dialog__map-list-row">
|
||||
<div className="stack horizontal items-center xs">
|
||||
<span className="text-lg">{number}.</span>
|
||||
<select
|
||||
value={serializedMapMode(map)}
|
||||
onChange={(e) => {
|
||||
const [mode, stageId] = e.target.value.split("-");
|
||||
onMapChange({
|
||||
mode: mode as ModeShort,
|
||||
stageId: Number(stageId) as StageId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tournament.ctx.toSetMapPool.map((map) => (
|
||||
<option
|
||||
key={serializedMapMode(map)}
|
||||
value={serializedMapMode(map)}
|
||||
>
|
||||
{t(`game-misc:MODE_SHORT_${map.mode}`)}{" "}
|
||||
{t(`game-misc:STAGE_${map.stageId}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className={clsx("map-list-dialog__map-list-row", {
|
||||
className={clsx(styles.mapListRow, {
|
||||
"text-theme-secondary underline": serializedMapMode(map) === hoveredMap,
|
||||
})}
|
||||
onMouseEnter={() => onHoverMap(serializedMapMode(map))}
|
||||
>
|
||||
<div className="stack horizontal items-center xs">
|
||||
<span className="text-lg">{number}.</span>
|
||||
<div className={styles.mapSelectContainer}>
|
||||
<span className="text-sm text-lighter font-semi-bold">{number}.</span>
|
||||
<ModeImage mode={map.mode} size={24} />
|
||||
<StageImage stageId={map.stageId} height={24} className="rounded-sm" />
|
||||
{t(`game-misc:STAGE_${map.stageId}`)}
|
||||
<select
|
||||
className={styles.mapSelect}
|
||||
value={serializedMapMode(map)}
|
||||
onChange={(e) => {
|
||||
const [mode, stageId] = e.target.value.split("-");
|
||||
onMapChange({
|
||||
mode: mode as ModeShort,
|
||||
stageId: Number(stageId) as StageId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{modesShort.map((mode) => {
|
||||
const mapsForMode = tournament.ctx.toSetMapPool.filter(
|
||||
(m) => m.mode === mode,
|
||||
);
|
||||
|
||||
if (mapsForMode.length === 0) return null;
|
||||
|
||||
return (
|
||||
<optgroup key={mode} label={t(`game-misc:MODE_LONG_${mode}`)}>
|
||||
{mapsForMode.map((m) => (
|
||||
<option
|
||||
key={serializedMapMode(m)}
|
||||
value={serializedMapMode(m)}
|
||||
>
|
||||
{t(`game-misc:MODE_SHORT_${mode}`)}{" "}
|
||||
{t(`game-misc:STAGE_${m.stageId}`)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<ChevronUpDownIcon className={styles.mapSelectIcon} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
|
@ -1076,7 +1072,7 @@ function MysteryRow({
|
|||
isTiebreaker: boolean;
|
||||
}) {
|
||||
return (
|
||||
<li className="map-list-dialog__map-list-row">
|
||||
<li className={styles.mapListRow}>
|
||||
<div
|
||||
className={clsx("stack horizontal items-center xs text-lighter", {
|
||||
"text-info": isCounterpicks,
|
||||
|
|
@ -1092,3 +1088,58 @@ function MysteryRow({
|
|||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function PatternInputs({
|
||||
patterns,
|
||||
mapCounts,
|
||||
onPatternsChange,
|
||||
}: {
|
||||
patterns: Map<number, string>;
|
||||
mapCounts: BracketMapCounts;
|
||||
onPatternsChange: (patterns: Map<number, string>) => void;
|
||||
}) {
|
||||
const uniqueCounts = new Set<number>();
|
||||
for (const groupCounts of mapCounts.values()) {
|
||||
for (const { count } of groupCounts.values()) {
|
||||
uniqueCounts.add(count);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedCounts = Array.from(uniqueCounts).sort((a, b) => a - b);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="stack horizontal xs items-center">
|
||||
<Label>Mode patterns</Label>
|
||||
<InfoPopover tiny className={styles.infoPopover}>
|
||||
Control how maps are generated. E.g. *SZ* would mean every 2nd mode
|
||||
needs to be Splat Zones. [SZ] means Splat Zones must appear at least
|
||||
once. [SZ!] means means Splat Zones must appear in the maps that are
|
||||
guaranteed to be played (e.g. first two maps of Bo3). You can also
|
||||
combine these patterns. [SZ]*TC* means SZ must appear at least once
|
||||
and that every 2nd mode must be TC.
|
||||
</InfoPopover>
|
||||
</div>
|
||||
<div className="stack horizontal xs">
|
||||
{sortedCounts.map((count) => (
|
||||
<Input
|
||||
key={count}
|
||||
className={styles.patternInput}
|
||||
leftAddon={`Bo${count}`}
|
||||
value={patterns.get(count) ?? ""}
|
||||
placeholder="[TC]*SZ*"
|
||||
onChange={(e) => {
|
||||
const newPatterns = new Map(patterns);
|
||||
if (e.target.value) {
|
||||
newPatterns.set(count, e.target.value);
|
||||
} else {
|
||||
newPatterns.delete(count);
|
||||
}
|
||||
onPatternsChange(newPatterns);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import { useIsMounted } from "~/hooks/useIsMounted";
|
|||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
import type { StageId } from "~/modules/in-game-lists/types";
|
||||
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types";
|
||||
import { nullFilledArray } from "~/utils/arrays";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type {
|
|||
ModeWithStage,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import { mapPickingStyleToModes } from "~/features/tournament/tournament-utils";
|
|||
import type * as PickBan from "~/features/tournament-bracket/core/PickBan";
|
||||
import type { Round } from "~/modules/brackets-model";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
|
||||
import {
|
||||
createTournamentMapList,
|
||||
type TournamentMaplistSource,
|
||||
} from "~/modules/tournament-map-list-generator";
|
||||
import { generateBalancedMapList } from "~/modules/tournament-map-list-generator/balanced-map-list";
|
||||
import { starterMap } from "~/modules/tournament-map-list-generator/starter-map";
|
||||
import type {
|
||||
TournamentMapListMap,
|
||||
TournamentMaplistSource,
|
||||
} from "~/modules/tournament-map-list-generator/types";
|
||||
import { syncCached } from "~/utils/cache.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
|
|
@ -29,6 +29,8 @@ interface ResolveCurrentMapListArgs {
|
|||
stageId: StageId;
|
||||
type: Tables["TournamentMatchPickBanEvent"]["type"];
|
||||
}>;
|
||||
/** Maps that both teams (interleaved) have recently played in the tournament with the most recent being first. */
|
||||
recentlyPlayedMaps: Array<{ mode: ModeShort; stageId: StageId }>;
|
||||
}
|
||||
|
||||
export function resolveMapList(
|
||||
|
|
@ -140,11 +142,12 @@ export function resolveFreshTeamPickedMapList(
|
|||
maps: new MapPool(findMapPoolByTeamId(args.teams[1])),
|
||||
},
|
||||
],
|
||||
recentlyPlayedMaps: args.recentlyPlayedMaps,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return createTournamentMapList({
|
||||
return generateBalancedMapList({
|
||||
count: count(),
|
||||
seed: String(args.matchId),
|
||||
modesIncluded: mapPickingStyleToModes(args.mapPickingStyle),
|
||||
|
|
@ -159,11 +162,12 @@ export function resolveFreshTeamPickedMapList(
|
|||
maps: new MapPool(findMapPoolByTeamId(args.teams[1])),
|
||||
},
|
||||
],
|
||||
recentlyPlayedMaps: args.recentlyPlayedMaps,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error("Failed to create map list. Falling back to default maps.", e);
|
||||
|
||||
return createTournamentMapList({
|
||||
return generateBalancedMapList({
|
||||
count: count(),
|
||||
seed: String(args.matchId),
|
||||
modesIncluded: mapPickingStyleToModes(args.mapPickingStyle),
|
||||
|
|
@ -178,6 +182,7 @@ export function resolveFreshTeamPickedMapList(
|
|||
maps: new MapPool([]),
|
||||
},
|
||||
],
|
||||
recentlyPlayedMaps: args.recentlyPlayedMaps,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
// TODO: tests about DE->SE underground bracket progression
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
/** Map list generation logic for "TO pick" as in the map list is defined beforehand by TO and teams don't pick */
|
||||
|
||||
import * as R from "remeda";
|
||||
import type { Tables, TournamentRoundMaps } from "~/db/tables";
|
||||
import * as MapList from "~/features/map-list-generator/core/MapList";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import type { Round } from "~/modules/brackets-model";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { SENDOUQ_DEFAULT_MAPS } from "~/modules/tournament-map-list-generator/constants";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
|
||||
|
|
@ -22,14 +22,14 @@ export interface GenerateTournamentRoundMaplistArgs {
|
|||
type: Tables["TournamentStage"]["type"];
|
||||
roundsWithPickBan: Set<number>;
|
||||
pickBanStyle: TournamentRoundMaps["pickBan"];
|
||||
flavor: "SZ_FIRST" | null;
|
||||
patterns: Map<number, string>;
|
||||
countType: TournamentRoundMaps["type"];
|
||||
}
|
||||
|
||||
export type TournamentRoundMapList = ReturnType<
|
||||
typeof generateTournamentRoundMaplist
|
||||
>;
|
||||
|
||||
// TODO: future improvement could be slightly biasing against maps that appear in slots that are not guaranteed to be played
|
||||
export function generateTournamentRoundMaplist(
|
||||
args: GenerateTournamentRoundMaplistArgs,
|
||||
) {
|
||||
|
|
@ -41,14 +41,16 @@ export function generateTournamentRoundMaplist(
|
|||
// so in the typical order that people play out the tournament
|
||||
const sortedRounds = sortRounds(filteredRounds, args.type);
|
||||
|
||||
const modeFrequency = new Map<ModeShort, number>();
|
||||
const stageAppearance = new Map<StageId, number>();
|
||||
const comboAppearance = new Map<string, number>();
|
||||
|
||||
// roundId
|
||||
const result: Map<number, Omit<TournamentRoundMaps, "type">> = new Map();
|
||||
|
||||
for (const [iteration, round] of sortedRounds.entries()) {
|
||||
const generator = MapList.generate({
|
||||
mapPool: new MapPool(args.pool),
|
||||
considerGuaranteed: args.countType === "BEST_OF",
|
||||
});
|
||||
generator.next();
|
||||
|
||||
for (const round of sortedRounds.values()) {
|
||||
const count = resolveRoundMapCount(round, args.mapCounts, args.type);
|
||||
|
||||
const amountOfMapsToGenerate = () => {
|
||||
|
|
@ -65,13 +67,10 @@ export function generateTournamentRoundMaplist(
|
|||
|
||||
assertUnreachable(args.pickBanStyle);
|
||||
};
|
||||
const modes = modeOrder({
|
||||
count: amountOfMapsToGenerate(),
|
||||
pool: args.pool,
|
||||
modeFrequency,
|
||||
iteration,
|
||||
flavor: args.flavor,
|
||||
});
|
||||
|
||||
const pattern = !args.roundsWithPickBan.has(round.id)
|
||||
? args.patterns.get(count)
|
||||
: undefined;
|
||||
|
||||
result.set(round.id, {
|
||||
count,
|
||||
|
|
@ -83,16 +82,10 @@ export function generateTournamentRoundMaplist(
|
|||
args.pool.length === 0
|
||||
? null
|
||||
: // TO pick
|
||||
modes.map((mode) => ({
|
||||
mode,
|
||||
stageId: resolveStage(
|
||||
mode,
|
||||
args.pool,
|
||||
stageAppearance,
|
||||
comboAppearance,
|
||||
iteration,
|
||||
),
|
||||
})),
|
||||
generator.next({
|
||||
amount: amountOfMapsToGenerate(),
|
||||
pattern,
|
||||
}).value,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -149,140 +142,3 @@ function resolveRoundMapCount(
|
|||
|
||||
return count;
|
||||
}
|
||||
|
||||
function modeOrder({
|
||||
count,
|
||||
pool,
|
||||
modeFrequency,
|
||||
iteration,
|
||||
flavor,
|
||||
}: {
|
||||
count: number;
|
||||
pool: GenerateTournamentRoundMaplistArgs["pool"];
|
||||
modeFrequency: Map<ModeShort, number>;
|
||||
iteration: number;
|
||||
flavor: GenerateTournamentRoundMaplistArgs["flavor"];
|
||||
}) {
|
||||
const modes = R.unique(pool.map((x) => x.mode));
|
||||
const shuffledModes = R.shuffle(modes);
|
||||
shuffledModes.sort((a, b) => {
|
||||
const aFreq = modeFrequency.get(a) ?? 0;
|
||||
const bFreq = modeFrequency.get(b) ?? 0;
|
||||
|
||||
return aFreq - bFreq;
|
||||
});
|
||||
|
||||
const biasedModes = modesWithSZBiased({ modes: shuffledModes, flavor });
|
||||
|
||||
const result: ModeShort[] = [];
|
||||
|
||||
let currentI = 0;
|
||||
while (result.length < count) {
|
||||
result.push(biasedModes[currentI]);
|
||||
modeFrequency.set(biasedModes[currentI], iteration);
|
||||
currentI++;
|
||||
if (currentI >= biasedModes.length) {
|
||||
currentI = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function modesWithSZBiased({
|
||||
modes,
|
||||
flavor,
|
||||
}: {
|
||||
modes: ModeShort[];
|
||||
flavor?: GenerateTournamentRoundMaplistArgs["flavor"];
|
||||
}) {
|
||||
// map list without SZ
|
||||
if (!modes.includes("SZ")) {
|
||||
return modes;
|
||||
}
|
||||
|
||||
if (flavor === "SZ_FIRST" && modes.includes("SZ")) {
|
||||
const result: ModeShort[] = structuredClone(modes);
|
||||
const szIndex = result.indexOf("SZ");
|
||||
result.splice(szIndex, 1);
|
||||
result.unshift("SZ");
|
||||
return result;
|
||||
}
|
||||
|
||||
// not relevant if there are less than 4 modes
|
||||
if (modes.length < 4) {
|
||||
return modes;
|
||||
}
|
||||
|
||||
if (modes[0] === "SZ" || modes[1] === "SZ" || modes[2] === "SZ") {
|
||||
return modes;
|
||||
}
|
||||
|
||||
const result: ModeShort[] = modes.filter((mode) => mode !== "SZ");
|
||||
|
||||
const szIndex = Math.floor(Math.random() * 3);
|
||||
result.splice(szIndex, 0, "SZ");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const serializedMap = (mode: ModeShort, stage: StageId) => `${mode}-${stage}`;
|
||||
|
||||
function resolveStage(
|
||||
mode: ModeShort,
|
||||
pool: GenerateTournamentRoundMaplistArgs["pool"],
|
||||
stageAppearance: Map<StageId, number>,
|
||||
comboAppearance: Map<string, number>,
|
||||
currentIteration: number,
|
||||
) {
|
||||
const allOptions = pool.filter((x) => x.mode === mode).map((x) => x.stageId);
|
||||
|
||||
let equallyGoodOptionsIgnoringCombo: StageId[] = [];
|
||||
{
|
||||
let earliestAppearance = Number.POSITIVE_INFINITY;
|
||||
for (const option of allOptions) {
|
||||
const appearance = stageAppearance.get(option) ?? -1;
|
||||
if (appearance < earliestAppearance) {
|
||||
earliestAppearance = appearance;
|
||||
equallyGoodOptionsIgnoringCombo = [];
|
||||
}
|
||||
|
||||
if (appearance === earliestAppearance) {
|
||||
equallyGoodOptionsIgnoringCombo.push(option);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bestOptions: StageId[] = [];
|
||||
{
|
||||
let earliestAppearance = Number.POSITIVE_INFINITY;
|
||||
for (const option of equallyGoodOptionsIgnoringCombo) {
|
||||
const appearance = comboAppearance.get(serializedMap(mode, option)) ?? -1;
|
||||
if (appearance < earliestAppearance) {
|
||||
earliestAppearance = appearance;
|
||||
bestOptions = [];
|
||||
}
|
||||
|
||||
if (appearance === earliestAppearance) {
|
||||
bestOptions.push(option);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stage = R.shuffle(equallyGoodOptionsIgnoringCombo)[0];
|
||||
if (typeof stage !== "number") {
|
||||
const fallback = R.shuffle(SENDOUQ_DEFAULT_MAPS[mode])[0];
|
||||
logger.warn(
|
||||
`No stage found for mode ${mode} iteration ${currentIteration}, using fallback ${fallback}`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
stageAppearance.set(stage, currentIteration);
|
||||
comboAppearance.set(
|
||||
serializedMap(mode, stage),
|
||||
(comboAppearance.get(serializedMap(mode, stage)) ?? 0) + 1,
|
||||
);
|
||||
|
||||
return stage;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
|
||||
import { resolveMapList } from "../core/mapList.server";
|
||||
import { findMatchById } from "../queries/findMatchById.server";
|
||||
|
|
@ -34,6 +36,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|||
mapPickingStyle: match.mapPickingStyle,
|
||||
maps: match.roundMaps,
|
||||
pickBanEvents,
|
||||
recentlyPlayedMaps:
|
||||
await TournamentTeamRepository.findRecentlyPlayedMapsByIds({
|
||||
teamIds: [match.opponentOne.id, match.opponentTwo.id],
|
||||
}).catch((error) => {
|
||||
logger.error("Failed to fetch recently played maps", error);
|
||||
return [];
|
||||
}),
|
||||
})
|
||||
: null;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,9 @@ import type { TournamentRoundMaps } from "~/db/tables";
|
|||
import type { TournamentBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
|
||||
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator";
|
||||
import {
|
||||
seededRandom,
|
||||
sourceTypes,
|
||||
} from "~/modules/tournament-map-list-generator";
|
||||
import { sourceTypes } from "~/modules/tournament-map-list-generator/constants";
|
||||
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types";
|
||||
import { seededRandom } from "~/modules/tournament-map-list-generator/utils";
|
||||
import { logger } from "~/utils/logger";
|
||||
import type { TournamentLoaderData } from "../tournament/loaders/to.$id.server";
|
||||
import type { FindMatchById } from "../tournament-bracket/queries/findMatchById.server";
|
||||
|
|
|
|||
|
|
@ -542,26 +542,6 @@
|
|||
width: 280px;
|
||||
}
|
||||
|
||||
.map-list-dialog__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-5);
|
||||
}
|
||||
|
||||
.map-list-dialog__pick-ban-select {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.map-list-dialog__map-list-row {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
margin-block: var(--s-2);
|
||||
list-style: none;
|
||||
min-width: 275px;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.finalize__badge-container {
|
||||
padding: var(--s-2);
|
||||
background-color: black;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type { Transaction } from "kysely";
|
|||
import { sql } from "kysely";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables } from "~/db/tables";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { flatZip } from "~/utils/arrays";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
import invariant from "~/utils/invariant";
|
||||
|
|
@ -357,3 +359,41 @@ export function updateStartingBrackets(
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function findTeamRecentMaps(teamId: number, limit: number) {
|
||||
return db
|
||||
.selectFrom("TournamentMatchGameResult")
|
||||
.innerJoin(
|
||||
"TournamentMatchGameResultParticipant",
|
||||
"TournamentMatchGameResultParticipant.matchGameResultId",
|
||||
"TournamentMatchGameResult.id",
|
||||
)
|
||||
.select([
|
||||
"TournamentMatchGameResult.mode",
|
||||
"TournamentMatchGameResult.stageId",
|
||||
])
|
||||
.where("TournamentMatchGameResultParticipant.tournamentTeamId", "=", teamId)
|
||||
.orderBy("TournamentMatchGameResult.createdAt", "desc")
|
||||
.limit(limit)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findRecentlyPlayedMapsByIds({
|
||||
teamIds,
|
||||
limit = 5,
|
||||
}: {
|
||||
/** Team IDs to retrieve recent maps for */
|
||||
teamIds: [number, number];
|
||||
/** Limit of recent maps to retrieve per team
|
||||
*
|
||||
* @default 5
|
||||
*/
|
||||
limit?: number;
|
||||
}): Promise<Array<{ mode: ModeShort; stageId: StageId }>> {
|
||||
const [teamOneMaps, teamTwoMaps] = await Promise.all([
|
||||
findTeamRecentMaps(teamIds[0], limit),
|
||||
findTeamRecentMaps(teamIds[1], limit),
|
||||
]);
|
||||
|
||||
return flatZip(teamOneMaps, teamTwoMaps);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Tables } from "~/db/tables";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator";
|
||||
import { sourceTypes } from "~/modules/tournament-map-list-generator";
|
||||
import { sourceTypes } from "~/modules/tournament-map-list-generator/constants";
|
||||
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { findRoundsByTournamentId } from "../queries/findRoundsByTournamentId.server";
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type {
|
|||
TournamentData,
|
||||
TournamentDataTeam,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator";
|
||||
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import {
|
||||
teamPage,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import type { Result } from "neverthrow";
|
||||
import { err, ok } from "neverthrow";
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import type { ModeShort, StageId } from "../in-game-lists/types";
|
||||
import { DEFAULT_MAP_POOL } from "./constants";
|
||||
import type {
|
||||
|
|
@ -12,10 +15,39 @@ import { seededRandom } from "./utils";
|
|||
type ModeWithStageAndScore = TournamentMapListMap & { score: number };
|
||||
|
||||
const OPTIMAL_MAPLIST_SCORE = 0;
|
||||
const MAX_RECURSION_DEPTH = 5_000;
|
||||
|
||||
export function createTournamentMapList(
|
||||
export function generateBalancedMapList(
|
||||
input: TournamentMaplistInput,
|
||||
): Array<TournamentMapListMap> {
|
||||
const result = generateWithInput(input);
|
||||
|
||||
if (result.isErr()) {
|
||||
if (
|
||||
result.error === "MAX_RECURSION_DEPTH_EXCEEDED" &&
|
||||
input.recentlyPlayedMaps
|
||||
) {
|
||||
logger.error(
|
||||
`Failed to generate map list with recently played maps consideration. Retrying without recently played maps. Team IDs: ${input.teams.map((t) => t.id).join(", ")}`,
|
||||
);
|
||||
return generateWithInput({
|
||||
...input,
|
||||
recentlyPlayedMaps: undefined,
|
||||
})._unsafeUnwrap();
|
||||
}
|
||||
|
||||
throw new Error(`couldn't generate maplist: ${result.error}`);
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function generateWithInput(
|
||||
input: TournamentMaplistInput,
|
||||
): Result<
|
||||
Array<TournamentMapListMap>,
|
||||
"MAX_RECURSION_DEPTH_EXCEEDED" | "COULD_NOT_GENERATE_MAPLIST"
|
||||
> {
|
||||
validateInput(input);
|
||||
|
||||
const { shuffle } = seededRandom(input.seed);
|
||||
|
|
@ -25,8 +57,12 @@ export function createTournamentMapList(
|
|||
score: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
const usedStages = new Set<number>();
|
||||
let depth = 0;
|
||||
|
||||
const backtrack = () => {
|
||||
const backtrack = (): boolean => {
|
||||
if (++depth > MAX_RECURSION_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
invariant(mapList.length <= input.count, "mapList.length > input.count");
|
||||
const mapListScore = rateMapList();
|
||||
if (typeof mapListScore === "number" && mapListScore < bestMapList.score) {
|
||||
|
|
@ -36,7 +72,7 @@ export function createTournamentMapList(
|
|||
|
||||
// There can't be better map list than this
|
||||
if (bestMapList.score === OPTIMAL_MAPLIST_SCORE) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const stageList =
|
||||
|
|
@ -53,18 +89,22 @@ export function createTournamentMapList(
|
|||
mapList.push(stage);
|
||||
usedStages.add(i);
|
||||
|
||||
backtrack();
|
||||
const continueSearch = backtrack();
|
||||
if (!continueSearch) return false;
|
||||
|
||||
usedStages.delete(i);
|
||||
mapList.pop();
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
backtrack();
|
||||
const success = backtrack();
|
||||
|
||||
if (bestMapList.maps) return bestMapList.maps;
|
||||
if (!success) return err("MAX_RECURSION_DEPTH_EXCEEDED");
|
||||
if (bestMapList.maps) return ok(bestMapList.maps);
|
||||
|
||||
throw new Error("couldn't generate maplist");
|
||||
return err("COULD_NOT_GENERATE_MAPLIST");
|
||||
|
||||
function resolveCommonStages() {
|
||||
const sorted = input.teams
|
||||
|
|
@ -380,11 +420,28 @@ export function createTournamentMapList(
|
|||
score += 1;
|
||||
}
|
||||
|
||||
const scoreSum = mapList.reduce((acc, cur) => acc + cur.score, 0);
|
||||
if (scoreSum !== 0) {
|
||||
const fairnessBalance = mapList.reduce((acc, cur) => acc + cur.score, 0);
|
||||
if (fairnessBalance !== 0) {
|
||||
score += 100;
|
||||
}
|
||||
|
||||
if (input.recentlyPlayedMaps) {
|
||||
for (const map of mapList) {
|
||||
const recentIndex = input.recentlyPlayedMaps.findIndex(
|
||||
(recent) =>
|
||||
recent.stageId === map.stageId && recent.mode === map.mode,
|
||||
);
|
||||
|
||||
if (recentIndex !== -1) {
|
||||
const recencyPenalty = Math.max(
|
||||
10 - Math.floor(recentIndex / 2) * 2,
|
||||
0,
|
||||
);
|
||||
score += recencyPenalty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
|
|
@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
|||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { rankedModesShort } from "../in-game-lists/modes";
|
||||
import type { RankedModeShort } from "../in-game-lists/types";
|
||||
import { createTournamentMapList } from ".";
|
||||
import { generateBalancedMapList } from "./balanced-map-list";
|
||||
import { DEFAULT_MAP_POOL } from "./constants";
|
||||
import type { TournamentMaplistInput } from "./types";
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ const generateMaps = ({
|
|||
modesIncluded = [...rankedModesShort],
|
||||
followModeOrder = false,
|
||||
}: Partial<TournamentMaplistInput> = {}) => {
|
||||
return createTournamentMapList({
|
||||
return generateBalancedMapList({
|
||||
count,
|
||||
seed,
|
||||
teams,
|
||||
|
|
@ -790,3 +790,74 @@ describe("TournamentMapListGeneratorOneMode", () => {
|
|||
).toThrowError("Duplicate map");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Recently played maps", () => {
|
||||
test("Avoids recently played maps when possible", () => {
|
||||
const recentlyPlayedMaps = [
|
||||
{ mode: "SZ" as const, stageId: 4 as const },
|
||||
{ mode: "TC" as const, stageId: 5 as const },
|
||||
];
|
||||
|
||||
const mapList = generateMaps({
|
||||
seed: "recent-test",
|
||||
recentlyPlayedMaps,
|
||||
});
|
||||
|
||||
const hasRecentMap = mapList.some((map) =>
|
||||
recentlyPlayedMaps.some(
|
||||
(recent) => recent.mode === map.mode && recent.stageId === map.stageId,
|
||||
),
|
||||
);
|
||||
|
||||
expect(hasRecentMap).toBe(false);
|
||||
});
|
||||
|
||||
test("Works correctly with no recently played maps", () => {
|
||||
const mapList = generateMaps({
|
||||
recentlyPlayedMaps: [],
|
||||
});
|
||||
|
||||
expect(mapList.length).toBe(5);
|
||||
});
|
||||
|
||||
test("Penalties decrease for maps further back in history", () => {
|
||||
const recentlyPlayedMaps = [
|
||||
{ mode: "SZ" as const, stageId: 4 as const },
|
||||
{ mode: "SZ" as const, stageId: 5 as const },
|
||||
{ mode: "TC" as const, stageId: 5 as const },
|
||||
{ mode: "TC" as const, stageId: 6 as const },
|
||||
{ mode: "RM" as const, stageId: 7 as const },
|
||||
{ mode: "RM" as const, stageId: 8 as const },
|
||||
];
|
||||
|
||||
const mapListWithRecent = generateMaps({
|
||||
seed: "history-test",
|
||||
recentlyPlayedMaps,
|
||||
});
|
||||
|
||||
const hasVeryRecentMap = mapListWithRecent.some((map) =>
|
||||
recentlyPlayedMaps
|
||||
.slice(0, 2)
|
||||
.some(
|
||||
(recent) =>
|
||||
recent.mode === map.mode && recent.stageId === map.stageId,
|
||||
),
|
||||
);
|
||||
|
||||
expect(hasVeryRecentMap).toBe(false);
|
||||
});
|
||||
|
||||
test("Still generates valid maplist even with many recently played maps", () => {
|
||||
const recentlyPlayedMaps = [
|
||||
...team1Picks.stageModePairs,
|
||||
...team2Picks.stageModePairs,
|
||||
];
|
||||
|
||||
const mapList = generateMaps({
|
||||
seed: "many-recent",
|
||||
recentlyPlayedMaps,
|
||||
});
|
||||
|
||||
expect(mapList.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export { generateBalancedMapList } from "./balanced-map-list";
|
||||
export { sourceTypes } from "./constants";
|
||||
export { createTournamentMapList } from "./tournament-map-list";
|
||||
export type {
|
||||
BracketType,
|
||||
TournamentMapListMap,
|
||||
|
|
|
|||
|
|
@ -6,27 +6,38 @@ import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
|||
import { logger } from "~/utils/logger";
|
||||
import { modesShort } from "../in-game-lists/modes";
|
||||
import type { ModeWithStage } from "../in-game-lists/types";
|
||||
import {
|
||||
seededRandom,
|
||||
type TournamentMapListMap,
|
||||
type TournamentMaplistInput,
|
||||
} from ".";
|
||||
import type { TournamentMapListMap, TournamentMaplistInput } from "./types";
|
||||
import { seededRandom } from "./utils";
|
||||
|
||||
type StarterMapArgs = Pick<
|
||||
TournamentMaplistInput,
|
||||
"modesIncluded" | "tiebreakerMaps" | "seed" | "teams"
|
||||
"modesIncluded" | "tiebreakerMaps" | "seed" | "teams" | "recentlyPlayedMaps"
|
||||
>;
|
||||
|
||||
export function starterMap(args: StarterMapArgs): Array<TournamentMapListMap> {
|
||||
const { shuffle } = seededRandom(args.seed);
|
||||
|
||||
const commonMap = resolveRandomCommonMap(args.teams, shuffle);
|
||||
const isRecentlyPlayed = (map: ModeWithStage) => {
|
||||
return Boolean(
|
||||
args.recentlyPlayedMaps?.some(
|
||||
(recent) => recent.stageId === map.stageId && recent.mode === map.mode,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const commonMap = resolveRandomCommonMap(
|
||||
args.teams,
|
||||
shuffle,
|
||||
isRecentlyPlayed,
|
||||
);
|
||||
if (commonMap) {
|
||||
return [{ ...commonMap, source: "BOTH" }];
|
||||
}
|
||||
|
||||
if (!args.tiebreakerMaps.isEmpty()) {
|
||||
const randomTiebreaker = shuffle(args.tiebreakerMaps.stageModePairs)[0];
|
||||
const tiebreakers = shuffle(args.tiebreakerMaps.stageModePairs);
|
||||
const nonRecentTiebreaker = tiebreakers.find((tb) => !isRecentlyPlayed(tb));
|
||||
const randomTiebreaker = nonRecentTiebreaker ?? tiebreakers[0];
|
||||
|
||||
return [
|
||||
{
|
||||
|
|
@ -45,6 +56,20 @@ export function starterMap(args: StarterMapArgs): Array<TournamentMapListMap> {
|
|||
.flatMap((mode) => stageIds.map((stageId) => ({ mode, stageId }))),
|
||||
);
|
||||
|
||||
for (const map of allAvailableMaps) {
|
||||
if (
|
||||
!args.teams.some((team) =>
|
||||
team.maps.stageModePairs.some(
|
||||
(teamMap) =>
|
||||
teamMap.mode === map.mode && teamMap.stageId === map.stageId,
|
||||
),
|
||||
) &&
|
||||
!isRecentlyPlayed(map)
|
||||
) {
|
||||
return [{ ...map, source: "DEFAULT" }];
|
||||
}
|
||||
}
|
||||
|
||||
for (const map of allAvailableMaps) {
|
||||
if (
|
||||
!args.teams.some((team) =>
|
||||
|
|
@ -58,7 +83,7 @@ export function starterMap(args: StarterMapArgs): Array<TournamentMapListMap> {
|
|||
}
|
||||
}
|
||||
|
||||
logger.warn("startedMap: fallback choice");
|
||||
logger.warn("starterMap: fallback choice");
|
||||
|
||||
return [{ ...allAvailableMaps[0], source: "DEFAULT" }];
|
||||
}
|
||||
|
|
@ -66,10 +91,23 @@ export function starterMap(args: StarterMapArgs): Array<TournamentMapListMap> {
|
|||
function resolveRandomCommonMap(
|
||||
teams: StarterMapArgs["teams"],
|
||||
shuffle: <T>(o: T[]) => T[],
|
||||
isRecentlyPlayed: (map: ModeWithStage) => boolean,
|
||||
): ModeWithStage | null {
|
||||
const teamOnePicks = shuffle(teams[0].maps.stageModePairs);
|
||||
const teamTwoPicks = shuffle(teams[1].maps.stageModePairs);
|
||||
|
||||
for (const map of teamOnePicks) {
|
||||
for (const map2 of teamTwoPicks) {
|
||||
if (
|
||||
map.mode === map2.mode &&
|
||||
map.stageId === map2.stageId &&
|
||||
!isRecentlyPlayed(map)
|
||||
) {
|
||||
return map;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const map of teamOnePicks) {
|
||||
for (const map2 of teamTwoPicks) {
|
||||
if (map.mode === map2.mode && map.stageId === map2.stageId) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export interface TournamentMaplistInput {
|
|||
tiebreakerMaps: MapPool;
|
||||
modesIncluded: ModeShort[];
|
||||
followModeOrder?: boolean;
|
||||
recentlyPlayedMaps?: ModeWithStage[];
|
||||
}
|
||||
|
||||
export type TournamentMaplistSource = number | (typeof sourceTypes)[number];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { diff, mostPopularArrayElement } from "./arrays";
|
||||
import { diff, flatZip, mostPopularArrayElement } from "./arrays";
|
||||
|
||||
describe("diff", () => {
|
||||
it("should return elements in arr2 but not in arr1", () => {
|
||||
|
|
@ -69,3 +69,54 @@ describe("mostPopularArrayElement", () => {
|
|||
expect(result).toBe("only");
|
||||
});
|
||||
});
|
||||
|
||||
describe("flatZip", () => {
|
||||
it("should zip arrays of equal length by alternating elements", () => {
|
||||
const arr1 = [1, 2, 3];
|
||||
const arr2 = ["a", "b", "c"];
|
||||
const result = flatZip(arr1, arr2);
|
||||
expect(result).toEqual([1, "a", 2, "b", 3, "c"]);
|
||||
});
|
||||
|
||||
it("should zip and append remaining elements when second array is longer", () => {
|
||||
const arr1 = [1, 2];
|
||||
const arr2 = ["a", "b", "c"];
|
||||
const result = flatZip(arr1, arr2);
|
||||
expect(result).toEqual([1, "a", 2, "b", "c"]);
|
||||
});
|
||||
|
||||
it("should zip and append remaining elements when first array is longer", () => {
|
||||
const arr1 = [1, 2, 3, 4];
|
||||
const arr2 = ["a", "b"];
|
||||
const result = flatZip(arr1, arr2);
|
||||
expect(result).toEqual([1, "a", 2, "b", 3, 4]);
|
||||
});
|
||||
|
||||
it("should handle empty first array", () => {
|
||||
const arr1: number[] = [];
|
||||
const arr2 = ["a", "b"];
|
||||
const result = flatZip(arr1, arr2);
|
||||
expect(result).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("should handle empty second array", () => {
|
||||
const arr1 = [1, 2];
|
||||
const arr2: string[] = [];
|
||||
const result = flatZip(arr1, arr2);
|
||||
expect(result).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("should handle both empty arrays", () => {
|
||||
const arr1: number[] = [];
|
||||
const arr2: string[] = [];
|
||||
const result = flatZip(arr1, arr2);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle single element arrays", () => {
|
||||
const arr1 = [1];
|
||||
const arr2 = ["a"];
|
||||
const result = flatZip(arr1, arr2);
|
||||
expect(result).toEqual([1, "a"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -110,3 +110,34 @@ export function mostPopularArrayElement<T>(arr: T[]): T | null {
|
|||
|
||||
return mostPopularElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely zips two arrays together by alternating elements. If arrays have different lengths,
|
||||
* zips as much as possible, then appends remaining elements from the longer array.
|
||||
*
|
||||
* @param arr1 - The first array
|
||||
* @param arr2 - The second array
|
||||
* @returns Alternating elements from both arrays: [arr1[0], arr2[0], arr1[1], arr2[1], ...]
|
||||
* followed by any remaining elements from the longer array
|
||||
*
|
||||
* @example
|
||||
* zipSafe([1, 2], ['a', 'b']) // [1, 'a', 2, 'b']
|
||||
* zipSafe([1, 2], ['a', 'b', 'c']) // [1, 'a', 2, 'b', 'c']
|
||||
* zipSafe([1, 2, 3], ['a', 'b']) // [1, 'a', 2, 'b', 3]
|
||||
*/
|
||||
export function flatZip<T, U>(arr1: T[], arr2: U[]): Array<T | U> {
|
||||
const result: Array<T | U> = [];
|
||||
const minLength = Math.min(arr1.length, arr2.length);
|
||||
|
||||
for (let i = 0; i < minLength; i++) {
|
||||
result.push(arr1[i], arr2[i]);
|
||||
}
|
||||
|
||||
if (arr1.length > minLength) {
|
||||
result.push(...arr1.slice(minLength));
|
||||
} else if (arr2.length > minLength) {
|
||||
result.push(...arr2.slice(minLength));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -661,7 +661,7 @@ test.describe("Tournament bracket", () => {
|
|||
|
||||
await page.getByTestId("brackets-tab").click();
|
||||
await page.getByTestId("finalize-bracket-button").click();
|
||||
await page.getByLabel("Count", { exact: true }).selectOption("5");
|
||||
await page.getByTestId("increase-map-count-button").first().click();
|
||||
await page.getByTestId("confirm-finalize-bracket-button").click();
|
||||
|
||||
await page.locator('[data-match-id="1"]').click();
|
||||
|
|
@ -970,10 +970,9 @@ test.describe("Tournament bracket", () => {
|
|||
|
||||
await page.getByTestId("prepare-maps-button").click();
|
||||
|
||||
await page.getByRole("button", { name: "Unlink" }).click();
|
||||
await page.getByTestId("unlink-finals-3rd-place-match-button").click();
|
||||
|
||||
await page.getByRole("button", { name: "Edit" }).last().click();
|
||||
await page.getByLabel("Bo9").click();
|
||||
await page.getByTestId("increase-map-count-button").last().click();
|
||||
|
||||
await page.getByTestId("confirm-finalize-bracket-button").click();
|
||||
|
||||
|
|
@ -987,7 +986,9 @@ test.describe("Tournament bracket", () => {
|
|||
await page.getByTestId("prepare-maps-button").click();
|
||||
|
||||
// link button should be visible because we unlinked and made finals and third place match maps different earlier
|
||||
expect(page.getByRole("button", { name: "Link" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("link-finals-3rd-place-match-button"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
for (const pickBan of ["COUNTERPICK", "BAN_2"]) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user