From c274aaea12ce7fbc2d89a3bfee1c558a357474f1 Mon Sep 17 00:00:00 2001 From: "Kalle (Sendou)" <38327916+Sendouc@users.noreply.github.com> Date: Mon, 25 Jan 2021 21:00:18 +0200 Subject: [PATCH] generate ladder matches --- lib/playFunctions.ts | 149 ++++++++++++++++++ package-lock.json | 70 ++++++++ package.json | 1 + pages/api/play/teams/matches.ts | 74 +++++++++ pages/play/index.tsx | 8 +- .../getAllLadderRegisteredTeamsForMatches.ts | 19 +++ 6 files changed, 318 insertions(+), 3 deletions(-) create mode 100644 lib/playFunctions.ts create mode 100644 pages/api/play/teams/matches.ts create mode 100644 prisma/queries/getAllLadderRegisteredTeamsForMatches.ts diff --git a/lib/playFunctions.ts b/lib/playFunctions.ts new file mode 100644 index 000000000..c1cf1a5ef --- /dev/null +++ b/lib/playFunctions.ts @@ -0,0 +1,149 @@ +import { GetAllLadderRegisteredTeamsForMatchesData } from "prisma/queries/getAllLadderRegisteredTeamsForMatches"; +import { quality, Rating } from "ts-trueskill"; + +type TeamsWithRanking = { + id: number; + roster: { + id: number; + rating: Rating; + }[]; +}; + +export const getLadderRounds = ( + registeredTeams: GetAllLadderRegisteredTeamsForMatchesData +) => { + if (registeredTeams.length < 4) { + throw Error("registeredTeams length less than 4"); + } + const teamsWithRanking: TeamsWithRanking[] = registeredTeams.map( + (registeredTeam) => ({ + id: registeredTeam.id, + roster: registeredTeam.roster.map((user) => ({ + id: user.id, + rating: user.trueSkill + ? new Rating(user.trueSkill.mu, user.trueSkill.sigma) + : new Rating(), + })), + }) + ); + + // this chooses the teams to sit out each round if uneven number of teams + // if even it just returns `teamsWithRanking` in both 0 index and 1 index + const [teamsRoundOne, teamsRoundTwo] = getTeamsForRounds(); + + // helper variable accessed from generatePairings + let bestPairs: TeamsWithRanking[][] | undefined; + // helper variable accessed from generatePairings + let bestAverageQuality = Infinity; + + // first round matches actual + let firstRound: TeamsWithRanking[][] | undefined; + + generatePairings(teamsRoundOne, 0); + + firstRound = bestPairs; + bestAverageQuality = -Infinity; + + generatePairings(teamsRoundTwo, 0); + + if (!firstRound || !bestPairs || firstRound === bestPairs) { + throw Error("unexpected falsy firstROund or bestPairs"); + } + + return [firstRound, bestPairs]; + + // https://stackoverflow.com/a/37449857 + // start is the current position in the list, advancing by 2 each time + // pass 0 as start when calling at the top level + function generatePairings(items: TeamsWithRanking[], start: number) { + if (items.length % 2 !== 0) { + throw Error("uneven number of teams in generatePairings"); + } + + // is this a complete pairing? + if (start === items.length) { + if (hasDuplicatePairing()) { + return; + } + + let qualitySum = 0; + for (let i = 0; i < items.length; i += 2) { + const teamAlpha = items[i].roster.map((user) => user.rating); + const teamBravo = items[i + 1].roster.map((user) => user.rating); + + qualitySum += quality([teamAlpha, teamBravo]); + } + + qualitySum /= items.length / 2; + if (qualitySum > bestAverageQuality) { + bestAverageQuality = qualitySum; + bestPairs = items + .map((team, i) => (i % 2 !== 0 ? null : [team, items[i + 1]])) + .filter((team) => team) as TeamsWithRanking[][]; + } + + return; + } + + // for the next pair, choose the first element in the list for the + // first item in the pair (meaning we don't have to do anything + // but leave it in place), and each of the remaining elements for + // the second item: + for (let j = start + 1; j < items.length; j++) { + // swap start+1 and j: + let temp = items[start + 1]; + items[start + 1] = items[j]; + items[j] = temp; + + // recurse: + generatePairings(items, start + 2); + + // swap them back: + temp = items[start + 1]; + items[start + 1] = items[j]; + items[j] = temp; + } + + function hasDuplicatePairing() { + if (!firstRound) return false; + + for (let i = 0; i < items.length; i += 2) { + const teamAlpha = items[i]; + const teamBravo = items[i + 1]; + + if ( + firstRound.some( + ([pairsAlpha, pairsBravo]) => + (pairsAlpha.id === teamAlpha.id && + pairsBravo.id === teamBravo.id) || + (pairsAlpha.id === teamBravo.id && pairsBravo.id === teamAlpha.id) + ) + ) { + return true; + } + } + + return false; + } + } + + function getTeamsForRounds() { + if (teamsWithRanking.length % 2 === 0) + return [teamsWithRanking, teamsWithRanking]; + + const firstTeamToSitOut = randomChoiceIndex(); + let secondTeamToSitOut = randomChoiceIndex(); + while (secondTeamToSitOut === firstTeamToSitOut) { + secondTeamToSitOut = randomChoiceIndex(); + } + + return [ + teamsWithRanking.filter((_, i) => i !== firstTeamToSitOut), + teamsWithRanking.filter((_, i) => i !== secondTeamToSitOut), + ]; + + function randomChoiceIndex() { + return Math.floor(Math.random() * teamsWithRanking.length); + } + } +}; diff --git a/package-lock.json b/package-lock.json index 0d96a5720..32f4bfc65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3538,6 +3538,11 @@ "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=" }, + "complex.js": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.0.11.tgz", + "integrity": "sha512-6IArJLApNtdg1P1dFtn3dnyzoZBEF0MwMnrfF1exSBRpZYoy4yieMkpZhQDC0uwctw48vii0CFVyHfpgZ/DfGw==" + }, "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", @@ -4262,6 +4267,11 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" }, + "decimal.js": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.2.1.tgz", + "integrity": "sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw==" + }, "decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -4665,6 +4675,11 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" }, + "escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==" + }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -5145,6 +5160,11 @@ "mime-types": "^2.1.12" } }, + "fraction.js": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.0.13.tgz", + "integrity": "sha512-E1fz2Xs9ltlUp+qbiyx9wmt2n9dRzPsS11Jtdb8D2o+cC7wr9xkkKsVKJuBX0ST+LVS+LhLO+SbLJNtfWcJvXA==" + }, "fragment-cache": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", @@ -6145,6 +6165,11 @@ "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", "dev": true }, + "javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha1-+eIwPUUH9tdDVac2ZNFED7Wg71k=" + }, "jest-get-type": { "version": "26.3.0", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", @@ -6853,6 +6878,21 @@ "resolved": "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz", "integrity": "sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg==" }, + "mathjs": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-8.1.1.tgz", + "integrity": "sha512-b3TX3EgiZObujjwb8lZnTDLUuivC2jar4ZBjmGJ4stFYCDXx/DNwx5yry5t/z65p9mvejyZel1qoeR05KtChcQ==", + "requires": { + "complex.js": "^2.0.11", + "decimal.js": "^10.2.1", + "escape-latex": "^1.2.0", + "fraction.js": "^4.0.13", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^2.0.0" + } + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -9251,6 +9291,11 @@ "ajv-keywords": "^3.5.2" } }, + "seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" + }, "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", @@ -10103,6 +10148,11 @@ "setimmediate": "^1.0.4" } }, + "tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" + }, "tiny-invariant": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz", @@ -10217,6 +10267,11 @@ "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==" }, + "ts-gaussian": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ts-gaussian/-/ts-gaussian-2.0.2.tgz", + "integrity": "sha512-DdMwQc1bgzRwbKQ3VoiV3aK2isz7CJhOmtO2oA+mp9soGpbN/p2pkBaIrZnJBW3jB293CUARLFmNktcTSHjypQ==" + }, "ts-node": { "version": "9.1.1", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-9.1.1.tgz", @@ -10236,6 +10291,16 @@ "resolved": "https://registry.npmjs.org/ts-pnp/-/ts-pnp-1.2.0.tgz", "integrity": "sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==" }, + "ts-trueskill": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ts-trueskill/-/ts-trueskill-3.2.0.tgz", + "integrity": "sha512-bzTv5KAWHvplf1tMCoD7/v5z/jNLXHTLX9nF6RIM/LIO/SbPfhxMqXLofCrP+HlZ0MlE4Sjf4IIjxxSdpwAVLA==", + "requires": { + "mathjs": "^8.0.1", + "ts-gaussian": "^2.0.2", + "uuid": "^8.3.1" + } + }, "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", @@ -10271,6 +10336,11 @@ "integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==", "dev": true }, + "typed-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-2.0.0.tgz", + "integrity": "sha512-Hhy1Iwo/e4AtLZNK10ewVVcP2UEs408DS35ubP825w/YgSBK1KVLwALvvIG4yX75QJrxjCpcWkzkVRB0BwwYlA==" + }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", diff --git a/package.json b/package.json index a819d0ed3..334b2765e 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "react-string-replace": "^0.4.4", "recharts": "^2.0.3", "swr": "^0.4.0", + "ts-trueskill": "^3.2.0", "uuid": "^8.3.2", "zod": "^1.11.11" }, diff --git a/pages/api/play/teams/matches.ts b/pages/api/play/teams/matches.ts new file mode 100644 index 000000000..8b6a4d6b1 --- /dev/null +++ b/pages/api/play/teams/matches.ts @@ -0,0 +1,74 @@ +import { getMySession } from "lib/getMySession"; +import { getLadderRounds } from "lib/playFunctions"; +import { NextApiRequest, NextApiResponse } from "next"; +import { getAllLadderRegisteredTeamsForMatches } from "prisma/queries/getAllLadderRegisteredTeamsForMatches"; + +const matchesHandler = async (req: NextApiRequest, res: NextApiResponse) => { + switch (req.method) { + case "POST": + await postHandler(req, res); + break; + default: + res.status(405).end(); + } +}; + +async function postHandler(req: NextApiRequest, res: NextApiResponse) { + const user = await getMySession(); + + // if (user?.discordId !== ADMIN_DISCORD_ID) { + // return res.status(401).end(); + // } + + const teams = (await getAllLadderRegisteredTeamsForMatches()).filter( + (team) => team.roster.length === 4 + ); + + if (teams.length < 4) return res.status(400).end(); + + const matches = getLadderRounds(teams); + + // await Promise.all( + // matches.flatMap((round, i) => + // round.map((match) => + // prisma.ladderMatch.create({ + // data: { + // date: "", + // maplist: {}, + // order: i + 1, + // players: { + // create: match.flatMap((team, teamI) => + // team.roster.map((user) => ({ + // userId: user.id, + // team: teamI === 0 ? "ALPHA" : "BRAVO", + // })) + // ), + // }, + // }, + // }) + // ) + // ) + // ); + + res.status(200).json( + matches.flatMap((round, i) => + round.map((match) => ({ + data: { + date: "", + maplist: {}, + order: i + 1, + players: { + create: match.flatMap((team, teamI) => + team.roster.map((user) => ({ + userId: user.id, + team: teamI === 0 ? "ALPHA" : "BRAVO", + })) + ), + }, + }, + })) + ) + ); +} + +export default matchesHandler; diff --git a/pages/play/index.tsx b/pages/play/index.tsx index 54021966f..43e10152d 100644 --- a/pages/play/index.tsx +++ b/pages/play/index.tsx @@ -45,7 +45,7 @@ const PlayPage = () => { ) .find((tuple) => tuple[1] >= 3); return ( - + {teamTuple ? ( {teamTuple[0].twitterName && ( @@ -56,12 +56,14 @@ const PlayPage = () => { /> )} {teamTuple[0].name} - {teamTuple[1] === 3 && +1} + {teamTuple[1] === 3 && team.roster.length >= 4 && ( + +1 + )} ) : ( {team.roster.map((member) => ( - + ))} )} diff --git a/prisma/queries/getAllLadderRegisteredTeamsForMatches.ts b/prisma/queries/getAllLadderRegisteredTeamsForMatches.ts new file mode 100644 index 000000000..6e836222d --- /dev/null +++ b/prisma/queries/getAllLadderRegisteredTeamsForMatches.ts @@ -0,0 +1,19 @@ +import { Prisma } from "@prisma/client"; +import prisma from "prisma/client"; + +export type GetAllLadderRegisteredTeamsForMatchesData = Prisma.PromiseReturnType< + typeof getAllLadderRegisteredTeamsForMatches +>; + +export const getAllLadderRegisteredTeamsForMatches = async () => + prisma.ladderRegisteredTeam.findMany({ + select: { + id: true, + roster: { + select: { + id: true, + trueSkill: true, + }, + }, + }, + });