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) => ( +
  1. + {t(`game-misc:MODE_SHORT_${mode}`)}{" "} + {t(`game-misc:STAGE_${stageId}`)} +
  2. + ))} +
+ )} +
+ ); +} 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" }