Generates map lists

This commit is contained in:
Kalle 2022-10-09 00:58:19 +03:00
parent 97f9115245
commit b1042f8e37
13 changed files with 342 additions and 4 deletions

View File

@ -0,0 +1,18 @@
export function DownloadIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9.75v6.75m0 0l-3-3m3 3l3-3m-8.25 6a4.5 4.5 0 01-1.41-8.775 5.25 5.25 0 0110.233-2.33 3 3 0 013.758 3.848A3.752 3.752 0 0118 19.5H6.75z"
/>
</svg>
);
}

View File

@ -42,6 +42,7 @@ export type {
Ability,
AbilityType,
ModeShort,
ModeWithStage,
StageId,
BuildAbilitiesTuple,
BuildAbilitiesTupleWithUnknown,

View File

@ -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"];

View File

@ -0,0 +1,3 @@
export { generateMapList } from "./map-list";
export { modesOrder } from "./modes";
export { mapPoolToNonEmptyModes } from "./utils";

View File

@ -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<ModeShort, Map<StageId, number>>;
type MapBucket = Map<number, MapPool>;
/**
* @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<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, 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");
}

View File

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

View File

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

View File

@ -2,3 +2,5 @@ export {
mapPoolToSerializedString,
serializedStringToMapPool,
} from "./serializer";
export type { MapPool } from "./types";

View File

@ -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 (
<Main className="maps__container">
<Main className="maps__container stack lg">
<MapPoolLoaderSaver />
<MapPoolSelector
mapPool={mapPool}
handleMapPoolChange={handleMapPoolChange}
/>
<MapListCreator mapPool={mapPool} />
</Main>
);
}
@ -146,3 +166,56 @@ function MapPoolSelector({
</div>
);
}
function MapPoolLoaderSaver() {
return (
<Form className="maps__pool-loader-saver">
<div>
<Label>Code</Label>
<Input name="code" />
</div>
<Button icon={<DownloadIcon />} variant="outlined" type="submit">
Load map pool
</Button>
</Form>
);
}
// 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<ModeWithStage[]>();
const handleCreateMaplist = () => {
const [list] = generateMapList(
mapPool,
modesOrder("EQUAL", mapPoolToNonEmptyModes(mapPool)),
[AMOUNT_OF_MAPS_IN_MAP_LIST]
);
invariant(list);
setMapList(list);
};
return (
<div className="maps__map-list-creator">
<div className="maps__toggle-container">
<Label>50% SZ</Label>
<Toggle checked={false} setChecked={() => true} />
</div>
<Button onClick={handleCreateMaplist}>Create map list</Button>
{mapList && (
<ol className="maps__map-list">
{mapList.map(({ mode, stageId }, i) => (
<li key={i}>
{t(`game-misc:MODE_SHORT_${mode}`)}{" "}
{t(`game-misc:STAGE_${stageId}`)}
</li>
))}
</ol>
)}
</div>
);
}

View File

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

11
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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"
}