From b1042f8e3787d24b539fd48419b2b268eaf511e8 Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Sun, 9 Oct 2022 00:58:19 +0300
Subject: [PATCH] Generates map lists
---
app/components/icons/Download.tsx | 18 +++
app/modules/in-game-lists/index.ts | 1 +
app/modules/in-game-lists/types.ts | 2 +
app/modules/map-list-generator/index.ts | 3 +
app/modules/map-list-generator/map-list.ts | 164 +++++++++++++++++++++
app/modules/map-list-generator/modes.ts | 15 ++
app/modules/map-list-generator/utils.ts | 14 ++
app/modules/map-pool-serializer/index.ts | 2 +
app/routes/maps.tsx | 79 +++++++++-
app/styles/maps.css | 29 ++++
package-lock.json | 11 ++
package.json | 1 +
public/locales/en/game-misc.json | 7 +-
13 files changed, 342 insertions(+), 4 deletions(-)
create mode 100644 app/components/icons/Download.tsx
create mode 100644 app/modules/map-list-generator/index.ts
create mode 100644 app/modules/map-list-generator/map-list.ts
create mode 100644 app/modules/map-list-generator/modes.ts
create mode 100644 app/modules/map-list-generator/utils.ts
diff --git a/app/components/icons/Download.tsx b/app/components/icons/Download.tsx
new file mode 100644
index 000000000..be9c9b21e
--- /dev/null
+++ b/app/components/icons/Download.tsx
@@ -0,0 +1,18 @@
+export function DownloadIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/app/modules/in-game-lists/index.ts b/app/modules/in-game-lists/index.ts
index 6a6b44b15..8fe8aa8f5 100644
--- a/app/modules/in-game-lists/index.ts
+++ b/app/modules/in-game-lists/index.ts
@@ -42,6 +42,7 @@ export type {
Ability,
AbilityType,
ModeShort,
+ ModeWithStage,
StageId,
BuildAbilitiesTuple,
BuildAbilitiesTupleWithUnknown,
diff --git a/app/modules/in-game-lists/types.ts b/app/modules/in-game-lists/types.ts
index 251c40729..372467c76 100644
--- a/app/modules/in-game-lists/types.ts
+++ b/app/modules/in-game-lists/types.ts
@@ -11,6 +11,8 @@ export type ModeShort = typeof modes[number]["short"];
export type StageId = typeof stageIds[number];
+export type ModeWithStage = { mode: ModeShort; stageId: StageId };
+
export type Ability = typeof abilities[number]["name"];
export type AbilityWithUnknown = typeof abilities[number]["name"] | "UNKNOWN";
export type AbilityType = typeof abilities[number]["type"];
diff --git a/app/modules/map-list-generator/index.ts b/app/modules/map-list-generator/index.ts
new file mode 100644
index 000000000..88b1f2cd9
--- /dev/null
+++ b/app/modules/map-list-generator/index.ts
@@ -0,0 +1,3 @@
+export { generateMapList } from "./map-list";
+export { modesOrder } from "./modes";
+export { mapPoolToNonEmptyModes } from "./utils";
diff --git a/app/modules/map-list-generator/map-list.ts b/app/modules/map-list-generator/map-list.ts
new file mode 100644
index 000000000..fc3f125ee
--- /dev/null
+++ b/app/modules/map-list-generator/map-list.ts
@@ -0,0 +1,164 @@
+// Original version by Lean
+
+import invariant from "tiny-invariant";
+import type {
+ ModeShort,
+ ModeWithStage,
+ StageId,
+} from "~/modules/in-game-lists";
+import type { MapPool } from "~/modules/map-pool-serializer";
+import clone from "just-clone";
+
+const BACKLOG = 2;
+
+export type Popularity = Map>;
+
+type MapBucket = Map;
+
+/**
+ * @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[]) {
+ return !mapHistory.slice(-BACKLOG, mapHistory.length).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 MapPool);
+ }
+
+ /* prettier-ignore */
+ 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[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) {
+ 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(a: Array) {
+ let j, x, i;
+ 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, clone(mapPool));
+ }
+
+ for (let bucketNum = 0; bucketNum < buckets.size; bucketNum++) {
+ const item = buckets.get(bucketNum);
+ shuffle(item![mode]);
+ for (const stageId of item![mode]) {
+ if (isValid(stageId, mapHistory)) {
+ return addAndReturnMap(stageId, mode, buckets, bucketNum);
+ }
+ }
+ }
+ throw Error("Invalid bucket configuration");
+}
diff --git a/app/modules/map-list-generator/modes.ts b/app/modules/map-list-generator/modes.ts
new file mode 100644
index 000000000..cb4a17513
--- /dev/null
+++ b/app/modules/map-list-generator/modes.ts
@@ -0,0 +1,15 @@
+import shuffle from "just-shuffle";
+import type { ModeShort } from "../in-game-lists";
+
+export function modesOrder(
+ type: "EQUAL" | "SZ_EVERY_OTHER",
+ modes: ModeShort[]
+): ModeShort[] {
+ if (type === "EQUAL") {
+ return shuffle(modes);
+ }
+
+ const withoutSZ = shuffle(modes.filter((mode) => mode !== "SZ"));
+
+ return withoutSZ.flatMap((mode, i) => (i % 2 === 0 ? ["SZ", mode] : [mode]));
+}
diff --git a/app/modules/map-list-generator/utils.ts b/app/modules/map-list-generator/utils.ts
new file mode 100644
index 000000000..0906c6e66
--- /dev/null
+++ b/app/modules/map-list-generator/utils.ts
@@ -0,0 +1,14 @@
+import type { ModeShort } from "../in-game-lists";
+import type { MapPool } from "../map-pool-serializer";
+
+export function mapPoolToNonEmptyModes(mapPool: MapPool) {
+ const result: ModeShort[] = [];
+
+ for (const [key, stages] of Object.entries(mapPool)) {
+ if (stages.length === 0) continue;
+
+ result.push(key as ModeShort);
+ }
+
+ return result;
+}
diff --git a/app/modules/map-pool-serializer/index.ts b/app/modules/map-pool-serializer/index.ts
index 12b021fb5..79678884e 100644
--- a/app/modules/map-pool-serializer/index.ts
+++ b/app/modules/map-pool-serializer/index.ts
@@ -2,3 +2,5 @@ export {
mapPoolToSerializedString,
serializedStringToMapPool,
} from "./serializer";
+
+export type { MapPool } from "./types";
diff --git a/app/routes/maps.tsx b/app/routes/maps.tsx
index f66e0b9f5..10b15ab18 100644
--- a/app/routes/maps.tsx
+++ b/app/routes/maps.tsx
@@ -2,19 +2,37 @@ import type { LinksFunction } from "@remix-run/node";
import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
import { Main } from "~/components/Main";
-import type { ModeShort, StageId } from "~/modules/in-game-lists";
+import type {
+ ModeShort,
+ ModeWithStage,
+ StageId,
+} from "~/modules/in-game-lists";
import { modes, stageIds } from "~/modules/in-game-lists";
import type { MapPool } from "~/modules/map-pool-serializer/types";
import styles from "~/styles/maps.css";
import { modeImageUrl, stageImageUrl } from "~/utils/urls";
import clsx from "clsx";
-import { useSearchParams } from "@remix-run/react";
+import { Form, useSearchParams } from "@remix-run/react";
import {
mapPoolToSerializedString,
serializedStringToMapPool,
} from "~/modules/map-pool-serializer";
import { useUser } from "~/modules/auth";
import { ADMIN_DISCORD_ID } from "~/constants";
+import { Button } from "~/components/Button";
+import { Input } from "~/components/Input";
+import { Label } from "~/components/Label";
+import { DownloadIcon } from "~/components/icons/Download";
+import { Toggle } from "~/components/Toggle";
+import {
+ generateMapList,
+ mapPoolToNonEmptyModes,
+ modesOrder,
+} from "~/modules/map-list-generator";
+import * as React from "react";
+import invariant from "tiny-invariant";
+
+const AMOUNT_OF_MAPS_IN_MAP_LIST = stageIds.length * 2;
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
@@ -36,11 +54,13 @@ export default function MapListPage() {
const { mapPool, handleMapPoolChange } = useSearchParamMapPool();
return (
-
+
+
+
);
}
@@ -146,3 +166,56 @@ function MapPoolSelector({
);
}
+
+function MapPoolLoaderSaver() {
+ return (
+
+ );
+}
+
+// xxx: crashes if only one map in mode
+// xxx: presentational mode
+function MapListCreator({ mapPool }: { mapPool: MapPool }) {
+ const { t } = useTranslation(["game-misc"]);
+ const [mapList, setMapList] = React.useState();
+
+ const handleCreateMaplist = () => {
+ const [list] = generateMapList(
+ mapPool,
+ modesOrder("EQUAL", mapPoolToNonEmptyModes(mapPool)),
+ [AMOUNT_OF_MAPS_IN_MAP_LIST]
+ );
+
+ invariant(list);
+
+ setMapList(list);
+ };
+
+ return (
+
+
+
+ true} />
+
+
+ {mapList && (
+
+ {mapList.map(({ mode, stageId }, i) => (
+ -
+ {t(`game-misc:MODE_SHORT_${mode}`)}{" "}
+ {t(`game-misc:STAGE_${stageId}`)}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/app/styles/maps.css b/app/styles/maps.css
index db46d2b10..008206148 100644
--- a/app/styles/maps.css
+++ b/app/styles/maps.css
@@ -2,6 +2,13 @@
max-width: 32rem;
}
+.maps__pool-loader-saver {
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ gap: var(--s-4);
+}
+
.maps__stage-row {
display: flex;
width: 100%;
@@ -55,3 +62,25 @@
.maps__mode.selected {
filter: grayscale(0%);
}
+
+.maps__map-list-creator {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--s-4);
+}
+
+.maps__toggle-container {
+ --label-margin: 0;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--s-2);
+}
+
+.maps__map-list {
+ font-size: var(--fonts-xs);
+ font-weight: var(--semi-bold);
+}
diff --git a/package-lock.json b/package-lock.json
index 1299e42ac..43637d4bd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23,6 +23,7 @@
"i18next-fs-backend": "^1.1.5",
"i18next-http-backend": "^1.4.4",
"just-capitalize": "^3.1.1",
+ "just-clone": "^6.1.1",
"just-shuffle": "^4.1.1",
"markdown-to-jsx": "^7.1.7",
"node-cron": "3.0.2",
@@ -9642,6 +9643,11 @@
"resolved": "https://registry.npmjs.org/just-capitalize/-/just-capitalize-3.1.1.tgz",
"integrity": "sha512-j4OYSJg+XwNuxiJb/z1IZr2xohIcn0DKRIw3Fu4Dv1vjacKhH+zi99BoRCoXa/xvpvm18Ahn7CXr40oinTCDfA=="
},
+ "node_modules/just-clone": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/just-clone/-/just-clone-6.1.1.tgz",
+ "integrity": "sha512-V24KLIid8uaG7ayOymGfheNHtxgrbpzj1UznQnF9vQZMHlKGTSLT3WWmFx62OXSQPwk1Tn+uo+H5/Xhb4bL9pA=="
+ },
"node_modules/just-shuffle": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/just-shuffle/-/just-shuffle-4.1.1.tgz",
@@ -22724,6 +22730,11 @@
"resolved": "https://registry.npmjs.org/just-capitalize/-/just-capitalize-3.1.1.tgz",
"integrity": "sha512-j4OYSJg+XwNuxiJb/z1IZr2xohIcn0DKRIw3Fu4Dv1vjacKhH+zi99BoRCoXa/xvpvm18Ahn7CXr40oinTCDfA=="
},
+ "just-clone": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/just-clone/-/just-clone-6.1.1.tgz",
+ "integrity": "sha512-V24KLIid8uaG7ayOymGfheNHtxgrbpzj1UznQnF9vQZMHlKGTSLT3WWmFx62OXSQPwk1Tn+uo+H5/Xhb4bL9pA=="
+ },
"just-shuffle": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/just-shuffle/-/just-shuffle-4.1.1.tgz",
diff --git a/package.json b/package.json
index 7098ebb8c..2fcc18f0b 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
"i18next-fs-backend": "^1.1.5",
"i18next-http-backend": "^1.4.4",
"just-capitalize": "^3.1.1",
+ "just-clone": "^6.1.1",
"just-shuffle": "^4.1.1",
"markdown-to-jsx": "^7.1.7",
"node-cron": "3.0.2",
diff --git a/public/locales/en/game-misc.json b/public/locales/en/game-misc.json
index de7ff391b..be8229456 100644
--- a/public/locales/en/game-misc.json
+++ b/public/locales/en/game-misc.json
@@ -10,5 +10,10 @@
"STAGE_8": "Inkblot Art Academy",
"STAGE_9": "Sturgeon Shipyard",
"STAGE_10": "MakoMart",
- "STAGE_11": "Wahoo World"
+ "STAGE_11": "Wahoo World",
+ "MODE_SHORT_TW": "TW",
+ "MODE_SHORT_SZ": "SZ",
+ "MODE_SHORT_TC": "TC",
+ "MODE_SHORT_RM": "RM",
+ "MODE_SHORT_CB": "CB"
}