pinewood-derby-scheduler 1.0.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 +7 -0
- package/README.md +67 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +208 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 Thomas Lee
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# pinewood-derby-scheduler
|
|
2
|
+
|
|
3
|
+
[](https://github.com/sbma44/pinewood-derby-scheduler/actions/workflows/node.js.yml)
|
|
4
|
+
|
|
5
|
+
A lane assignment scheduler for pinewood derby races. Generates fair race schedules that maximize lane diversity and opponent variety.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install pinewood-derby-scheduler
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { schedule } from 'pinewood-derby-scheduler';
|
|
17
|
+
|
|
18
|
+
// Define your racers (can be any shape)
|
|
19
|
+
const racers = [
|
|
20
|
+
{ id: 1, name: 'Lightning' },
|
|
21
|
+
{ id: 2, name: 'Thunder' },
|
|
22
|
+
{ id: 3, name: 'Rocket' },
|
|
23
|
+
{ id: 4, name: 'Blaze' },
|
|
24
|
+
{ id: 5, name: 'Storm' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// Generate a schedule
|
|
28
|
+
const raceSchedule = schedule(racers, {
|
|
29
|
+
numLanes: 4, // 4-lane track
|
|
30
|
+
heatsPerRacer: 3, // each car races 3 times
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// raceSchedule is a 2D array: [heat][lane]
|
|
34
|
+
raceSchedule.forEach((heat, i) => {
|
|
35
|
+
console.log(`Heat ${i + 1}:`, heat.map(r => r?.name ?? '(empty)'));
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Prioritizing Opponents Over Lanes
|
|
40
|
+
|
|
41
|
+
By default, the scheduler prioritizes lane diversity (each racer uses different lanes). You can switch to prioritize opponent diversity instead:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
const raceSchedule = schedule(racers, {
|
|
45
|
+
numLanes: 4,
|
|
46
|
+
heatsPerRacer: 4,
|
|
47
|
+
prioritize: 'opponents', // maximize unique matchups
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## API
|
|
52
|
+
|
|
53
|
+
### `schedule<T>(racers: T[], options: ScheduleOptions): Schedule<T>`
|
|
54
|
+
|
|
55
|
+
Generates a race schedule.
|
|
56
|
+
|
|
57
|
+
**Parameters:**
|
|
58
|
+
- `racers` — Array of racer objects (any shape)
|
|
59
|
+
- `options.numLanes` — Number of lanes on the track
|
|
60
|
+
- `options.heatsPerRacer` — How many heats each racer participates in
|
|
61
|
+
- `options.prioritize` — `'lanes'` (default) or `'opponents'`
|
|
62
|
+
|
|
63
|
+
**Returns:** A 2D array where `result[heatIndex][laneIndex]` is a racer or `null` (empty lane).
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduling criteria that can be prioritized.
|
|
3
|
+
* - 'lanes': Lane diversity (each racer uses different lanes)
|
|
4
|
+
* - 'opponents': Opponent diversity (each racer faces different opponents)
|
|
5
|
+
* - 'turnover': Minimize cars appearing in consecutive heats (faster race setup)
|
|
6
|
+
*/
|
|
7
|
+
type ScheduleCriterion = 'lanes' | 'opponents' | 'turnover';
|
|
8
|
+
/**
|
|
9
|
+
* Options for generating a race schedule.
|
|
10
|
+
*/
|
|
11
|
+
interface ScheduleOptions {
|
|
12
|
+
/** Number of lanes on the track */
|
|
13
|
+
numLanes: number;
|
|
14
|
+
/** Number of heats each racer should participate in */
|
|
15
|
+
heatsPerRacer: number;
|
|
16
|
+
/**
|
|
17
|
+
* Priority ordering for scheduling criteria.
|
|
18
|
+
* Can be specified as:
|
|
19
|
+
* - A single criterion string (backward compatible): 'lanes' | 'opponents'
|
|
20
|
+
* - An array of criteria in priority order (first = highest priority)
|
|
21
|
+
*
|
|
22
|
+
* Default: ['lanes', 'opponents', 'turnover']
|
|
23
|
+
*
|
|
24
|
+
* All criteria are always considered; this controls their relative weights.
|
|
25
|
+
* First criterion gets weight 1000, second gets 100, third gets 10.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* // Prioritize turnover (fast setup), then opponents, then lanes
|
|
29
|
+
* prioritize: ['turnover', 'opponents', 'lanes']
|
|
30
|
+
*/
|
|
31
|
+
prioritize?: ScheduleCriterion | ScheduleCriterion[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A single heat in the schedule, where each index represents a lane.
|
|
35
|
+
* Contains the racer object or null if the lane is empty.
|
|
36
|
+
*/
|
|
37
|
+
type Heat<T> = (T | null)[];
|
|
38
|
+
/**
|
|
39
|
+
* The complete race schedule as a 2D array.
|
|
40
|
+
* First dimension is heats, second dimension is lanes.
|
|
41
|
+
*/
|
|
42
|
+
type Schedule<T> = Heat<T>[];
|
|
43
|
+
/**
|
|
44
|
+
* Generates a race schedule assigning racers to lanes across multiple heats.
|
|
45
|
+
*
|
|
46
|
+
* @param racers - Array of racer objects (can be any shape)
|
|
47
|
+
* @param options - Scheduling options (numLanes, heatsPerRacer)
|
|
48
|
+
* @returns A 2D array where result[heatIndex][laneIndex] is a racer or null
|
|
49
|
+
* @throws Error if inputs are invalid (e.g., numLanes < 1, empty racers)
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* const racers = [{ id: 1, name: 'Car A' }, { id: 2, name: 'Car B' }];
|
|
54
|
+
* const schedule = schedule(racers, { numLanes: 4, heatsPerRacer: 3 });
|
|
55
|
+
* // schedule[0] is the first heat: [racer, racer, null, null]
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
declare function schedule<T>(racers: T[], options: ScheduleOptions): Schedule<T>;
|
|
59
|
+
|
|
60
|
+
export { type Heat, type Schedule, type ScheduleCriterion, type ScheduleOptions, schedule };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
function schedule(racers, options) {
|
|
3
|
+
const { numLanes, heatsPerRacer, prioritize = ["lanes", "opponents", "turnover"] } = options;
|
|
4
|
+
const priorityList = Array.isArray(prioritize) ? prioritize : prioritize === "lanes" ? ["lanes", "opponents", "turnover"] : ["opponents", "lanes", "turnover"];
|
|
5
|
+
const weights = { lanes: 1, opponents: 1, turnover: 1 };
|
|
6
|
+
const weightValues = [1e3, 100, 10];
|
|
7
|
+
for (let i = 0; i < priorityList.length; i++) {
|
|
8
|
+
weights[priorityList[i]] = weightValues[i] ?? 1;
|
|
9
|
+
}
|
|
10
|
+
const allCriteria = ["lanes", "opponents", "turnover"];
|
|
11
|
+
for (const criterion of allCriteria) {
|
|
12
|
+
if (!priorityList.includes(criterion)) {
|
|
13
|
+
weights[criterion] = 1;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (!Array.isArray(racers)) {
|
|
17
|
+
throw new Error("racers must be an array");
|
|
18
|
+
}
|
|
19
|
+
if (racers.length === 0) {
|
|
20
|
+
throw new Error("racers array cannot be empty");
|
|
21
|
+
}
|
|
22
|
+
if (!Number.isInteger(numLanes) || numLanes < 1) {
|
|
23
|
+
throw new Error("numLanes must be a positive integer");
|
|
24
|
+
}
|
|
25
|
+
if (!Number.isInteger(heatsPerRacer) || heatsPerRacer < 1) {
|
|
26
|
+
throw new Error("heatsPerRacer must be a positive integer");
|
|
27
|
+
}
|
|
28
|
+
const totalSlots = racers.length * heatsPerRacer;
|
|
29
|
+
const numHeats = Math.max(heatsPerRacer, Math.ceil(totalSlots / numLanes));
|
|
30
|
+
const result = [];
|
|
31
|
+
for (let i = 0; i < numHeats; i++) {
|
|
32
|
+
const heat = new Array(numLanes).fill(null);
|
|
33
|
+
result.push(heat);
|
|
34
|
+
}
|
|
35
|
+
const necessaryByes = numHeats * numLanes - totalSlots;
|
|
36
|
+
const byeSlots = /* @__PURE__ */ new Set();
|
|
37
|
+
function getLanePriority(n) {
|
|
38
|
+
const priority = [];
|
|
39
|
+
let left = 0, right = n - 1;
|
|
40
|
+
while (left <= right) {
|
|
41
|
+
if (left === right) {
|
|
42
|
+
priority.push(left);
|
|
43
|
+
} else {
|
|
44
|
+
priority.push(left, right);
|
|
45
|
+
}
|
|
46
|
+
left++;
|
|
47
|
+
right--;
|
|
48
|
+
}
|
|
49
|
+
return priority;
|
|
50
|
+
}
|
|
51
|
+
const laneLeft = 0;
|
|
52
|
+
const laneRight = numLanes - 1;
|
|
53
|
+
let leftByes = 0, rightByes = 0;
|
|
54
|
+
let heatIdx = result.length - 1;
|
|
55
|
+
let byesPlaced = 0;
|
|
56
|
+
const laneOrder = getLanePriority(numLanes);
|
|
57
|
+
while (byesPlaced < necessaryByes) {
|
|
58
|
+
let chooseEdge;
|
|
59
|
+
if (leftByes < rightByes) {
|
|
60
|
+
chooseEdge = laneLeft;
|
|
61
|
+
} else if (rightByes < leftByes) {
|
|
62
|
+
chooseEdge = laneRight;
|
|
63
|
+
} else {
|
|
64
|
+
chooseEdge = byesPlaced % 2 === 0 ? laneLeft : laneRight;
|
|
65
|
+
}
|
|
66
|
+
let assigned = false;
|
|
67
|
+
for (const idx of laneOrder) {
|
|
68
|
+
if (chooseEdge === laneLeft && idx !== laneLeft || chooseEdge === laneRight && idx !== laneRight) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const key = `${heatIdx},${idx}`;
|
|
72
|
+
if (!byeSlots.has(key)) {
|
|
73
|
+
byeSlots.add(key);
|
|
74
|
+
if (idx === laneLeft) leftByes++;
|
|
75
|
+
if (idx === laneRight) rightByes++;
|
|
76
|
+
byesPlaced++;
|
|
77
|
+
assigned = true;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!assigned) {
|
|
82
|
+
for (const idx of laneOrder) {
|
|
83
|
+
const key = `${heatIdx},${idx}`;
|
|
84
|
+
if (!byeSlots.has(key)) {
|
|
85
|
+
byeSlots.add(key);
|
|
86
|
+
if (idx === laneLeft) leftByes++;
|
|
87
|
+
if (idx === laneRight) rightByes++;
|
|
88
|
+
byesPlaced++;
|
|
89
|
+
assigned = true;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
heatIdx--;
|
|
95
|
+
if (heatIdx < 0) heatIdx = result.length - 1;
|
|
96
|
+
}
|
|
97
|
+
const isByeSlot = (heat, lane) => byeSlots.has(`${heat},${lane}`);
|
|
98
|
+
const heatsRemaining = new Array(racers.length).fill(heatsPerRacer);
|
|
99
|
+
const lanesUsed = racers.map(() => /* @__PURE__ */ new Set());
|
|
100
|
+
const racedTogether = /* @__PURE__ */ new Set();
|
|
101
|
+
const pairKey = (a, b) => a < b ? `${a},${b}` : `${b},${a}`;
|
|
102
|
+
let previousHeatRacers = /* @__PURE__ */ new Set();
|
|
103
|
+
const availableLanesPerHeat = [];
|
|
104
|
+
for (let heat = 0; heat < numHeats; heat++) {
|
|
105
|
+
const lanes = [];
|
|
106
|
+
for (let lane = 0; lane < numLanes; lane++) {
|
|
107
|
+
if (!isByeSlot(heat, lane)) {
|
|
108
|
+
lanes.push(lane);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
availableLanesPerHeat.push(lanes);
|
|
112
|
+
}
|
|
113
|
+
for (let heat = 0; heat < numHeats; heat++) {
|
|
114
|
+
const slotsInHeat = availableLanesPerHeat[heat].length;
|
|
115
|
+
const selectedRacers = [];
|
|
116
|
+
for (let slot = 0; slot < slotsInHeat; slot++) {
|
|
117
|
+
const candidates = [];
|
|
118
|
+
for (let r = 0; r < racers.length; r++) {
|
|
119
|
+
if (heatsRemaining[r] > 0 && !selectedRacers.includes(r)) {
|
|
120
|
+
candidates.push(r);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (candidates.length === 0) break;
|
|
124
|
+
let bestCandidate = candidates[0];
|
|
125
|
+
let bestScore = -Infinity;
|
|
126
|
+
const availableLanesNow = availableLanesPerHeat[heat];
|
|
127
|
+
for (const candidate of candidates) {
|
|
128
|
+
let newOpponents = 0;
|
|
129
|
+
let repeatOpponents = 0;
|
|
130
|
+
for (const alreadySelected of selectedRacers) {
|
|
131
|
+
if (racedTogether.has(pairKey(candidate, alreadySelected))) {
|
|
132
|
+
repeatOpponents++;
|
|
133
|
+
} else {
|
|
134
|
+
newOpponents++;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
let unusedLanesAvailable = 0;
|
|
138
|
+
for (const lane of availableLanesNow) {
|
|
139
|
+
if (!lanesUsed[candidate].has(lane)) {
|
|
140
|
+
unusedLanesAvailable++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const hasUnusedLane = unusedLanesAvailable > 0 ? 1 : 0;
|
|
144
|
+
const wasInPreviousHeat = previousHeatRacers.has(candidate) ? 1 : 0;
|
|
145
|
+
const turnoverScore = wasInPreviousHeat ? -1 : 1;
|
|
146
|
+
const laneScore = (hasUnusedLane * 10 + unusedLanesAvailable) * weights.lanes;
|
|
147
|
+
const opponentScore = (newOpponents * 2 - repeatOpponents * 5) * weights.opponents;
|
|
148
|
+
const turnoverScoreWeighted = turnoverScore * weights.turnover;
|
|
149
|
+
const score = laneScore + opponentScore + turnoverScoreWeighted + heatsRemaining[candidate] * 0.1;
|
|
150
|
+
if (score > bestScore || score === bestScore && candidate < bestCandidate) {
|
|
151
|
+
bestScore = score;
|
|
152
|
+
bestCandidate = candidate;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
selectedRacers.push(bestCandidate);
|
|
156
|
+
heatsRemaining[bestCandidate]--;
|
|
157
|
+
}
|
|
158
|
+
for (let i = 0; i < selectedRacers.length; i++) {
|
|
159
|
+
for (let j = i + 1; j < selectedRacers.length; j++) {
|
|
160
|
+
racedTogether.add(pairKey(selectedRacers[i], selectedRacers[j]));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
previousHeatRacers = new Set(selectedRacers);
|
|
164
|
+
const availableLanes = [...availableLanesPerHeat[heat]];
|
|
165
|
+
const racerLanePrefs = selectedRacers.map((r) => {
|
|
166
|
+
for (let offset = 0; offset < numLanes; offset++) {
|
|
167
|
+
const tryLane = (r + lanesUsed[r].size + offset) % numLanes;
|
|
168
|
+
if (!lanesUsed[r].has(tryLane)) {
|
|
169
|
+
return { racer: r, preferredLane: tryLane };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { racer: r, preferredLane: (r + lanesUsed[r].size) % numLanes };
|
|
173
|
+
});
|
|
174
|
+
racerLanePrefs.sort((a, b) => {
|
|
175
|
+
if (a.preferredLane !== b.preferredLane) return a.preferredLane - b.preferredLane;
|
|
176
|
+
return a.racer - b.racer;
|
|
177
|
+
});
|
|
178
|
+
for (const { racer, preferredLane } of racerLanePrefs) {
|
|
179
|
+
let assignedLane;
|
|
180
|
+
const prefIdx = availableLanes.indexOf(preferredLane);
|
|
181
|
+
if (prefIdx >= 0) {
|
|
182
|
+
assignedLane = preferredLane;
|
|
183
|
+
availableLanes.splice(prefIdx, 1);
|
|
184
|
+
} else if (availableLanes.length > 0) {
|
|
185
|
+
let foundUnused = false;
|
|
186
|
+
for (let i = 0; i < availableLanes.length; i++) {
|
|
187
|
+
if (!lanesUsed[racer].has(availableLanes[i])) {
|
|
188
|
+
assignedLane = availableLanes[i];
|
|
189
|
+
availableLanes.splice(i, 1);
|
|
190
|
+
foundUnused = true;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!foundUnused) {
|
|
195
|
+
assignedLane = availableLanes.shift();
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
result[heat][assignedLane] = racers[racer];
|
|
201
|
+
lanesUsed[racer].add(assignedLane);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
export {
|
|
207
|
+
schedule
|
|
208
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pinewood-derby-scheduler",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lane assignment scheduler for pinewood derby races",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/sbma44/pinewood-derby-scheduler.git"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist", "README.md", "LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup src/index.ts --format esm --dts",
|
|
26
|
+
"test": "vitest",
|
|
27
|
+
"test:run": "vitest run",
|
|
28
|
+
"site:dev": "vite site",
|
|
29
|
+
"site:build": "vite build site",
|
|
30
|
+
"site:preview": "vite preview site",
|
|
31
|
+
"prepublishOnly": "npm run build && npm run test:run"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"pinewood-derby",
|
|
35
|
+
"racing",
|
|
36
|
+
"scheduler",
|
|
37
|
+
"lane-assignment"
|
|
38
|
+
],
|
|
39
|
+
"author": "",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"tsup": "^8.0.1",
|
|
43
|
+
"typescript": "^5.3.3",
|
|
44
|
+
"vite": "^5.0.10",
|
|
45
|
+
"vitest": "^1.1.3"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|