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 getEloAlignmentReport(meals, eloData) { const mealIds = new Set(meals.map((meal) => meal.id)); const eloIds = new Set(eloData.entries.map((entry) => entry.id)); return { missingEntryIds: meals .map((meal) => meal.id) .filter((mealId) => !eloIds.has(mealId)), unexpectedEntryIds: eloData.entries .map((entry) => entry.id) .filter((entryId) => !mealIds.has(entryId)), }; } 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, getEloAlignmentReport, getRankedMeals, loadEloData, saveEloData, syncEloWithMeals, };