add: elo data model and static rankings page
All checks were successful
Deploy on push / deploy (push) Has been skipped
All checks were successful
Deploy on push / deploy (push) Has been skipped
This commit is contained in:
139
scripts/lib/elo.js
Normal file
139
scripts/lib/elo.js
Normal file
@@ -0,0 +1,139 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const { repoRoot } = require("./meals");
|
||||
|
||||
const eloPath = path.join(repoRoot, "data", "elo.json");
|
||||
|
||||
function validateEloData(eloData) {
|
||||
if (!eloData || typeof eloData !== "object" || Array.isArray(eloData)) {
|
||||
throw new Error("data/elo.json must contain an object");
|
||||
}
|
||||
|
||||
for (const field of ["defaultRating", "kFactor"]) {
|
||||
if (
|
||||
typeof eloData[field] !== "number" ||
|
||||
!Number.isFinite(eloData[field]) ||
|
||||
eloData[field] <= 0
|
||||
) {
|
||||
throw new Error(`data/elo.json "${field}" must be a positive number`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(eloData.entries)) {
|
||||
throw new Error('data/elo.json "entries" must be an array');
|
||||
}
|
||||
|
||||
const ids = new Set();
|
||||
|
||||
for (const [index, entry] of eloData.entries.entries()) {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
throw new Error(`Elo entry ${index} must be an object`);
|
||||
}
|
||||
|
||||
if (typeof entry.id !== "string" || !/^\d+$/.test(entry.id)) {
|
||||
throw new Error(`Elo entry ${index} has an invalid id`);
|
||||
}
|
||||
|
||||
if (ids.has(entry.id)) {
|
||||
throw new Error(`Duplicate Elo entry id "${entry.id}" found in data/elo.json`);
|
||||
}
|
||||
|
||||
ids.add(entry.id);
|
||||
|
||||
if (
|
||||
typeof entry.rating !== "number" ||
|
||||
!Number.isFinite(entry.rating) ||
|
||||
entry.rating <= 0
|
||||
) {
|
||||
throw new Error(`Elo entry ${index} must have a positive numeric rating`);
|
||||
}
|
||||
|
||||
for (const field of ["wins", "losses"]) {
|
||||
if (!Number.isInteger(entry[field]) || entry[field] < 0) {
|
||||
throw new Error(`Elo entry ${index} field "${field}" must be a non-negative integer`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadEloData() {
|
||||
const eloData = JSON.parse(fs.readFileSync(eloPath, "utf8"));
|
||||
|
||||
validateEloData(eloData);
|
||||
|
||||
return eloData;
|
||||
}
|
||||
|
||||
function saveEloData(eloData) {
|
||||
validateEloData(eloData);
|
||||
fs.writeFileSync(eloPath, `${JSON.stringify(eloData, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function createDefaultEntry(id, defaultRating) {
|
||||
return {
|
||||
id,
|
||||
rating: defaultRating,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function syncEloWithMeals(meals) {
|
||||
const eloData = loadEloData();
|
||||
const entryById = new Map(eloData.entries.map((entry) => [entry.id, entry]));
|
||||
const syncedData = {
|
||||
...eloData,
|
||||
entries: meals.map((meal) => {
|
||||
const existingEntry = entryById.get(meal.id);
|
||||
return existingEntry
|
||||
? { ...existingEntry }
|
||||
: createDefaultEntry(meal.id, eloData.defaultRating);
|
||||
}),
|
||||
};
|
||||
|
||||
if (JSON.stringify(syncedData) !== JSON.stringify(eloData)) {
|
||||
saveEloData(syncedData);
|
||||
}
|
||||
|
||||
return syncedData;
|
||||
}
|
||||
|
||||
function compareRankedMeals(left, right) {
|
||||
if (right.rating !== left.rating) {
|
||||
return right.rating - left.rating;
|
||||
}
|
||||
|
||||
if (right.matches !== left.matches) {
|
||||
return right.matches - left.matches;
|
||||
}
|
||||
|
||||
return Number.parseInt(left.id, 10) - Number.parseInt(right.id, 10);
|
||||
}
|
||||
|
||||
function getRankedMeals(meals, eloData) {
|
||||
const entryById = new Map(eloData.entries.map((entry) => [entry.id, entry]));
|
||||
|
||||
return meals
|
||||
.map((meal) => {
|
||||
const entry = entryById.get(meal.id) || createDefaultEntry(meal.id, eloData.defaultRating);
|
||||
const matches = entry.wins + entry.losses;
|
||||
|
||||
return {
|
||||
...meal,
|
||||
rating: entry.rating,
|
||||
wins: entry.wins,
|
||||
losses: entry.losses,
|
||||
matches,
|
||||
};
|
||||
})
|
||||
.sort(compareRankedMeals);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
eloPath,
|
||||
getRankedMeals,
|
||||
loadEloData,
|
||||
saveEloData,
|
||||
syncEloWithMeals,
|
||||
};
|
||||
Reference in New Issue
Block a user