140 lines
3.4 KiB
JavaScript
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,
|
|
};
|