Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,803 @@
import { describe, expect, test } from "vitest";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type { StageId } from "~/modules/in-game-lists/types";
import { unwrap } from "~/utils/result";
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", () => {
test.each([1, 3, 5])("returns an array of %d items", (amount) => {
const gen = initGenerator();
expect(gen.next({ amount }).value).toHaveLength(amount);
});
test("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);
}
});
test("contains only unique maps, when possible", () => {
const gen = initGenerator();
const maps = gen.next({ amount: 3 }).value;
expect(maps).toHaveLength(
new Set(maps.map((m) => MapList.modeStageKey(m.mode, m.stageId))).size,
);
});
test("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 });
}
});
test("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);
}
});
test("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);
});
test("handles empty map pool", () => {
const gen = initGenerator(MapPool.EMPTY);
const maps = gen.next({ amount: 3 }).value;
expect(maps).toHaveLength(0);
});
test("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");
}
});
test("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");
});
test("guarantees a must-include even when the pattern's only ANY slot is in the back half", () => {
for (let i = 0; i < 10; i++) {
const gen = initGenerator();
const maps = gen.next({ amount: 3, pattern: "[RM!]SZTC*" }).value;
expect(maps).toHaveLength(3);
expect(maps.map((map) => map.mode)).toContain("RM");
}
});
test("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");
});
test("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");
});
test("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");
}
});
test("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");
}
});
test("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");
}
});
test("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");
}
});
test("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");
});
test("places a non-guaranteed must-include when the pattern has no flexible slots but more maps than pattern parts", () => {
const gen = initGenerator();
const maps = gen.next({ amount: 5, pattern: "[RM]SZTC" }).value;
expect(maps.map((map) => map.mode)).toContain("RM");
});
test("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
});
test("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);
});
test("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");
});
test("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", () => {
test("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);
});
test("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 });
});
test("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);
});
test("cycles a single mode order continuously across sets", () => {
// 5 modes, Bo3 sets -> the order keeps rolling without resetting
const gen = initGenerator();
const first = gen.next({ amount: 3 }).value!.map((m) => m.mode);
const second = gen.next({ amount: 3 }).value!.map((m) => m.mode);
// set 2 continues where set 1 left off (positions 3, 4, 0)
expect(second[2]).toBe(first[0]);
});
test("uses the same mode order when a set spans the whole rotation", () => {
// 5 modes, Bo5 sets -> each set is exactly one full rotation
const gen = initGenerator();
const first = gen.next({ amount: 5 }).value!.map((m) => m.mode);
const second = gen.next({ amount: 5 }).value!.map((m) => m.mode);
expect(second).toEqual(first);
});
test("keeps cycling other modes across sets when a must-include pattern is set", () => {
// A single generator drives every bracket round. With a `[SZ]`
// must-include pattern the non-SZ slots should keep advancing through
// the mode order across rounds instead of replaying the order's prefix
// every set, otherwise modes in the order's tail (here RM) are starved.
const gen = MapList.generate({
mapPool: ALL_MODES_TEST_MAP_POOL,
modeOrder: ["SZ", "TC", "CB", "RM", "TW"],
});
gen.next();
const modesSeen: string[] = [];
for (let round = 0; round < 4; round++) {
const maps = gen.next({ amount: 3, pattern: "[SZ]" }).value;
modesSeen.push(...maps.map((m) => m.mode));
}
expect(modesSeen).toContain("RM");
});
test("keeps cycling other modes across sets when a positional pattern is set", () => {
// With a `*SZ*` pattern and Bo3 sets the two ANY slots are filled from
// the non-SZ modes (TC, RM, CB). Because the cycle offset advances by the
// full set size (3) instead of the slots actually consumed, every round
// resolves the ANY slots to the same modes and the remaining mode (here
// RM) never appears in the whole bracket.
const gen = MapList.generate({
mapPool: new MapPool({
TW: [],
SZ: [4, 5, 6],
TC: [7, 8, 9],
RM: [10, 11, 12],
CB: [13, 14, 15],
}),
modeOrder: ["SZ", "TC", "RM", "CB"],
});
gen.next();
const modesSeen = new Set<string>();
for (let round = 0; round < 10; round++) {
const maps = gen.next({ amount: 3, pattern: "*SZ*" }).value;
for (const map of maps) {
modesSeen.add(map.mode);
}
}
expect([...modesSeen].sort()).toEqual(["CB", "RM", "SZ", "TC"]);
});
test("advances the cycle by the ANY slots consumed when a positional pattern is set", () => {
const gen = MapList.generate({
mapPool: new MapPool({
TW: [],
SZ: [4, 5, 6],
TC: [7, 8, 9],
RM: [10, 11, 12],
CB: [13, 14, 15],
}),
modeOrder: ["SZ", "TC", "RM", "CB"],
});
gen.next();
const nextModes = () =>
gen.next({ amount: 3, pattern: "*SZ*" }).value.map((m) => m.mode);
expect(nextModes()).toEqual(["TC", "SZ", "RM"]);
expect(nextModes()).toEqual(["CB", "SZ", "TC"]);
expect(nextModes()).toEqual(["RM", "SZ", "CB"]);
});
test("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);
});
test("finds unique maps when possible (All 4 One #50 bug)", () => {
const mapPool = new MapPool({
TW: [],
SZ: [1, 2, 3, 4, 5, 6, 7],
TC: [],
RM: [],
CB: [],
});
for (let i = 0; i < 50; i++) {
const gen = MapList.generate({
mapPool,
considerGuaranteed: true,
});
gen.next();
gen.next({ amount: 5 });
const maps = gen.next({ amount: 5 }).value;
const stageIds = maps.map((m) => m.stageId);
const uniqueStageIds = new Set(stageIds);
expect(uniqueStageIds.size).toBe(5);
}
});
test("finds unique maps when possible (All 4 One #51 bug)", () => {
const mapPool = new MapPool({
TW: [],
SZ: [1, 2, 3, 4, 5, 6, 7],
TC: [],
RM: [],
CB: [],
});
for (let i = 0; i < 50; i++) {
const gen = MapList.generate({
mapPool,
});
gen.next();
const maps = gen.next({ amount: 7 }).value;
const stageIds = maps.map((m) => m.stageId);
const uniqueStageIds = new Set(stageIds);
expect(uniqueStageIds.size).toBe(7);
}
});
test("applies different weight penalties based on guaranteed positions when considerGuaranteed is true", () => {
const mapPool = new MapPool({
TW: [],
SZ: [1, 2, 3, 4, 5],
TC: [],
RM: [],
CB: [],
});
let slot4RepeatsWithFlag = 0;
let slot4RepeatsWithoutFlag = 0;
for (let i = 0; i < 50; i++) {
const genWith = MapList.generate({ mapPool, considerGuaranteed: true });
genWith.next();
const genWithout = MapList.generate({
mapPool,
considerGuaranteed: false,
});
genWithout.next();
const firstWith = genWith.next({ amount: 5 }).value;
const secondWith = genWith.next({ amount: 5 }).value;
const firstWithout = genWithout.next({ amount: 5 }).value;
const secondWithout = genWithout.next({ amount: 5 }).value;
if (
[firstWith[3].stageId, firstWith[4].stageId].includes(
secondWith[0].stageId,
)
) {
slot4RepeatsWithFlag++;
}
if (
[firstWithout[3].stageId, firstWithout[4].stageId].includes(
secondWithout[0].stageId,
)
) {
slot4RepeatsWithoutFlag++;
}
}
expect(slot4RepeatsWithFlag).toBeGreaterThan(slot4RepeatsWithoutFlag);
});
});
});
describe("MapList.parsePattern()", () => {
test("parses a simple pattern", () => {
expect(unwrap(MapList.parsePattern("SZ*TC"))).toEqual({
pattern: ["SZ", "ANY", "TC"],
});
});
test("handles extra spaces", () => {
expect(unwrap(MapList.parsePattern(" * SZ "))).toEqual({
pattern: ["ANY", "SZ"],
});
});
test("handles the same mode twice in pattern", () => {
expect(unwrap(MapList.parsePattern("SZ*SZ"))).toEqual({
pattern: ["SZ", "ANY", "SZ"],
});
});
test("returns error on invalid mode", () => {
expect(MapList.parsePattern("*INVALID*").ok).toBe(false);
});
test("if starts and ends with ANY, the ending ANY is dropped", () => {
expect(unwrap(MapList.parsePattern("*SZ*"))).toEqual({
pattern: ["ANY", "SZ"],
});
});
test("parses a mustInclude mode", () => {
expect(unwrap(MapList.parsePattern("[SZ]"))).toEqual({
mustInclude: [{ mode: "SZ", isGuaranteed: false }],
pattern: [],
});
});
test("parses a guaranteed mustInclude mode", () => {
expect(unwrap(MapList.parsePattern("[SZ!]"))).toEqual({
mustInclude: [{ mode: "SZ", isGuaranteed: true }],
pattern: [],
});
});
test("parses a complex pattern", () => {
expect(unwrap(MapList.parsePattern(" * [SZ] * TC [TW]"))).toEqual({
mustInclude: [
{ mode: "TW", isGuaranteed: false },
{ mode: "SZ", isGuaranteed: false },
],
pattern: ["ANY", "ANY", "TC"],
});
});
test("ignores repeated must include mode", () => {
expect(unwrap(MapList.parsePattern("[SZ][SZ]"))).toEqual({
mustInclude: [{ mode: "SZ", isGuaranteed: false }],
pattern: [],
});
});
test("parses an empty pattern", () => {
expect(unwrap(MapList.parsePattern(""))).toEqual({
pattern: [],
});
});
test("returns error when pattern is too long", () => {
const longPattern = "a".repeat(51);
const result = MapList.parsePattern(longPattern);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe("pattern too long");
}
});
test("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.",
).ok,
).toBe(false);
});
});
describe("MapList.generate() with initialWeights", () => {
test("accepts initialWeights parameter without errors", () => {
const mapPool = new MapPool({
SZ: [1, 2, 3],
TC: [4, 5],
RM: [],
CB: [],
TW: [],
});
const initialWeights = new Map<string, number>();
initialWeights.set("SZ-1", 100);
initialWeights.set("TC-44", -10);
const gen = MapList.generate({ mapPool, initialWeights });
gen.next();
const maps = gen.next({ amount: 3 }).value;
expect(maps).toHaveLength(3);
expect(maps.every((m) => mapPool.has(m))).toBe(true);
});
test("handles empty initialWeights", () => {
const mapPool = new MapPool({
SZ: [1, 2],
TC: [],
RM: [],
CB: [],
TW: [],
});
const gen = MapList.generate({ mapPool, initialWeights: new Map() });
gen.next();
const maps = gen.next({ amount: 2 }).value;
expect(maps).toHaveLength(2);
});
test("handles undefined initialWeights", () => {
const mapPool = new MapPool({
SZ: [1, 2],
TC: [],
RM: [],
CB: [],
TW: [],
});
const gen = MapList.generate({ mapPool });
gen.next();
const maps = gen.next({ amount: 2 }).value;
expect(maps).toHaveLength(2);
});
test("initialWeights affect stage selection", () => {
const mapPool = new MapPool({
SZ: [1, 2, 3, 4, 5],
TC: [],
RM: [],
CB: [],
TW: [],
});
const initialWeights = new Map<string, number>();
initialWeights.set("SZ-1", 1);
initialWeights.set("SZ-2", -1);
initialWeights.set("SZ-3", -1);
initialWeights.set("SZ-4", -1);
initialWeights.set("SZ-5", -1);
const gen = MapList.generate({ mapPool, initialWeights });
gen.next();
const maps = gen.next({ amount: 3 }).value;
expect(maps[0].stageId).toBe(1);
});
});
describe("MapList.resume()", () => {
const POOL = new MapPool({
TW: [],
SZ: [1, 2, 3],
TC: [4, 5, 6],
RM: [7, 8, 9],
CB: [10, 11, 12],
});
function nextMap(
history: Array<{ mode: "SZ" | "TC" | "RM" | "CB"; stageId: StageId }>,
) {
const gen = MapList.resume({ mapPool: POOL, history });
gen.next();
const result = gen.next({ amount: 1 }).value;
return result![0];
}
test("starts with the pool's first mode when history is empty", () => {
for (let i = 0; i < 20; i++) {
expect(nextMap([]).mode).toBe("SZ");
}
});
test("rotates through modes in pool order across history length", () => {
expect(nextMap([{ mode: "SZ", stageId: 1 }]).mode).toBe("TC");
expect(
nextMap([
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 4 },
]).mode,
).toBe("RM");
expect(
nextMap([
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 4 },
{ mode: "RM", stageId: 7 },
]).mode,
).toBe("CB");
});
test("wraps the mode order back to the start after a full rotation", () => {
const history = [
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 4 },
{ mode: "RM", stageId: 7 },
{ mode: "CB", stageId: 10 },
] as const;
expect(nextMap([...history]).mode).toBe("SZ");
});
test("avoids already-played (mode, stage) pairs", () => {
const history = [
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 4 },
{ mode: "RM", stageId: 7 },
{ mode: "CB", stageId: 10 },
] as const;
for (let i = 0; i < 30; i++) {
const next = nextMap([...history]);
expect(next.mode).toBe("SZ");
expect(next.stageId).not.toBe(1);
}
});
test("rotates only through modes present in the pool", () => {
const threeModePool = new MapPool({
TW: [],
SZ: [1, 2, 3],
TC: [4, 5, 6],
RM: [7, 8, 9],
CB: [],
});
const pickMode = (
history: Array<{ mode: "SZ" | "TC" | "RM"; stageId: StageId }>,
) => {
const gen = MapList.resume({ mapPool: threeModePool, history });
gen.next();
return gen.next({ amount: 1 }).value![0].mode;
};
expect(pickMode([])).toBe("SZ");
expect(pickMode([{ mode: "SZ", stageId: 1 }])).toBe("TC");
expect(
pickMode([
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 4 },
]),
).toBe("RM");
expect(
pickMode([
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 4 },
{ mode: "RM", stageId: 7 },
]),
).toBe("SZ");
});
test("avoids the just-played stage when alternatives exist, even across modes", () => {
const sharedPool = new MapPool({
TW: [],
SZ: [1, 2, 3, 4, 5],
TC: [1, 2, 3, 4, 5],
RM: [],
CB: [],
});
for (let i = 0; i < 50; i++) {
const gen = MapList.resume({
mapPool: sharedPool,
history: [{ mode: "SZ", stageId: 1 }],
});
gen.next();
const next = gen.next({ amount: 1 }).value![0];
expect(next.mode).toBe("TC");
expect(
next.stageId,
"same stage picked back-to-back across modes",
).not.toBe(1);
}
});
});

