mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Generates map lists
This commit is contained in:
parent
97f9115245
commit
b1042f8e37
18
app/components/icons/Download.tsx
Normal file
18
app/components/icons/Download.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ export type {
|
|||
Ability,
|
||||
AbilityType,
|
||||
ModeShort,
|
||||
ModeWithStage,
|
||||
StageId,
|
||||
BuildAbilitiesTuple,
|
||||
BuildAbilitiesTupleWithUnknown,
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
3
app/modules/map-list-generator/index.ts
Normal file
3
app/modules/map-list-generator/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { generateMapList } from "./map-list";
|
||||
export { modesOrder } from "./modes";
|
||||
export { mapPoolToNonEmptyModes } from "./utils";
|
||||
164
app/modules/map-list-generator/map-list.ts
Normal file
164
app/modules/map-list-generator/map-list.ts
Normal 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");
|
||||
}
|
||||
15
app/modules/map-list-generator/modes.ts
Normal file
15
app/modules/map-list-generator/modes.ts
Normal 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]));
|
||||
}
|
||||
14
app/modules/map-list-generator/utils.ts
Normal file
14
app/modules/map-list-generator/utils.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -2,3 +2,5 @@ export {
|
|||
mapPoolToSerializedString,
|
||||
serializedStringToMapPool,
|
||||
} from "./serializer";
|
||||
|
||||
export type { MapPool } from "./types";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
11
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user