Files
gallery/scripts/lib/elo.js
Ryan Chou 26adbe617f
All checks were successful
Deploy on push / deploy (push) Has been skipped
add: elo data model and static rankings page
2026-03-22 20:18:28 -07:00

140 lines
3.4 KiB
JavaScript

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,
};