View File

@@ -0,0 +1,473 @@
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 { err, ok, unwrapOr } from "~/utils/result";
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>;
}
/**
* Creates a unique key for a stage-mode combination.
* @example
* stageModeKey("SZ", 1); // "1-SZ"
*/
export function modeStageKey(mode: ModeShort, stageId: StageId): string {
return `${mode}-${stageId}`;
}
/**
* Generates map lists that avoid repeating stages and optionally allows providing mode pattern.
*
* @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;
/** Initial weights for specific stage-mode combinations. Key format: `${mode}-${stageId}` (generate via `MapList.stageModeKey`). Negative weights can be used to deprioritize certain maps. */
initialWeights?: Map<string, number>;
/** Skip the ensureMinimumCandidates check that inflates weights to ensure half the pool is available. Useful when initial weights already define the desired selection. */
skipEnsureMinimumCandidates?: boolean;
/** Fixed mode order — when set, skips the random shuffle and uses only this order. Intended for `resume`. */
modeOrder?: ModeShort[];
/** Initial weights for stages (mode-agnostic). Used by `resume` to carry over stage-level penalties from history. */
initialStageWeights?: Map<StageId, number>;
}): Generator<Array<ModeWithStage>, Array<ModeWithStage>, GenerateNext> {
if (args.mapPool.isEmpty()) {
while (true) yield [];
}
const modes = args.mapPool.modes;
const { stageWeights, stageModeWeights } = initializeWeights(
modes,
args.mapPool.parsed,
args.initialWeights,
args.initialStageWeights,
);
const modeOrder = args.modeOrder ?? R.shuffle(modes);
let modePosition = 0;
const firstArgs = yield [];
let amount = firstArgs.amount;
let pattern = firstArgs.pattern
? unwrapOr(parsePattern(firstArgs.pattern), null)
: null;
while (true) {
const result: ModeWithStage[] = [];
const { currentModeOrder, modesConsumed } = pattern
? modifyModeOrderByPattern(modeOrder, pattern, amount, modePosition)
: {
currentModeOrder: Array.from(
{ length: amount },
(_, i) => modeOrder[(modePosition + i) % modeOrder.length],
),
modesConsumed: amount,
};
if (!args.skipEnsureMinimumCandidates) {
ensureMinimumCandidates({
mapPool: args.mapPool,
stageWeights,
stageModeWeights,
});
}
for (let i = 0; i < amount; i++) {
const mode = currentModeOrder[i % currentModeOrder.length];
const possibleStages = args.mapPool.parsed[mode];
const isNotGuaranteedToBePlayed = args.considerGuaranteed
? Math.ceil(amount / 2) <= i
: false;
const stageId = selectStageWeighted({
possibleStages,
mode,
stageWeights,
stageModeWeights,
});
result.push({ mode, stageId });
for (const [key, value] of stageWeights.entries()) {
stageWeights.set(key, value + 1);
}
for (const [key, value] of stageModeWeights.entries()) {
stageModeWeights.set(key, value + 1);
}
const stageWeightPenalty = isNotGuaranteedToBePlayed
? -2
: -Math.max(5, amount);
const stageModeWeightPenalty = args.mapPool.modes.length > 1 ? -10 : 0;
stageWeights.set(stageId, stageWeightPenalty);
stageModeWeights.set(modeStageKey(mode, stageId), stageModeWeightPenalty);
}
modePosition += modesConsumed;
const nextArgs = yield result;
amount = nextArgs.amount;
pattern = nextArgs.pattern
? unwrapOr(parsePattern(nextArgs.pattern), null)
: null;
}
}
/**
* Returns a generator primed to continue map selection after the given history.
*
* Keeps the pool's mode order stable (rotated so the next-to-play mode is first)
* and biases against already-played `(mode, stage)` pairs so they are not picked
* again unless every option in that mode has already been played.
*
* @example
* const generator = resume({ mapPool, history });
* generator.next();
* const { mode, stageId } = generator.next({ amount: 1 }).value![0];
*/
export function resume(args: {
mapPool: MapPool;
history: Array<{ mode: ModeShort; stageId: StageId }>;
}) {
const modes = args.mapPool.modes;
const lastMode = args.history.at(-1)?.mode;
const lastIdx = lastMode ? modes.indexOf(lastMode) : -1;
const offset = modes.length > 0 ? (lastIdx + 1) % modes.length : 0;
const modeOrder = [...modes.slice(offset), ...modes.slice(0, offset)];
const initialWeights = new Map<string, number>();
for (const pair of args.mapPool.stageModePairs) {
initialWeights.set(modeStageKey(pair.mode, pair.stageId), 0);
}
for (const { mode, stageId } of args.history) {
initialWeights.set(modeStageKey(mode, stageId), -100);
}
const STAGE_PENALTY = -20;
const initialStageWeights = new Map<StageId, number>();
for (const pair of args.mapPool.stageModePairs) {
if (!initialStageWeights.has(pair.stageId)) {
initialStageWeights.set(pair.stageId, 0);
}
}
for (const { stageId } of args.history) {
for (const [key, value] of initialStageWeights.entries()) {
initialStageWeights.set(key, value + 1);
}
initialStageWeights.set(stageId, STAGE_PENALTY);
}
return generate({
mapPool: args.mapPool,
modeOrder,
initialWeights: initialWeights.size > 0 ? initialWeights : undefined,
initialStageWeights,
skipEnsureMinimumCandidates: true,
});
}
function initializeWeights(
modes: ModeShort[],
mapPool: ReadonlyMapPoolObject,
initialWeights?: Map<string, number>,
initialStageWeights?: Map<StageId, number>,
) {
const stageWeights = new Map<StageId, number>();
const stageModeWeights = new Map<string, number>();
const hasInitialWeights = initialWeights && initialWeights.size > 0;
for (const mode of modes) {
const stageIds = mapPool[mode];
for (const stageId of stageIds) {
stageWeights.set(stageId, initialStageWeights?.get(stageId) ?? 0);
const key = modeStageKey(mode, stageId);
const initialWeight =
initialWeights?.get(key) ?? (hasInitialWeights ? -1000 : 0);
stageModeWeights.set(key, initialWeight);
}
}
return { stageWeights, stageModeWeights };
}
function weightedRandomSelect<T>(
candidates: T[],
getWeight: (candidate: T) => number,
): T {
const totalWeight = candidates.reduce(
(sum, candidate) => sum + Math.max(0, getWeight(candidate)),
0,
);
invariant(totalWeight > 0, "Expected at least one candidate with weight > 0");
let random = Math.random() * totalWeight;
for (const candidate of candidates) {
const weight = Math.max(0, getWeight(candidate));
random -= weight;
if (random <= 0) {
return candidate;
}
}
return candidates[candidates.length - 1];
}
function selectStageWeighted({
possibleStages,
mode,
stageWeights,
stageModeWeights,
}: {
possibleStages: readonly StageId[];
mode: ModeShort;
stageWeights: Map<StageId, number>;
stageModeWeights: Map<string, number>;
}): StageId {
const getCandidates = () =>
possibleStages.filter((stageId) => {
const stageWeight = stageWeights.get(stageId) ?? 0;
const stageModeWeight =
stageModeWeights.get(modeStageKey(mode, stageId)) ?? 0;
return stageWeight >= 0 && stageModeWeight >= 0;
});
let candidates = getCandidates();
while (candidates.length === 0) {
for (const [key, value] of stageWeights.entries()) {
stageWeights.set(key, value + 1);
}
for (const [key, value] of stageModeWeights.entries()) {
stageModeWeights.set(key, value + 1);
}
candidates = getCandidates();
}
return weightedRandomSelect(candidates, (stageId) => {
const stageWeight = stageWeights.get(stageId) ?? 0;
const stageModeWeight =
stageModeWeights.get(modeStageKey(mode, stageId)) ?? 0;
return stageWeight + stageModeWeight + 1;
});
}
/** Ensure that stage map pool has at least half the total options at the start of a round to avoid predictable replay order */
function ensureMinimumCandidates({
mapPool,
stageWeights,
stageModeWeights,
}: {
mapPool: MapPool;
stageWeights: Map<StageId, number>;
stageModeWeights: Map<string, number>;
}) {
const countAvailableStages = () => {
return mapPool.stages.filter((stageId) => {
const stageWeight = stageWeights.get(stageId) ?? 0;
return stageWeight >= 0;
}).length;
};
const requiredCandidates = Math.ceil(mapPool.stages.length / 2);
while (countAvailableStages() < requiredCandidates) {
for (const [key, value] of stageWeights.entries()) {
stageWeights.set(key, value + 1);
}
for (const [key, value] of stageModeWeights.entries()) {
stageModeWeights.set(key, value + 1);
}
}
}
function modifyModeOrderByPattern(
modeOrder: ModeShort[],
pattern: MaplistPattern,
amount: number,
offset: number,
) {
const filteredModes = modeOrder.filter(
(mode) => !pattern.pattern.includes(mode),
);
const modesToUse = filteredModes.length > 0 ? filteredModes : modeOrder;
const expandedPattern = Array.from(
{ length: amount },
(_, i) => pattern.pattern[i % pattern.pattern.length],
);
// slots fixed by the pattern don't consume modes from the cycle, otherwise
// the same cycle positions would resolve every set and modes get starved
const result: ModeShort[] = [];
let modesConsumed = 0;
for (const part of expandedPattern) {
if (part !== undefined && part !== "ANY" && modeOrder.includes(part)) {
result.push(part);
continue;
}
result.push(modesToUse[(offset + modesConsumed) % modesToUse.length]);
modesConsumed++;
}
const mustIncludePlacedIndices = new Set<number>();
if (pattern.mustInclude) {
const flexibleIndices = expandedPattern.flatMap((part, idx) =>
part === undefined || part === "ANY" ? [idx] : [],
);
// with no flexible slots a must include may still claim slots from the
// pattern's repeats, but never from its first cycle (pattern has priority)
const overridableIndices =
flexibleIndices.length > 0
? flexibleIndices
: expandedPattern.flatMap((_, idx) =>
idx >= pattern.pattern.length ? [idx] : [],
);
for (const { mode, isGuaranteed } of pattern.mustInclude) {
// impossible must include, mode is not in the pool
if (!modeOrder.includes(mode)) continue;
let possibleIndices = overridableIndices;
if (isGuaranteed) {
const guaranteedPositions = Math.ceil(amount / 2);
const frontIndices = possibleIndices.filter(
(idx) => idx < guaranteedPositions,
);
// an unsatisfiable guaranteed position degrades to any allowed slot
if (frontIndices.length > 0) {
possibleIndices = frontIndices;
}
}
// nothing the must include is allowed to claim, pattern wins entirely
if (possibleIndices.length === 0) continue;
const currentIndex = result.indexOf(mode);
const isAlreadyIncluded = currentIndex !== -1;
// "good spot" means a slot the must include may claim, or one the
// pattern itself pins the mode to (relocating from there is pointless
// as the pattern gets stamped back over fixed slots)
const isInGoodSpot =
possibleIndices.includes(currentIndex) ||
(!isGuaranteed && expandedPattern[currentIndex] === mode);
if (!isAlreadyIncluded) {
const randomIndex = R.sample(possibleIndices, 1)[0];
invariant(typeof randomIndex === "number");
result[randomIndex] = mode;
mustIncludePlacedIndices.add(randomIndex);
} else if (!isInGoodSpot) {
const swapTargets = possibleIndices.filter(
(idx) => idx !== currentIndex,
);
if (swapTargets.length === 0) continue;
const targetIndex = R.sample(swapTargets, 1)[0];
invariant(typeof targetIndex === "number");
[result[currentIndex], result[targetIndex]] = [
result[targetIndex],
result[currentIndex],
];
mustIncludePlacedIndices.add(targetIndex);
}
}
}
if (pattern.pattern.every((part) => part === "ANY")) {
return { currentModeOrder: result, modesConsumed };
}
for (const [idx, mode] of expandedPattern.entries()) {
if (mode === "ANY") continue;
if (mustIncludePlacedIndices.has(idx)) continue;
if (modeOrder.includes(mode)) {
result[idx] = mode;
}
}
return { currentModeOrder: result, modesConsumed };
}
const validPatternParts = new Set(["*", ...modesShort] as const);
/**
* Parses a pattern string into structured pattern data for map list generation.
*
* @example
* unwrapOr(parsePattern("SZ*TC"), null); // { pattern: ["SZ", "ANY", "TC"] }
* unwrapOr(parsePattern("[RM!]*SZ"), 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,
});
}

View File

@@ -0,0 +1,92 @@
import { describe, expect, test } from "vitest";
import {
mapPoolToSerializedString,
serializedStringToMapPool,
} from "./serializer";
import type { MapPoolObject } from "./types";
const testSerializedPool =
"tw:3330000;sz:3a14000;tc:2c98000;rm:2bc0000;cb:39c0000";
describe("Map pool serializer", () => {
test("Unserializes and then serializes to same result", () => {
const mapPool = serializedStringToMapPool(testSerializedPool);
expect(mapPoolToSerializedString(mapPool)).toEqual(testSerializedPool);
});
test("Ignores invalid mode key", () => {
const testSerializedPoolWithInvalidMode = `${testSerializedPool};ab:1ce0`;
const mapPool = serializedStringToMapPool(
testSerializedPoolWithInvalidMode,
);
expect(mapPoolToSerializedString(mapPool)).toEqual(testSerializedPool);
});
test("Matching serialization with IPLMapGen2", () => {
const testMapPool: MapPoolObject = {
// Gorge, Spillway, Mincemeat, Mahi-Mahi, Inkblot
TW: [0, 3, 4, 7, 8],
// Gorge, Eeltail, Spillway, Inkblot, MakoMart
SZ: [0, 1, 3, 8, 10],
// Eeltail, Hagglefish, Bridge, Inbklot, Sturgeon
TC: [1, 2, 5, 8, 9],
// Eeltail, Spillway, Mincemeat, Bridge, Museum
RM: [1, 3, 4, 5, 6],
// Gorge, Eeltail, Mincemeat, Bridge, Museum
CB: [0, 1, 4, 5, 6],
};
expect(mapPoolToSerializedString(testMapPool)).toEqual(testSerializedPool);
});
test("Omits key if mode has no maps", () => {
const testPoolWithoutTw: MapPoolObject = {
CB: [1, 2],
RM: [1, 8],
TC: [8, 4],
SZ: [10],
TW: [],
};
const serialized = mapPoolToSerializedString(testPoolWithoutTw);
expect(
serialized.includes("sz") && !serialized.includes("tw"),
).toBeTruthy();
});
test("Returns empty string if no maps", () => {
const testPoolWithoutTw: MapPoolObject = {
CB: [],
RM: [],
TC: [],
SZ: [],
TW: [],
};
const serialized = mapPoolToSerializedString(testPoolWithoutTw);
expect(serialized).toEqual("");
});
test("Value of two modes is the same with same maps", () => {
const testPoolWithDuplicateMaps: MapPoolObject = {
CB: [1, 2],
RM: [1, 2],
TC: [],
SZ: [],
TW: [],
};
const serialized = mapPoolToSerializedString(testPoolWithDuplicateMaps);
const [modeOne, modeTwo] = serialized.split(";");
if (!modeOne || !modeTwo) {
throw new Error("Map pool is missing modes");
}
expect(modeOne.split(":")[1]).toEqual(modeTwo.split(":")[1]);
});
});

View File

@@ -0,0 +1,86 @@
import invariant from "~/utils/invariant";
import { modesShort } from "../../../../modules/in-game-lists/modes";
import { stageIds } from "../../../../modules/in-game-lists/stage-ids";
import type { StageId } from "../../../../modules/in-game-lists/types";
import type { MapPoolObject, ReadonlyMapPoolObject } from "./types";
export function mapPoolToSerializedString(
mapPool: ReadonlyMapPoolObject,
): string {
const serializedModes = [];
for (const mode of modesShort) {
const stages = mapPool[mode];
if (stages.length === 0) continue;
serializedModes.push(`${mode}:${binaryToHex(stageIdsToBinary(stages))}`);
}
return serializedModes.join(";").toLowerCase();
}
function stageIdsToBinary(input: readonly StageId[]) {
let result = "1";
for (const stageId of stageIds) {
if (input.includes(stageId)) {
result += "1";
} else {
result += "0";
}
}
return result;
}
function binaryToHex(binary: string) {
return Number.parseInt(binary, 2).toString(16);
}
export function serializedStringToMapPool(
serialized: string,
): ReadonlyMapPoolObject {
const result: MapPoolObject = {
SZ: [],
CB: [],
RM: [],
TC: [],
TW: [],
};
for (const serializedMode of serialized.split(";")) {
const [mode, mapsInHex] = serializedMode.split(":");
if (!mode || !mapsInHex) continue;
const validatedMode = modesShort.find(
(realMode) => realMode === mode.toUpperCase(),
);
if (!validatedMode) continue;
const stagesBinary = hexToBinary(mapsInHex);
result[validatedMode].push(...binaryToStageIds(stagesBinary));
}
return result;
}
function binaryToStageIds(binary: string): readonly StageId[] {
const result: StageId[] = [];
// first 1 is padding
for (let i = 1; i <= stageIds.length; i++) {
const letter = binary[i];
if (letter === "0" || !letter) continue;
const stage = stageIds[i - 1];
invariant(typeof stage === "number");
result.push(stage);
}
return result;
}
function hexToBinary(hex: string) {
return Number.parseInt(hex, 16).toString(2);
}

View File

@@ -0,0 +1,9 @@
import type {
ModeShort,
StageId,
} from "../../../../modules/in-game-lists/types";
export type MapPoolObject = Record<ModeShort, StageId[]>;
export type ReadonlyMapPoolObject = Readonly<
Record<ModeShort, readonly StageId[]>
>;

View File

@@ -0,0 +1,199 @@
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 {
mapPoolToSerializedString,
serializedStringToMapPool,
} from "./map-pool-serializer/serializer";
import type {
MapPoolObject,
ReadonlyMapPoolObject,
} from "./map-pool-serializer/types";
export type DbMapPoolList = Array<
Pick<Tables["MapPoolMap"], "stageId" | "mode">
>;
export class MapPool {
private source: string | ReadonlyMapPoolObject;
private asSerialized?: string;
private asObject?: ReadonlyMapPoolObject;
constructor(init: ReadonlyMapPoolObject | string | DbMapPoolList) {
this.source = Array.isArray(init)
? this.mapPoolListToMapPoolObject(init)
: init;
}
static serialize(init: ReadonlyMapPoolObject | string | DbMapPoolList) {
return new MapPool(init).serialized;
}
static parse(init: MapPoolObject | string | DbMapPoolList) {
return new MapPool(init).parsed;
}
static toDbList(init: MapPoolObject | string | DbMapPoolList) {
return new MapPool(init).dbList;
}
get serialized(): string {
if (this.asSerialized !== undefined) {
return this.asSerialized;
}
// biome-ignore lint/suspicious/noAssignInExpressions: biome migration
return (this.asSerialized =
typeof this.source === "string"
? this.source
: mapPoolToSerializedString(this.source));
}
get parsed(): ReadonlyMapPoolObject {
if (this.asObject !== undefined) {
return this.asObject;
}
// biome-ignore lint/suspicious/noAssignInExpressions: biome migration
return (this.asObject =
typeof this.source === "string"
? serializedStringToMapPool(this.source)
: this.source);
}
get dbList(): DbMapPoolList {
return Object.entries(this.parsed).flatMap(([mode, stages]) =>
stages.flatMap((stageId) => ({ mode: mode as ModeShort, stageId })),
);
}
get stages() {
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 })),
);
}
has({ stageId, mode }: { stageId: StageId; mode: ModeShort }) {
return this.parsed[mode].includes(stageId);
}
hasMode(mode: ModeShort): boolean {
return this.parsed[mode].length > 0;
}
hasStage(stageId: StageId): boolean {
return Object.values(this.parsed).some((stages) =>
stages.includes(stageId),
);
}
overlaps(other: MapPool): boolean {
return this.stageModePairs.some((pair) => other.has(pair));
}
isEmpty(): boolean {
return Object.values(this.parsed).every((stages) => stages.length === 0);
}
countMapsByMode(mode: ModeShort): number {
return this.parsed[mode].length;
}
get length() {
return this.stageModePairs.length;
}
getClonedObject() {
return structuredClone(this.parsed) as MapPoolObject;
}
toString() {
return this.serialized;
}
toJSON() {
return this.parsed;
}
[Symbol.iterator]() {
let index = -1;
const data = this.stageModePairs;
return {
next: () => ({ value: data[++index], done: !(index in data) }),
};
}
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: [],
CB: [],
RM: [],
TW: [],
});
static ALL = new MapPool({
SZ: [...stageIds],
TC: [...stageIds],
CB: [...stageIds],
RM: [...stageIds],
TW: [...stageIds],
});
static ANARCHY = new MapPool({
SZ: [...stageIds],
TC: [...stageIds],
CB: [...stageIds],
RM: [...stageIds],
TW: [],
});
static SZ = new MapPool({
...MapPool.EMPTY.parsed,
SZ: [...stageIds],
});
static TC = new MapPool({
...MapPool.EMPTY.parsed,
TC: [...stageIds],
});
static CB = new MapPool({
...MapPool.EMPTY.parsed,
CB: [...stageIds],
});
static RM = new MapPool({
...MapPool.EMPTY.parsed,
RM: [...stageIds],
});
static TW = new MapPool({
...MapPool.EMPTY.parsed,
TW: [...stageIds],
});
}

View File

@@ -0,0 +1,27 @@
import { describe, test } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/search-params/search-params-test-utils";
import { MapPool } from "./core/map-pool";
import { mapListGeneratorSearchParams } from "./map-list-generator-search-params";
describe("mapListGeneratorSearchParams", () => {
test("round-trips", () => {
assertRoundTrips(mapListGeneratorSearchParams, {
pool: [
MapPool.ANARCHY.serialized,
new MapPool({ TW: [], SZ: [1, 2], TC: [3], RM: [], CB: [] }).serialized,
],
eventId: [null, 1, 500],
readonly: [false, true],
});
});
test("decodes garbage to defaults", () => {
assertDecodesToDefault(mapListGeneratorSearchParams, "eventId", [
["abc"],
["-1"],
]);
});
});

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
import { MapPool } from "./core/map-pool";
export const mapListGeneratorSearchParams = SearchParams.define({
pool: SP.param(z.string(), {
default: MapPool.ANARCHY.serialized,
resets: ["eventId"],
loader: false,
}),
eventId: SP.param(z.number().int().positive().nullable(), {
resets: ["pool"],
loader: false,
}),
readonly: SP.param(z.boolean(), { default: false, loader: false }),
});

View File

@@ -0,0 +1,9 @@
import { MAPS_URL } from "~/utils/urls";
import type { MapPool } from "./core/map-pool";
import { mapListGeneratorSearchParams } from "./map-list-generator-search-params";
export const mapsPageWithMapPool = (mapPool: MapPool) =>
mapListGeneratorSearchParams.href(MAPS_URL, {
readonly: true,
pool: mapPool.serialized,
});

View File

@@ -0,0 +1,41 @@
.container {
max-width: 38rem;
}
.poolSelector {
padding-block: var(--s-3);
}
.tournamentMapListLink {
font-size: var(--font-2xs);
text-align: center;
}
.mapListCreator {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--s-4);
}
.toggleContainer {
--label-margin: 0;
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-2);
}
.mapList {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
margin-block-start: var(--s-4);
}
.modeAbbr {
color: var(--color-text-high);
font-weight: var(--weight-bold);
text-decoration: none;
}

View File

@@ -0,0 +1,168 @@
import { Check, Clipboard } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouSwitch } from "~/components/elements/Switch";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { MapPoolSelector, MapPoolStages } from "~/components/MapPoolSelector";
import type { Tables } from "~/db/tables";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type { ModeWithStage } from "~/modules/in-game-lists/types";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
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 * as MapList from "../core/MapList";
import { MapPool } from "../core/map-pool";
import { mapListGeneratorSearchParams } from "../map-list-generator-search-params";
import styles from "./maps.module.css";
const AMOUNT_OF_MAPS_IN_MAP_LIST = stageIds.length * 2;
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Map List Generator",
ogTitle: "Splatoon 3 map list generator",
description:
"Generate a map list based on maps you choose or a tournament's map pool.",
location: args.location,
});
};
export const handle: SendouRouteHandle = {
i18n: "game-misc",
breadcrumb: () => ({
imgPath: navIconUrl("maps"),
href: MAPS_URL,
type: "IMAGE",
}),
};
export default function MapListPage() {
const { t } = useTranslation(["common"]);
const { mapPool, handleMapPoolChange, readonly } =
useSearchParamPersistedMapPool();
return (
<Main className={`${styles.container} stack lg`}>
{readonly ? (
<MapPoolStages mapPool={mapPool} />
) : (
<MapPoolSelector
mapPool={mapPool}
handleMapPoolChange={handleMapPoolChange}
allowBulkEdit
className={styles.poolSelector}
/>
)}
<a
href={ipLabsMaps(mapPool.serialized)}
target="_blank"
rel="noreferrer"
className={styles.tournamentMapListLink}
>
{t("common:maps.tournamentMaplist")}
</a>
<MapListCreator mapPool={mapPool} />
</Main>
);
}
export function useSearchParamPersistedMapPool() {
const [params, setParams] = useSearchParamsTyped(
mapListGeneratorSearchParams,
);
const [mapPool, setMapPool] = React.useState(() => new MapPool(params.pool));
const handleMapPoolChange = (
newMapPool: MapPool,
event?: Pick<Tables["CalendarEvent"], "id" | "name">,
) => {
setMapPool(newMapPool);
setParams(event ? { eventId: event.id } : { pool: newMapPool.serialized });
};
return {
mapPool,
readonly: params.readonly,
handleMapPoolChange,
};
}
function MapListCreator({ mapPool }: { mapPool: MapPool }) {
const { t } = useTranslation(["game-misc", "common"]);
const [mapList, setMapList] = React.useState<ModeWithStage[]>();
const [szEveryOther, setSzEveryOther] = React.useState(false);
const { copyToClipboard, copySuccess } = useCopyToClipboard();
const handleCreateMaplist = () => {
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);
setMapList(list);
};
const disabled =
mapPool.isEmpty() || (szEveryOther && !mapPool.hasMode("SZ"));
return (
<div className={styles.mapListCreator}>
<div className={styles.toggleContainer}>
<Label>{t("common:maps.halfSz")}</Label>
<SendouSwitch isSelected={szEveryOther} onChange={setSzEveryOther} />
</div>
<SendouButton onPress={handleCreateMaplist} isDisabled={disabled}>
{t("common:maps.createMapList")}
</SendouButton>
{mapList && (
<>
<ol className={styles.mapList}>
{mapList.map(({ mode, stageId }, i) => (
<li key={i}>
<abbr
className={styles.modeAbbr}
title={t(`game-misc:MODE_LONG_${mode}`)}
>
{t(`game-misc:MODE_SHORT_${mode}`)}
</abbr>{" "}
{t(`game-misc:STAGE_${stageId}`)}
</li>
))}
</ol>
<SendouButton
size="small"
variant="outlined"
icon={copySuccess ? <Check /> : <Clipboard />}
onPress={() =>
copyToClipboard(
mapList
.map(
({ mode, stageId }, i) =>
`${i + 1}) ${t(`game-misc:MODE_SHORT_${mode}`)} ${t(
`game-misc:STAGE_${stageId}`,
)}`,
)
.join("\n"),
)
}
>
{t("common:actions.copyToClipboard")}
</SendouButton>
</>
)}
</div>
);
}