@leaguesolver/round-robin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +103 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ren Fujii
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# @leaguesolver/round-robin
|
|
2
|
+
|
|
3
|
+
A dependency-free, deterministic single/double round-robin schedule generator for
|
|
4
|
+
TypeScript and JavaScript. Given a list of team names, it produces a full circle-method
|
|
5
|
+
schedule: every pair of teams meets exactly once (single) or twice with home/away swapped
|
|
6
|
+
(double), with automatic byes for odd team counts and balanced home/away counts per team.
|
|
7
|
+
|
|
8
|
+
No `Date`, `Math.random`, or I/O of any kind: the same input always produces the exact
|
|
9
|
+
same output, and the input array is never mutated.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @leaguesolver/round-robin
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Node.js >= 22. Ships as ESM only.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { generateRoundRobin } from "@leaguesolver/round-robin";
|
|
23
|
+
|
|
24
|
+
const rounds = generateRoundRobin(["Tigers", "Lions", "Bears", "Sharks"], "single");
|
|
25
|
+
|
|
26
|
+
console.log(rounds);
|
|
27
|
+
// [
|
|
28
|
+
// { round: 1, matches: [{ home: "Tigers", away: "Sharks" }, { home: "Bears", away: "Lions" }], byeTeam: null },
|
|
29
|
+
// { round: 2, matches: [{ home: "Bears", away: "Tigers" }, { home: "Sharks", away: "Lions" }], byeTeam: null },
|
|
30
|
+
// { round: 3, matches: [{ home: "Lions", away: "Tigers" }, { home: "Sharks", away: "Bears" }], byeTeam: null },
|
|
31
|
+
// ]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
An odd team count adds a bye each round:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
generateRoundRobin(["Tigers", "Lions", "Bears"], "single");
|
|
38
|
+
// 3 rounds, one match plus one byeTeam per round.
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Use `"double"` to play every pair twice, with home/away swapped in the return leg:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
generateRoundRobin(["Tigers", "Lions", "Bears", "Sharks"], "double");
|
|
45
|
+
// 6 rounds: the first 3 as above, followed by the same 3 rounds with home/away reversed.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## API
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
type RoundRobinFormat = "single" | "double";
|
|
52
|
+
|
|
53
|
+
interface RoundRobinMatch {
|
|
54
|
+
readonly home: string;
|
|
55
|
+
readonly away: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface RoundRobinRound {
|
|
59
|
+
readonly round: number;
|
|
60
|
+
readonly matches: readonly RoundRobinMatch[];
|
|
61
|
+
readonly byeTeam: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function generateRoundRobin(
|
|
65
|
+
teams: readonly string[],
|
|
66
|
+
format: RoundRobinFormat,
|
|
67
|
+
): readonly RoundRobinRound[];
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Scope
|
|
71
|
+
|
|
72
|
+
This library only generates the simple round-robin pairing structure. It does not handle
|
|
73
|
+
venue availability, blackout dates, rest-day constraints, or infeasibility diagnostics --
|
|
74
|
+
that is a constraint-optimization problem, not a scheduling-pattern one.
|
|
75
|
+
|
|
76
|
+
Need venue availability, rest days, home/away balance, or infeasibility diagnostics? Use
|
|
77
|
+
the LeagueSolver API: https://leaguesolver.slipway-studio.com
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure circle-method round-robin schedule generator. No Date, Math.random, or IO of any
|
|
3
|
+
* kind: the same teams and format always produce the exact same schedule, and inputs are
|
|
4
|
+
* never mutated.
|
|
5
|
+
*
|
|
6
|
+
* The circle method fixes one seat and rotates every other seat around it each round,
|
|
7
|
+
* which guarantees every pair of teams meets exactly once per leg with no repeats and no
|
|
8
|
+
* team ever missing a round. An odd team count gets one extra "bye" seat so the algorithm
|
|
9
|
+
* always works on an even seat count; whichever team lands opposite the bye seat in a
|
|
10
|
+
* given round sits that round out instead of playing a match.
|
|
11
|
+
*
|
|
12
|
+
* Home/away is assigned per pairing (not per round): each match's home team is whichever
|
|
13
|
+
* of the two teams has played fewer home games so far, with a deterministic round/slot
|
|
14
|
+
* tiebreak when both are equal. Since every team plays exactly n - 1 games in a single
|
|
15
|
+
* leg, this keeps every team's home-game count within 1 of its away-game count for any
|
|
16
|
+
* team count, even or odd.
|
|
17
|
+
*/
|
|
18
|
+
export type RoundRobinFormat = "single" | "double";
|
|
19
|
+
export interface RoundRobinMatch {
|
|
20
|
+
readonly home: string;
|
|
21
|
+
readonly away: string;
|
|
22
|
+
}
|
|
23
|
+
export interface RoundRobinRound {
|
|
24
|
+
readonly round: number;
|
|
25
|
+
readonly matches: readonly RoundRobinMatch[];
|
|
26
|
+
readonly byeTeam: string | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Generates a full round-robin schedule. `single` plays every pair once; `double` appends
|
|
30
|
+
* a mirrored second leg (home/away swapped) so every pair plays twice.
|
|
31
|
+
*/
|
|
32
|
+
export declare function generateRoundRobin(teams: readonly string[], format: RoundRobinFormat): readonly RoundRobinRound[];
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure circle-method round-robin schedule generator. No Date, Math.random, or IO of any
|
|
3
|
+
* kind: the same teams and format always produce the exact same schedule, and inputs are
|
|
4
|
+
* never mutated.
|
|
5
|
+
*
|
|
6
|
+
* The circle method fixes one seat and rotates every other seat around it each round,
|
|
7
|
+
* which guarantees every pair of teams meets exactly once per leg with no repeats and no
|
|
8
|
+
* team ever missing a round. An odd team count gets one extra "bye" seat so the algorithm
|
|
9
|
+
* always works on an even seat count; whichever team lands opposite the bye seat in a
|
|
10
|
+
* given round sits that round out instead of playing a match.
|
|
11
|
+
*
|
|
12
|
+
* Home/away is assigned per pairing (not per round): each match's home team is whichever
|
|
13
|
+
* of the two teams has played fewer home games so far, with a deterministic round/slot
|
|
14
|
+
* tiebreak when both are equal. Since every team plays exactly n - 1 games in a single
|
|
15
|
+
* leg, this keeps every team's home-game count within 1 of its away-game count for any
|
|
16
|
+
* team count, even or odd.
|
|
17
|
+
*/
|
|
18
|
+
// Internal sentinel occupying the extra seat when the team count is odd. Never appears in
|
|
19
|
+
// any returned match or bye value -- a pairing against it becomes that round's bye instead.
|
|
20
|
+
const BYE_SEAT = Symbol("round-robin-bye-seat");
|
|
21
|
+
/**
|
|
22
|
+
* Generates a full round-robin schedule. `single` plays every pair once; `double` appends
|
|
23
|
+
* a mirrored second leg (home/away swapped) so every pair plays twice.
|
|
24
|
+
*/
|
|
25
|
+
export function generateRoundRobin(teams, format) {
|
|
26
|
+
const firstLeg = generateSingleLeg(teams);
|
|
27
|
+
return format === "single" ? firstLeg : [...firstLeg, ...mirrorLeg(firstLeg)];
|
|
28
|
+
}
|
|
29
|
+
function generateSingleLeg(teams) {
|
|
30
|
+
const seats = teams.length % 2 === 0 ? [...teams] : [...teams, BYE_SEAT];
|
|
31
|
+
const seatCount = seats.length;
|
|
32
|
+
const roundCount = seatCount - 1;
|
|
33
|
+
const half = seatCount / 2;
|
|
34
|
+
// Local mutable accumulator, not shared/passed-in state: tracks each team's running
|
|
35
|
+
// home-game count across rounds so buildRound can balance home/away per pairing. Never
|
|
36
|
+
// mutates `teams` itself.
|
|
37
|
+
const homeCounts = new Map(teams.map((team) => [team, 0]));
|
|
38
|
+
const rounds = [];
|
|
39
|
+
let current = seats;
|
|
40
|
+
for (let roundIndex = 0; roundIndex < roundCount; roundIndex++) {
|
|
41
|
+
rounds.push(buildRound(roundIndex, current, half, seatCount, homeCounts));
|
|
42
|
+
current = rotate(current);
|
|
43
|
+
}
|
|
44
|
+
return rounds;
|
|
45
|
+
}
|
|
46
|
+
/** Builds one round's matches (and bye, if any) from the current seating arrangement,
|
|
47
|
+
* assigning home/away per pairing via `assignHomeAway` (see its docstring) and updating
|
|
48
|
+
* `homeCounts` in place as each match is decided. */
|
|
49
|
+
function buildRound(roundIndex, seats, half, seatCount, homeCounts) {
|
|
50
|
+
const matches = [];
|
|
51
|
+
let byeTeam = null;
|
|
52
|
+
for (let i = 0; i < half; i++) {
|
|
53
|
+
const seatA = seats[i];
|
|
54
|
+
const seatB = seats[seatCount - 1 - i];
|
|
55
|
+
if (seatA === BYE_SEAT || seatB === BYE_SEAT) {
|
|
56
|
+
byeTeam = seatA === BYE_SEAT ? seatB : seatA;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const match = assignHomeAway(seatA, seatB, roundIndex, i, homeCounts);
|
|
60
|
+
homeCounts.set(match.home, (homeCounts.get(match.home) ?? 0) + 1);
|
|
61
|
+
matches.push(match);
|
|
62
|
+
}
|
|
63
|
+
return { round: roundIndex + 1, matches, byeTeam };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Decides which of `teamA`/`teamB` hosts this match: whichever has played fewer home
|
|
67
|
+
* games so far. When both are tied, breaks the tie by round/slot parity rather than
|
|
68
|
+
* always favoring the same side of the pairing -- always preferring one side is exactly
|
|
69
|
+
* the bug this replaces (see the module docstring), since it would still bias whichever
|
|
70
|
+
* team happens to stay tied with its opponent across multiple rounds.
|
|
71
|
+
*/
|
|
72
|
+
function assignHomeAway(teamA, teamB, roundIndex, slotIndex, homeCounts) {
|
|
73
|
+
const homeCountA = homeCounts.get(teamA) ?? 0;
|
|
74
|
+
const homeCountB = homeCounts.get(teamB) ?? 0;
|
|
75
|
+
if (homeCountA < homeCountB) {
|
|
76
|
+
return { home: teamA, away: teamB };
|
|
77
|
+
}
|
|
78
|
+
if (homeCountB < homeCountA) {
|
|
79
|
+
return { home: teamB, away: teamA };
|
|
80
|
+
}
|
|
81
|
+
return (roundIndex + slotIndex) % 2 === 0
|
|
82
|
+
? { home: teamA, away: teamB }
|
|
83
|
+
: { home: teamB, away: teamA };
|
|
84
|
+
}
|
|
85
|
+
/** Standard circle-method rotation: seat 0 stays fixed, every other seat advances one
|
|
86
|
+
* position, with the last seat wrapping around to position 1. Returns a new array. */
|
|
87
|
+
function rotate(seats) {
|
|
88
|
+
if (seats.length <= 1) {
|
|
89
|
+
return [...seats];
|
|
90
|
+
}
|
|
91
|
+
const [first, ...rest] = seats;
|
|
92
|
+
const lastSeat = rest[rest.length - 1];
|
|
93
|
+
return [first, lastSeat, ...rest.slice(0, -1)];
|
|
94
|
+
}
|
|
95
|
+
/** The return leg of a double round-robin: same rounds, home/away swapped, renumbered to
|
|
96
|
+
* continue after the first leg. */
|
|
97
|
+
function mirrorLeg(firstLeg) {
|
|
98
|
+
return firstLeg.map((round) => ({
|
|
99
|
+
round: round.round + firstLeg.length,
|
|
100
|
+
matches: round.matches.map((match) => ({ home: match.away, away: match.home })),
|
|
101
|
+
byeTeam: round.byeTeam,
|
|
102
|
+
}));
|
|
103
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@leaguesolver/round-robin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Dependency-free, deterministic single/double round-robin schedule generator.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "22.20.1",
|
|
26
|
+
"@vitest/coverage-v8": "4.1.10",
|
|
27
|
+
"typescript": "5.9.3",
|
|
28
|
+
"vitest": "4.1.10"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.build.json",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:watch": "vitest",
|
|
34
|
+
"lint": "tsc --noEmit"
|
|
35
|
+
},
|
|
36
|
+
"main": "./dist/index.js",
|
|
37
|
+
"types": "./dist/index.d.ts"
|
|
38
|
+
}
|