diff --git a/src/worker/core/phase/newPhaseRegularSeason.ts b/src/worker/core/phase/newPhaseRegularSeason.ts index 6d53efa903..783b6b8665 100644 --- a/src/worker/core/phase/newPhaseRegularSeason.ts +++ b/src/worker/core/phase/newPhaseRegularSeason.ts @@ -1,6 +1,7 @@ import { season } from ".."; import { idb } from "../../db"; import { g, helpers, local, logEvent, toUI } from "../../util"; +import { getDivisionRanks } from "../../util/orderTeams"; import type { Conditions, PhaseReturn } from "../../../common/types"; import { EMAIL_ADDRESS, @@ -23,8 +24,89 @@ const newPhaseRegularSeason = async ( }, "noCopyCache", ); - - await season.setSchedule(season.newSchedule(teams)); + //As I understand it, this is the relevant area to generate a regular season schedule + if (isSport("football") && teams.length === 32) { + // This needs to be more strict, should verify league is in default configuration + const year = g.get("season"); + const startingYear = g.get("startingSeason"); + const rawTeams = + //Borrowed some of this logic from the standing view, which may not be the optimum way to get ranked teams. + await idb.getCopies.teamsPlus( + { + attrs: ["tid"], + seasonAttrs: [ + "won", + "lost", + "tied", + "otl", + "winp", + "pts", + "ptsPct", + "wonHome", + "lostHome", + "tiedHome", + "otlHome", + "wonAway", + "lostAway", + "tiedAway", + "otlAway", + "wonDiv", + "lostDiv", + "tiedDiv", + "otlDiv", + "wonConf", + "lostConf", + "tiedConf", + "otlConf", + "cid", + "did", + "abbrev", + "region", + "name", + "clinchedPlayoffs", + ], + stats: ["pts", "oppPts", "gp"], + season: year > startingYear ? year - 1 : year, //TODO: We want the previous year's schedule, otherwise just place in TID order + showNoStats: true, + }, + "noCopyCache", + ); + const rankedTeams: number[] = Array(rawTeams.length); + //If it's not the first season, base order on previous season's ranking, otherwise, just do order received + if (year > startingYear) { + // rankings will give the row + // TODO: THIS DOES NOT WORK RIGHT + const rankings = await getDivisionRanks(rawTeams, rawTeams, { + skipDivisionLeaders: false, + skipTiebreakers: false, + season: g.get("season"), + }); + // division ID and conference ID thankfully maps to our general concept. So we create an array, and then assign all teams to their + //expected location + rawTeams.forEach(team => { + const row = rankings.get(team.tid); + const col = team.seasonAttrs.did; + //Assign teams based on rank + rankedTeams[row! * 8 + col] = team.tid; + }); + } else { + //this does work right + rawTeams.forEach(team => { + const col = team.seasonAttrs.did; + //Assign teams based on order received. + for (let i = 0; i < 4; i++) { + if (rankedTeams[i * 8 + col] === undefined) { + rankedTeams[i * 8 + col] = team.tid; + break; + } + } + }); + } + const matches = await season.generateMatches(rankedTeams, g.get("season")); + season.setSchedule([], season.scheduleSort(matches)); + } else { + await season.setSchedule(season.newSchedule(teams)); + } if (g.get("autoDeleteOldBoxScores")) { const tx = idb.league.transaction("games", "readwrite"); diff --git a/src/worker/core/season/index.ts b/src/worker/core/season/index.ts index 636ad11fcf..00a43fc127 100644 --- a/src/worker/core/season/index.ts +++ b/src/worker/core/season/index.ts @@ -11,14 +11,17 @@ import getSchedule from "./getSchedule"; import newSchedule from "./newSchedule"; import newSchedulePlayoffsDay from "./newSchedulePlayoffsDay"; import setSchedule from "./setSchedule"; +import scheduleSort from "./scheduleSortSpeculative"; import updateOwnerMood from "./updateOwnerMood"; import validatePlayoffSettings from "./validatePlayoffSettings"; +import generateMatches from "./newScheduleSpeculative.Football"; export default { addDaysToSchedule, doAwards, genPlayoffSeeds, genPlayoffSeries, + generateMatches, getAwardCandidates, getDaysLeftSchedule, getInitialNumGamesConfDivSettings, @@ -28,6 +31,7 @@ export default { newSchedule, newSchedulePlayoffsDay, setSchedule, + scheduleSort, updateOwnerMood, validatePlayoffSettings, }; diff --git a/src/worker/core/season/newScheduleSpeculative.Football.ts b/src/worker/core/season/newScheduleSpeculative.Football.ts new file mode 100644 index 0000000000..dc7b839182 --- /dev/null +++ b/src/worker/core/season/newScheduleSpeculative.Football.ts @@ -0,0 +1,362 @@ +// Assign matches based on NFL rules. +// Rules: +// Home/away vs division +// mixed vs. another division same conference +// mixed vs. another division other conference +// mixed vs. same rank, another division same conference (2 games) +// 1 from other division, other conference, switch home/away based on season + +// # Table (array visualization): +// # AFC | NFC +// # Place | East | North | South | West | East | North | South | West +// # 1st | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 +// # 2nd | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 +// # 3rd | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 +// # 4th | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 +// ## + +//nestedArrayIncludes, checks for whether a nested match array is equitable. Uses array.some() +//and checks whether first item === first item and second item === second item +const nestedArrayIncludes = (array: number[][], nested: number[]): boolean => { + return array.some(i => { + return i[0] === nested[0] && i[1] === nested[1]; + }); +}; + +// generateMatches takes the year and returns an array of arrays, which carry +// home and away team matches based on position in the teams array. This creates the current +// 17 game NFL schedule using the NFL's current scheduling formula. +const generateMatches = async ( + teams: number[], + year: number, +): Promise => { + //For the time being, I'm not feeling super inspired for how to further expand the functionality. + //Ideally, one would be able to pass these as arguments and we'd figure out how + //to allocate games dynamically based on conference and division alignment. + const conferences: number = 2; + const divisions: number = 4; + const teamsPerDiv: number = teams.length / (conferences * divisions); + + const matches: number[][] = []; + + //Conference offsets work according to this table. Utilizing this pattern we can ensure + //that every division will play each other every 4 years. + //Divs | 0 1 2 3 + // ---------------- + //Year 0 | 0 1 2 3 + //Year 1 | 1 0 3 2 + //Year 2 | 2 3 0 1 + //Year 3 | 3 2 1 0 + //So this is apparently a Latin Square. + //After some thinking, I think we can create these particular latin squares by using a the smallest + //version and applying the pattern across pairs of the next sized version. + // So this means that: + // 0 | 1 + // 1 | 0 + // can be applied to the next size up: + // Pairs = [0,1], [2,3] + // [0,1], [2,3] [0,1,2,3] + // [1,0], [3,2] which can [1,0,3,2] + // [2,3], [0,1] simplify to [2,3,0,1] + // [3,2], [1,0] [3,2,1,0] + // and you can use this configuration to generate the next size up, and so on. + const latinSquareBuilder = (base: number): number[][] => { + //Start with first combination + let start: number[][] = [ + [0, 1], + [1, 0], + ]; + //Base 1 is the first combination, so good work + if (base === 1) { + return start; + } + // We'll run up the bases, starting at 2 (since we've already solved for 1) + for (let i = 2; i < base + 1; i++) { + const combos = 2 ** i; + const pairs: number[][] = []; + //Create pairs from combos + for (let j = 0; j < combos; j++) { + if (j % 2 == 0) { + pairs.push([j]); + } else { + pairs[(j / 2) | 0].push(j); + } + } + // build a temp square based on the current square, pairs + const tempSquare: number[][] = []; + for (let j = 0; j < combos; j++) { + tempSquare.push([]); + const row = (j / 2) | 0; + //normal order + if (j % 2 == 0) { + for (let k = 0; k < combos; k = k + 2) { + const col = (k / 2) | 0; + tempSquare[j].push( + pairs[start[row][col]][0], + pairs[start[row][col]][1], + ); + } + //reverse + } else { + for (let k = 0; k < combos; k = k + 2) { + const col = (k / 2) | 0; + tempSquare[j].push( + pairs[start[row][col]][1], + pairs[start[row][col]][0], + ); + } + } + } + //replace start with tempSquare, use start to seed the next size up. + start = JSON.parse(JSON.stringify(tempSquare)); + } + return start; + }; + + //Only works for 2,4,8... ect. (Math.log2 returns a whole number) divisions. + const crossConferenceOffsets: number[][] = latinSquareBuilder( + Math.log2(divisions), + ); + + //IntraConference is the same as cross conference, without the first item. + const intraConferenceOffsets: number[][] = crossConferenceOffsets.slice(1); + + // Divisional States will carry the possible binary representations of home and away games for divisional + // matches where we want to face all teams once. This should work for any even square number of games for sure + // and can likely be tweaked for any number of equal sized divisions. The '16' below should be 2^n, where n is the + // number of games. + + // Thankfully, someone already asked this on Stack Overflow, and even better, someone left a great answer that's already + // in javascript which is quite nice. + // https://stackoverflow.com/questions/36451090/permutations-of-binary-number-by-swapping-two-bits-not-lexicographically/36466454#36466454 + + // So we'll avail ourselves to the walking bit algo. + function walkingBits(n: number, k: number, container: number[][]) { + const seq: number[] = []; + for (let i = 0; i < n; i++) seq[i] = 0; + walk(n, k, 1, 0); + + function walk(n: number, k: number, dir: number, pos: number) { + for (let i = 1; i <= n - k + 1; i++, pos += dir) { + seq[pos] = 1; + if (k > 1) + walk( + n - i, + k - 1, + i % 2 ? dir : -dir, + pos + dir * (i % 2 ? 1 : n - i), + ); + else container.push(JSON.parse(JSON.stringify(seq))); + seq[pos] = 0; + } + } + } + + const divisionalStates: number[][] = []; + walkingBits(4, 2, divisionalStates); + //Tracks pairs of intra-conference division pairings + const intraConferenceSets = new Map(); + //tracks pairs of cross conference division pairings + const crossConferenceSets = new Map(); + //tracks pairs of intra-conference matches based on rank (two divisions not playing a full slate against team) + const intraRankSet = new Map(); + + // Iterate team IDs, using i as the positional index and assigning matches with tID + teams.forEach((tID, i) => { + const div: number = i % (conferences * divisions); + //some silly float rounding is killing me here, but it appears we can use bitwise OR 0 to do what we want + const rank: number = (i / (conferences * divisions)) | 0; + // Divisions for conference sets will be set based on the year, as well as offsets. This + // rotates the matches every divisions years, with same conference games rotating + // divisions - 1 years, since a division already plays itself. + const divSameConf: number = + div < divisions + ? intraConferenceOffsets[year % (divisions - 1)][div] + : intraConferenceOffsets[year % (divisions - 1)][div % divisions] + + divisions; + const divOtherConf: number = + div < divisions + ? crossConferenceOffsets[year % divisions][div] + divisions + : crossConferenceOffsets[year % divisions][div % divisions]; + + // Just take divisions /2, so it offset by at least one in all multi-division scenarios + const extraDiv: number = + div < divisions + ? crossConferenceOffsets[(year + ((divisions / 2) | 0)) % divisions][ + div + ] + divisions + : crossConferenceOffsets[(year + ((divisions / 2) | 0)) % divisions][ + div % divisions + ]; + // IntraConfRanked holds the remaining divisions in the same conference that are not already assigned to get games. + const intraConfRanked = Array.from(Array(divisions).keys()) + .filter(dID => dID !== divSameConf % divisions && dID !== div % divisions) + .map(dID => (div < divisions ? dID : dID + divisions)); + + //Assign intraConfRanked to intraRankSet, so that we can track home and away. Since there's only two games that used ranked atm (H/A or A/H), + //We can skip a binary representation and instead just track on a simple map. We'll just check if the dID has already been assigned + //a home game (key), if not, assigned as a home game else assign as an away game. + intraConfRanked.forEach(dID => { + if (!Array.from(intraRankSet.keys()).includes(div)) { + if (!Array.from(intraRankSet.values()).includes(dID)) { + intraRankSet.set(div, dID); + } + } else if (!Array.from(intraRankSet.values()).includes(div)) { + if (!Array.from(intraRankSet.keys()).includes(dID)) { + intraRankSet.set(dID, div); + } + } + }); + + //Assign intra and crossConference sets. Since these are 1:1 divisional matches, just ensure each div is assigned once. + if ( + !Array.from(intraConferenceSets.keys()).includes(div) && + !Array.from(intraConferenceSets.values()).includes(div) + ) { + intraConferenceSets.set(div, divSameConf); + } + + if ( + !Array.from(crossConferenceSets.keys()).includes(div) && + !Array.from(crossConferenceSets.values()).includes(div) + ) { + crossConferenceSets.set(div, divOtherConf); + } + + //Major and Minor states carry the binary representation for home and away games between 4 teams on any given year. + //So take the year, get the remainder from the total number of possible states, and that will give the layout of games + //for that year. + const majorState: number[] = + divisionalStates[year % divisionalStates.length]; + + //Anything bitwise appears to be a pain in JS, so we'll just invert using an array + const minorState: number[] = majorState.map(d => { + if (d === 0) { + return 1; + } else { + return 0; + } + }); + + // Finally assign the binary representations based on rank and whether the division has been assigned first or + // second in the conference set maps + let intraArrangement: number[]; + let crossArrangement: number[]; + if (majorState[rank] === 0) { + intraArrangement = Array.from(intraConferenceSets.keys()).includes(div) + ? majorState + : minorState; + crossArrangement = Array.from(crossConferenceSets.keys()).includes(div) + ? majorState + : minorState; + } else { + intraArrangement = Array.from(intraConferenceSets.keys()).includes(div) + ? minorState + : majorState; + crossArrangement = Array.from(crossConferenceSets.keys()).includes(div) + ? minorState + : majorState; + } + + // Instead of iterating all teams, we can iterate over teams per div and set the matches where divisions play + // another division. In this instance, the teamsPerDiv keys will stand in for the ranks of opposing teams + Array.from(Array(teamsPerDiv).keys()).forEach(oppRank => { + // opposing rank is the row offset. With this we can derive the position of the opposing team, then + // place into spot + const opposingRankOffset: number = oppRank * divisions * conferences; + + //intra-division matches. Home and away against every team in division. Using the opposingRankOffset, + //We can add that, plus the division, to find the team ID of the opposing team in the teams (array) + + // if opposing is not the same as the current index + if (opposingRankOffset + div !== i) { + // Check if we have a home game + if ( + !nestedArrayIncludes(matches, [tID, teams[opposingRankOffset + div]]) + ) { + matches.push([tID, teams[opposingRankOffset + div]]); + } + // check for away game + if ( + !nestedArrayIncludes(matches, [teams[opposingRankOffset + div], tID]) + ) { + matches.push([teams[opposingRankOffset + div], tID]); + } + } + + //intra-conference Divisional matches + if (intraArrangement[oppRank] === 0) { + if ( + !nestedArrayIncludes(matches, [ + tID, + teams[opposingRankOffset + divSameConf], + ]) + ) { + matches.push([tID, teams[opposingRankOffset + divSameConf]]); + } + } else { + if ( + !nestedArrayIncludes(matches, [ + teams[opposingRankOffset + divSameConf], + tID, + ]) + ) { + matches.push([teams[opposingRankOffset + divSameConf], tID]); + } + } + + //cross conference divisional matches + if (crossArrangement[oppRank] === 0) { + if ( + !nestedArrayIncludes(matches, [ + tID, + teams[opposingRankOffset + divOtherConf], + ]) + ) { + matches.push([tID, teams[opposingRankOffset + divOtherConf]]); + } + } else { + if ( + !nestedArrayIncludes(matches, [ + teams[opposingRankOffset + divOtherConf], + tID, + ]) + ) { + matches.push([teams[opposingRankOffset + divOtherConf], tID]); + } + } + }); + // 14 games down + // Iterate through intraRankSet to find relevant matches + for (const [d1, d2] of Array.from(intraRankSet.entries())) { + if (d1 === div) { + const opp: number = teams[d2 + rank * divisions * conferences]; + if (!nestedArrayIncludes(matches, [tID, opp])) { + matches.push([tID, opp]); + } + } else if (d2 === div) { + const opp: number = teams[d1 + rank * divisions * conferences]; + if (!nestedArrayIncludes(matches, [opp, tID])) { + matches.push([opp, tID]); + } + } + } + + // 17th game is a home game distributed every other year to a conference + if (year % 2 === 0) { + // conference splits at halfway point + if (div < divisions) { + const opp: number = teams[extraDiv + rank * divisions * conferences]; + matches.push([tID, opp]); + } + } else { + if (div >= divisions) { + const opp: number = teams[extraDiv + rank * divisions * conferences]; + matches.push([tID, opp]); + } + } + }); + return matches; +}; + +export default generateMatches; diff --git a/src/worker/core/season/newScheduleSpeculative.football.test.ts b/src/worker/core/season/newScheduleSpeculative.football.test.ts new file mode 100644 index 0000000000..3187f0141e --- /dev/null +++ b/src/worker/core/season/newScheduleSpeculative.football.test.ts @@ -0,0 +1,87 @@ +import assert from "assert"; +import testHelpers from "../../../test/helpers"; +import generateMatches from "./newScheduleSpeculative.Football"; +import { random } from "lodash-es"; + +describe("worker/core/season/newScheduleSpeculative", () => { + let year: number; + let newDefaultTeams: number[]; + + beforeAll(() => { + year = random(2500); + newDefaultTeams = Array.from(Array(32).keys()); + }); + + describe("football", () => { + beforeAll(() => { + testHelpers.resetG(); + }); + + test("schedule 272 games (17 each for 32 teams)", async () => { + const matches = await generateMatches(newDefaultTeams, year); + assert.strictEqual(matches.length, 272); + }); + + test("schedule 8 home games and 8 away games for each team", async () => { + const matches = await generateMatches(newDefaultTeams, year); + assert.strictEqual(matches.length, 272); + + const home: Record = {}; // Number of home games for each team + const away: Record = {}; // Number of away games for each team + for (let i = 0; i < matches.length; i++) { + if (home[matches[i][0]] === undefined) { + home[matches[i][0]] = 0; + } + if (away[matches[i][1]] === undefined) { + away[matches[i][1]] = 0; + } + home[matches[i][0]] += 1; + away[matches[i][1]] += 1; + } + + assert.strictEqual(Object.keys(home).length, newDefaultTeams.length); + + for (const numGames of [...Object.values(home), ...Object.values(away)]) { + if (numGames !== 8 && numGames !== 9) { + throw new Error(`Got ${numGames} home/away games`); + } + } + }); + + // test("schedule each team two home games against every team in the same division", () => { + // const matches = generateMatches(newDefaultTeams, year); + // assert.strictEqual(matches.length, 272); + + // // Each element in this object is an object representing the number of home games against each other team (only the ones in the same division will be populated) + // const home: Record> = {}; + + // for (let i = 0; i < matches.length; i++) { + // const t0 = defaultTeams.find(t => t.tid === tids[i][0]); + // const t1 = defaultTeams.find(t => t.tid === tids[i][1]); + // if (!t0 || !t1) { + // console.log(tids[i]); + // throw new Error("Team not found"); + // } + // if (t0.seasonAttrs.did === t1.seasonAttrs.did) { + // if (home[tids[i][1]] === undefined) { + // home[tids[i][1]] = {}; + // } + // if (home[tids[i][1]][tids[i][0]] === undefined) { + // home[tids[i][1]][tids[i][0]] = 0; + // } + // home[tids[i][1]][tids[i][0]] += 1; + // } + // } + + // assert.strictEqual(Object.keys(home).length, defaultTeams.length); + + // for (const { tid } of defaultTeams) { + // assert.strictEqual(Object.values(home[tid]).length, 3); + // assert.strictEqual( + // testHelpers.numInArrayEqualTo(Object.values(home[tid]), 1), + // 3, + // ); + // } + // }); + }); +}); diff --git a/src/worker/core/season/scheduleSortSpeculative.test.ts b/src/worker/core/season/scheduleSortSpeculative.test.ts new file mode 100644 index 0000000000..409f0428f1 --- /dev/null +++ b/src/worker/core/season/scheduleSortSpeculative.test.ts @@ -0,0 +1,57 @@ +import assert from "assert"; +import testHelpers from "../../../test/helpers"; +import generateMatches from "./newScheduleSpeculative.Football"; +import { random } from "lodash-es"; +import scheduleSort from "./scheduleSortSpeculative"; + +describe("worker/core/season/scheduleSortSpeculative", () => { + let matches: number[][]; + let year: number; + let newDefaultTeams: number[]; + + beforeAll(() => {}); + beforeEach(async () => { + year = random(2500); + newDefaultTeams = Array.from(Array(32).keys()); + matches = await generateMatches(newDefaultTeams, year); + }); + + describe("football", () => { + beforeAll(() => { + testHelpers.resetG(); + }); + + test("18 week schedule", () => { + const schedule = scheduleSort(matches); + assert.strictEqual(schedule.length, 18); + }); + + // fix it + // test("At least 10 games a week", () => { + // const schedule = scheduleSort(matches); + // schedule.forEach(w => { + // assert(w.length >= 10); + // }); + // }); + + test("Every team plays 17 games", () => { + const schedule = scheduleSort(matches); + const games: Record = {}; // Number of home games for each team + schedule.forEach(m => { + if (games[m.homeTid] === undefined) { + games[m.homeTid] = 0; + } + if (games[m.awayTid] === undefined) { + games[m.awayTid] = 0; + } + games[m.homeTid] += 1; + games[m.awayTid] += 1; + }); + assert.strictEqual(Object.keys(games).length, newDefaultTeams.length); + + newDefaultTeams.forEach(t => { + assert.strictEqual(games[t], 17); + }); + }); + }); +}); diff --git a/src/worker/core/season/scheduleSortSpeculative.ts b/src/worker/core/season/scheduleSortSpeculative.ts new file mode 100644 index 0000000000..1e9d68abae --- /dev/null +++ b/src/worker/core/season/scheduleSortSpeculative.ts @@ -0,0 +1,188 @@ +import type { ScheduleGameWithoutKey } from "src/common/types"; +import { random } from "../../../worker/util"; + +//scheduleSort takes a list of nfl matches, and sorts them into an 18 week configuration, where each team has one bye. I'm going +//to leave games (desired games per week for a full week), partWeeks and fullWeeks assignable, thought right now it's expecting defaults +//of 16 games a week, 10 fullWeeks, 8 partWeeks. While there's a little fuzziness in my understanding, one thing that seems likely is that +//part weeks should be a factor of the total number of bye week matches. +// be returned as an array of weeks containing an array of matches +const scheduleSort = ( + matches: number[][], + gamesPerWeek?: number, + partiallyFullWeeks?: number, + fullSlateWeeks?: number, +): ScheduleGameWithoutKey[] => { + // A schedule is an array of weeks, each with an array of matches + + const games: number = typeof gamesPerWeek === "undefined" ? 16 : gamesPerWeek; + const partWeeks: number = + typeof partiallyFullWeeks === "undefined" ? 8 : partiallyFullWeeks; + const fullWeeks: number = + typeof fullSlateWeeks === "undefined" ? 10 : fullSlateWeeks; + //I guess this could be a variable, so I'm leaving as is for a moment, but ideally, maxByesPerWeek should be double the ideal number of byes + //in a given week (16 games across 8 weeks = 2 per, max of 4) + const maxByesPerWeek: number = Math.ceil(games / partWeeks) * 2; + + //First sort: return desired number of full weeks. From personal experience, this number can only be slightly + //over half of all games assigned. There's likely some general math principal that would give a better idea of what + //the optimum amount of bye weeks given a set of games and desired number of games per week. + const fullSlates: number[][][] = []; + while (fullSlates.length < fullWeeks) { + //Tentative week + const week: number[][] = []; + //Teams assigned to a week + const assigned: number[] = []; + //Is week partially full? We'll assume this works, then set to true if we iterate through all matchups + let notFull: boolean = false; + // track matchups through iterations, avoid having to iterate over matchups and clean up after picking games + let i: number = 0; + + while (week.length < games) { + //if iterated through all matches and week is not full, break while loop, set notFull to true + if (i >= matches.length) { + notFull = true; + break; + } + + if ( + !assigned.includes(matches[i][0]) && + !assigned.includes(matches[i][1]) + ) { + assigned.push(matches[i][0], matches[i][1]); + week.push(matches[i]); + matches.splice(i, 1); + i -= 1; + } + i += 1; + } + + if (notFull === false) { + fullSlates.push(week); + } else { + week.forEach((m, index) => { + index % 2 === 0 ? matches.push(m) : matches.unshift(m); //Maybe cheaper just to shuffle matches instead of alternating push/unshift? + }); + } + } + //Second Sort: partial Weeks. + + //We'll describe how it works across an NFL 17 game schedule + //Partial weeks need to fit 112 matchups across 8 weeks. Since it can be difficult to arrange these perfectly just by allocating, + //we'll instead build 8 weeks with 88 games. Why? Through trial and error, this appears to be the maximum amount of games + //we can allocate without occasionally getting locked in an unsolvable state. Again, there's probably some high math underpinning what we + //can allocate for any order of matches, but for now let's go with good enough. + const partialWeeks: number[][][] = []; + const partialAssigned: number[][] = []; + while (partialWeeks.length < partWeeks) { + const week: number[][] = []; + const assigned: number[] = []; + let notFull: boolean = false; + let i: number = 0; + + //Set partial weeks length to games - (maxByePerWeek + 1). This gives a little extra breathing room in our sort before the final sort + //which is a little more labor intensive. In the final sort we'll attempt to respect maxByesPerWeek, but it's not terribly + //important (Which probably means it needs a new name.) + while (week.length < games - (maxByesPerWeek + 2)) { + if (i >= matches.length) { + notFull = true; + break; + } + + if ( + !assigned.includes(matches[i][0]) && + !assigned.includes(matches[i][1]) + ) { + assigned.push(matches[i][0], matches[i][1]); + week.push(matches[i]); + matches.splice(i, 1); + i -= 1; + } + i += 1; + } + + if (notFull === false) { + partialWeeks.push(week); + partialAssigned.push(assigned); + } else { + week.reverse(); + week.forEach(m => { + matches.push(m); + }); + } + } + + //The final sort deals with the final 24 unallocated games. Assuming that this formula is provided with appropriate fullWeek and partWeek + //values and provides a combination of partialWeek schedules and unallocated games that can be solved, this will eventually find it. + //In practice, this process appears to reach that solution pretty quickly. + let matchesLength: number = matches.length; + while (matches.length > 0) { + partialWeeks.forEach((week, i) => { + let j = 0; + while (j < matches.length) { + if ( + !partialAssigned[i].includes(matches[j][0]) && + !partialAssigned[i].includes(matches[j][1]) + ) { + if (week.length < games - 1) { + const match = matches.splice(j, 1)[0]; + week.push(match); + partialAssigned[i].push(match[0], match[1]); + j -= 1; + } + } + j += 1; + } + }); + // If we were able to assign a match, then update matchesLength + if (matchesLength != matches.length) { + matchesLength = matches.length; + } else { + //Otherwise, we're stuck, so we need to dissolve a week and re-incorporate it. We'll take + //the dissolved week and add it back to the match pool, then reformulate a new week using the + //matches that we weren't able to assign as the first matches is in the week, followed by the matches + //of the dissolved week. + const dissolving: number[][] = partialWeeks.shift()!; + partialAssigned.shift(); + //The reverse might be unnecessary, but it helps vary the available matchups when we get stuck + dissolving.reverse(); + dissolving.forEach(m => matches.push(m)); + //Create new week, allocate matches + const newWeek: number[][] = []; + const newWeekAssigned: number[] = []; + + let j = 0; + while (j < matches.length) { + if ( + !newWeekAssigned.includes(matches[j][0]) && + !newWeekAssigned.includes(matches[j][1]) + ) { + if (newWeek.length < games - 1) { + const match = matches.splice(j, 1)[0]; + newWeek.push(match); + newWeekAssigned.push(match[0], match[1]); + j -= 1; + } + } + j += 1; + } + //push newWeek and newWeekAssigned back to their respective arrays + partialWeeks.push(newWeek); + partialAssigned.push(newWeekAssigned); + } + } + random.shuffle(fullSlates); + random.shuffle(partialWeeks); + const schedule: number[][][] = fullSlates + .slice(0, 7) + .concat(partialWeeks) + .concat(fullSlates.slice(7)); + const finalSchedule: ScheduleGameWithoutKey[] = []; + schedule.forEach((week, i) => { + week.forEach(game => { + finalSchedule.push({ awayTid: game[1], homeTid: game[0], day: i }); + }); + }); + return finalSchedule; +}; + +export default scheduleSort; diff --git a/src/worker/core/season/setSchedule.ts b/src/worker/core/season/setSchedule.ts index 87f5de98dc..55e8567525 100644 --- a/src/worker/core/season/setSchedule.ts +++ b/src/worker/core/season/setSchedule.ts @@ -16,10 +16,15 @@ const makePlayoffsKey = (game: ScheduleGameWithoutKey) => * Save the schedule to the database, overwriting what's currently there. * * @param {Array} tids A list of lists, each containing the team IDs of the home and - away teams, respectively, for every game in the season, respectively. + away teams, respectively, for every game in the season, respectively. + * @param {ScheduleGameWithoutKey[]} schedule A list of games, containing the home and away team IDs, presorted into + days/weeks (skips addDaysToSchedule function) * @return {Promise} */ -const setSchedule = async (tids: [number, number][]) => { +const setSchedule = async ( + tids: [number, number][], + schedule?: ScheduleGameWithoutKey[], +) => { const playoffs = g.get("phase") === PHASE.PLAYOFFS; const oldPlayoffGames: Record = {}; @@ -34,15 +39,16 @@ const setSchedule = async (tids: [number, number][]) => { } await idb.cache.schedule.clear(); - - const schedule = addDaysToSchedule( - tids.map(([homeTid, awayTid]) => ({ - homeTid, - awayTid, - })), - await idb.cache.games.getAll(), - ); - for (const game of schedule) { + if (schedule === undefined && tids !== []) { + schedule = addDaysToSchedule( + tids.map(([homeTid, awayTid]) => ({ + homeTid, + awayTid, + })), + ); + } + await idb.cache.games.getAll(); + for (const game of schedule!) { if (playoffs) { const key = makePlayoffsKey(game); if (oldPlayoffGames[key]) {