diff --git a/app/core/mapList.ts b/app/core/mapList.ts deleted file mode 100644 index e6c78788d..000000000 --- a/app/core/mapList.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Mode, Stage } from ".prisma/client"; -import { EliminationBracket } from "./tournament/bracket"; - -interface MapDesirability { - stageName: string; - value: number; - originalValue: number; - modes: Mode[]; - originalModes: Mode[]; -} - -export function generateMapListMapForRounds({ - mapPool, - rounds, -}: { - mapPool: Stage[]; - rounds: EliminationBracket; -}): EliminationBracket { - const modes = Array.from(new Set(mapPool.map((stage) => stage.mode))); - - return { - winners: rounds.winners.map((round) => roundsMapList(round)), - losers: rounds.losers.map((round) => roundsMapList(round)), - }; - - function roundsMapList(bestOf: number): Stage[] { - return new Array(bestOf) - .fill(null) - .map(() => ({ id: -1, name: "The Reef", mode: "SZ" })); - } -} diff --git a/app/core/tournament/bracket.ts b/app/core/tournament/bracket.ts index dab21779a..7e5c2df68 100644 --- a/app/core/tournament/bracket.ts +++ b/app/core/tournament/bracket.ts @@ -1,6 +1,6 @@ import type { BracketType, Stage } from ".prisma/client"; import invariant from "tiny-invariant"; -import { generateMapListMapForRounds } from "../mapList"; +import { generateMapListForRounds } from "./mapList"; import type { Bracket } from "./algorithms"; export function participantCountToRoundsInfo({ @@ -18,7 +18,7 @@ export function participantCountToRoundsInfo({ > { const roundNames = getRoundNames(bracket); const roundsDefaultBestOf = getRoundsDefaultBestOf(bracket); - const mapList = generateMapListMapForRounds({ + const mapList = generateMapListForRounds({ mapPool, rounds: roundsDefaultBestOf, }); @@ -29,6 +29,8 @@ export function participantCountToRoundsInfo({ winners: roundNames.winners.map((roundName, i) => { const bestOf = roundsDefaultBestOf.winners[i]; const maps = mapList.winners[i]; + invariant(bestOf, "bestOf undefined in winners"); + invariant(maps, "maps undefined in winners"); return { name: roundName, bestOf, @@ -38,6 +40,8 @@ export function participantCountToRoundsInfo({ losers: roundNames.losers.map((roundName, i) => { const bestOf = roundsDefaultBestOf.losers[i]; const maps = mapList.losers[i]; + invariant(bestOf, "bestOf undefined in losers"); + invariant(maps, "bestOf undefined in losers"); return { name: roundName, bestOf, @@ -53,9 +57,11 @@ const GRAND_FINALS_DEFAULT = 7; const LOSERS_DEFAULT = 3; const LOSERS_FINALS_DEFAULT = 5; -function getRoundsDefaultBestOf( +export type BestOf = 3 | 5 | 7 | 9; + +export function getRoundsDefaultBestOf( bracket: Bracket -): EliminationBracket { +): EliminationBracket { const { winners: winnersRoundCount, losers: losersRoundCount } = countRounds(bracket); @@ -106,8 +112,8 @@ export function countRounds(bracket: Bracket): EliminationBracket { while (true) { losers++; - const match1 = losersMatch.match1; - const match2 = losersMatch.match2; + const match1 = losersMatch?.match1; + const match2 = losersMatch?.match2; if (match1 && losersMatchIds.has(match1.id)) { losersMatch = match1; continue; @@ -144,7 +150,7 @@ export function countRounds(bracket: Bracket): EliminationBracket { export function resolveTournamentFormatString( brackets: { type: BracketType }[] ) { - invariant(brackets.length > 0, "Unexpected no brackets"); + invariant(brackets[0], "no brackets"); return brackets[0].type === "DE" ? "Double Elimination" : "Single Elimination"; diff --git a/app/core/tournament/mapList.test.ts b/app/core/tournament/mapList.test.ts new file mode 100644 index 000000000..c032b7f05 --- /dev/null +++ b/app/core/tournament/mapList.test.ts @@ -0,0 +1,35 @@ +import { Mode, Stage } from ".prisma/client"; +import invariant from "tiny-invariant"; +import { suite } from "uvu"; +import * as assert from "uvu/assert"; +import { eliminationBracket } from "./algorithms"; +import { getRoundsDefaultBestOf } from "./bracket"; +import { generateMapListForRounds } from "./mapList"; + +const MapListForRounds = suite("generateMapListMapForRounds()"); + +const modes: Mode[] = ["SZ", "TC", "RM", "CB"]; +const mapPool: Stage[] = ["a", "b", "c", "d", "e", "f", "g"].map((name, i) => { + const mode = modes.shift(); + invariant(mode, "Unexpected no mode"); + modes.push(mode); + return { name, id: i, mode }; +}); +const bracket = eliminationBracket(100, "DE"); +const rounds = getRoundsDefaultBestOf(bracket); + +MapListForRounds("No mode is repeated in the same round", () => { + const bracket = generateMapListForRounds({ mapPool, rounds }); + + for (const side of [bracket.winners, bracket.losers]) { + for (const round of side) { + for (const [i, stage] of round.entries()) { + if (i === 0) continue; + + assert.not.equal(stage.mode, round[i - 1]?.mode); + } + } + } +}); + +MapListForRounds.run(); diff --git a/app/core/tournament/mapList.ts b/app/core/tournament/mapList.ts new file mode 100644 index 000000000..e8750a216 --- /dev/null +++ b/app/core/tournament/mapList.ts @@ -0,0 +1,117 @@ +import { Mode, Stage } from ".prisma/client"; +import shuffle from "just-shuffle"; +import invariant from "tiny-invariant"; +import clone from "just-clone"; +import { BestOf, EliminationBracket } from "./bracket"; + +export function generateMapListForRounds({ + mapPool, + rounds, +}: { + mapPool: Stage[]; + rounds: EliminationBracket; +}): EliminationBracket { + const modes = mapPool.reduce((acc: [Mode, number][], cur) => { + if (cur.mode === "SZ") return acc; + if (acc.some(([mode]) => mode === cur.mode)) { + return acc.map((modeTuple) => + modeTuple[0] === cur.mode + ? ([modeTuple[0], ++modeTuple[1]] as [Mode, number]) + : modeTuple + ); + } + + acc.push([cur.mode, 1]); + return acc; + }, []); + let currentModes = clone(modes); + const hasSZ = mapPool.some((stage) => stage.mode === "SZ"); + + return { + winners: rounds.winners.map((round) => roundsMapList(round)), + losers: rounds.losers.map((round) => roundsMapList(round)), + }; + + function roundsMapList(bestOf: BestOf): Stage[] { + const modes = resolveModes(bestOf); + + return new Array(bestOf).fill(null).map((_, i) => { + const mode = modes[i]; + invariant(mode, "mode undefined"); + return { id: -1, name: "The Reef", mode }; + }); + } + + function resolveModes(bestOf: BestOf): Mode[] { + let result: (Mode | null)[] = []; + /** 0, 1, 2, 3, 4 */ + const amountOfSZToAdd = !hasSZ ? 0 : Math.floor(bestOf / 2); + for (let _ = 0; _ < amountOfSZToAdd; _++) { + result.push("SZ"); + } + while (result.length < bestOf) { + result.push(null); + } + + result = shuffle(result); + + while (SZInInvalidPosition(result)) { + result = shuffle(result); + } + + let resultWithNoNull: Mode[] = []; + for (let i = 0; i < result.length; i++) { + const element = result[i]; + // Is SZ + if (element) { + resultWithNoNull.push(element); + continue; + } + + const previous = resultWithNoNull[i - 1]; + invariant(previous !== null, "previous is null"); + + if (currentModes.every(([_mode, count]) => count === 0)) { + currentModes = clone(modes); + } + resetCurrentModesIfWouldHaveToRepeatMode(previous); + currentModes = shuffle(currentModes); + currentModes.sort((a, b) => { + if (previous && previous === a[0]) return 1; + if (previous && previous === b[0]) return -1; + + return b[1] - a[1]; + }); + + const nextModeTuple = currentModes[0]; + invariant(nextModeTuple, "nextModeTuple undefined"); + invariant(previous !== nextModeTuple[0], "Repeating mode"); + + resultWithNoNull.push(nextModeTuple[0]); + nextModeTuple[1]--; + } + + return resultWithNoNull; + } + + function SZInInvalidPosition(modes: (Mode | null)[]) { + for (const [i, mode] of modes.entries()) { + if (i === 0) continue; + if (!mode) continue; + if (mode === modes[i - 1]) return true; + } + return false; + } + + function resetCurrentModesIfWouldHaveToRepeatMode(previous?: Mode | null) { + if (!previous) return; + const modesLeft = currentModes.flatMap(([mode, count]) => + count === 0 ? [] : mode + ); + if (modesLeft.length > 1) return; + invariant(modesLeft[0], "modesLeft[0] is undefined"); + if (modesLeft[0][0] !== previous) return; + + currentModes = clone(modes); + } +} diff --git a/app/routes/to/$organization.$tournament/start.tsx b/app/routes/to/$organization.$tournament/start.tsx index 8e4ea4934..2de36b922 100644 --- a/app/routes/to/$organization.$tournament/start.tsx +++ b/app/routes/to/$organization.$tournament/start.tsx @@ -1,5 +1,6 @@ import { useMatches } from "remix"; import { useTournamentRounds } from "~/hooks/useTournamentRounds"; +import type { UseTournamentRoundsState } from "~/hooks/useTournamentRounds/types"; import type { FindTournamentByNameForUrlI } from "~/services/tournament"; // TODO: handle warning if check-in has not concluded @@ -9,47 +10,45 @@ export default function StartBracketTab() { const [rounds, dispatch] = useTournamentRounds({ mapPool, teams, + // TODO: and also below don't show losers if SE type: "DE", }); return (
-

Winners

- {rounds.winners.map((round) => { - return ( - // TODO: key potentially unstable -
-

{round.name}

-
    - {round.mapList.map((stage) => { - return ( -
  1. - {stage.name} {stage.mode} -
  2. - ); - })} -
-
- ); - })} -

Losers

- {rounds.losers.map((round) => { - return ( - // TODO: key potentially unstable -
-

{round.name}

-
    - {round.mapList.map((stage) => { - return ( -
  1. - {stage.name} {stage.mode} -
  2. - ); - })} -
-
- ); - })} + +
); } + +function RoundsCollection({ + side, + rounds, +}: { + side: "Winners" | "Losers"; + rounds: UseTournamentRoundsState["winners"]; +}) { + return ( + <> +

{side}

+ {rounds.map((round) => { + return ( + // TODO: key potentially unstable +
+

{round.name}

+
    + {round.mapList.map((stage) => { + return ( +
  1. + {stage.mode} {stage.name} +
  2. + ); + })} +
+
+ ); + })} + + ); +} diff --git a/package-lock.json b/package-lock.json index bceb2d651..827560144 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "cross-env": "^7.0.3", "express": "^4.17.1", "express-session": "^1.17.2", + "just-clone": "^5.0.1", "just-shuffle": "^4.0.1", "morgan": "^1.10.0", "passport": "^0.5.0", @@ -4078,6 +4079,11 @@ "verror": "1.10.0" } }, + "node_modules/just-clone": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/just-clone/-/just-clone-5.0.1.tgz", + "integrity": "sha512-y6hFQ8X4fhJTZzdNvrE9mnCQX33uN4xV0hZ+5WNgs6yCcrRzs9ieFXuYRKznWqQ/gOSZpJu0yQ6hRHLCU6ezyw==" + }, "node_modules/just-shuffle": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/just-shuffle/-/just-shuffle-4.0.1.tgz", @@ -11546,6 +11552,11 @@ "verror": "1.10.0" } }, + "just-clone": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/just-clone/-/just-clone-5.0.1.tgz", + "integrity": "sha512-y6hFQ8X4fhJTZzdNvrE9mnCQX33uN4xV0hZ+5WNgs6yCcrRzs9ieFXuYRKznWqQ/gOSZpJu0yQ6hRHLCU6ezyw==" + }, "just-shuffle": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/just-shuffle/-/just-shuffle-4.0.1.tgz", diff --git a/package.json b/package.json index 594efefdf..312d6d5dc 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "cross-env": "^7.0.3", "express": "^4.17.1", "express-session": "^1.17.2", + "just-clone": "^5.0.1", "just-shuffle": "^4.0.1", "morgan": "^1.10.0", "passport": "^0.5.0", diff --git a/tsconfig.json b/tsconfig.json index 8a7d843bb..87e54e440 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, "types": ["cypress"], "paths": { "~/*": ["./app/*"]