const fs = require("fs"); const path = require("path"); const { loadEloData } = require("./elo"); const { loadMeals, repoRoot } = require("./meals"); const STATE_VERSION = 1; const defaultStatePath = path.join(repoRoot, ".runtime", "rankings-state.json"); function createDefaultEntry(id, defaultRating) { return { id, rating: defaultRating, wins: 0, losses: 0, }; } function cloneEntry(entry) { return { id: entry.id, rating: entry.rating, wins: entry.wins, losses: entry.losses, }; } function roundRating(rating) { return Math.round(rating * 1000) / 1000; } function expectedScore(rating, opponentRating) { return 1 / (1 + Math.pow(10, (opponentRating - rating) / 400)); } function isValidStoredEntry(entry) { if (!entry || typeof entry !== "object" || Array.isArray(entry)) { return false; } if (typeof entry.id !== "string" || !/^\d+$/.test(entry.id)) { return false; } if (typeof entry.rating !== "number" || !Number.isFinite(entry.rating) || entry.rating <= 0) { return false; } return ["wins", "losses"].every( (field) => Number.isInteger(entry[field]) && entry[field] >= 0 ); } function createSeedState(seedData) { return { version: STATE_VERSION, voteCount: 0, lastPairKey: null, updatedAt: null, elo: { defaultRating: seedData.elo.defaultRating, kFactor: seedData.elo.kFactor, entries: seedData.meals.map((meal) => { const seedEntry = seedData.elo.entries.find((entry) => entry.id === meal.id) || createDefaultEntry(meal.id, seedData.elo.defaultRating); return cloneEntry(seedEntry); }), }, }; } function syncStoredState(seedData, storedState) { if (!storedState || typeof storedState !== "object" || Array.isArray(storedState)) { return createSeedState(seedData); } const storedEntries = Array.isArray(storedState.elo?.entries) ? storedState.elo.entries.filter(isValidStoredEntry) : []; const storedEntryById = new Map(storedEntries.map((entry) => [entry.id, entry])); const seedEntryById = new Map(seedData.elo.entries.map((entry) => [entry.id, entry])); const entries = seedData.meals.map((meal) => { const storedEntry = storedEntryById.get(meal.id); if (storedEntry) { return cloneEntry(storedEntry); } const seedEntry = seedEntryById.get(meal.id) || createDefaultEntry(meal.id, seedData.elo.defaultRating); return cloneEntry(seedEntry); }); const derivedVoteCount = entries.reduce((sum, entry) => sum + entry.wins, 0); return { version: STATE_VERSION, voteCount: Number.isInteger(storedState.voteCount) && storedState.voteCount >= derivedVoteCount ? storedState.voteCount : derivedVoteCount, lastPairKey: typeof storedState.lastPairKey === "string" ? storedState.lastPairKey : null, updatedAt: typeof storedState.updatedAt === "string" ? storedState.updatedAt : null, elo: { defaultRating: seedData.elo.defaultRating, kFactor: seedData.elo.kFactor, entries, }, }; } function createPairKey(leftId, rightId) { return [leftId, rightId].sort().join(":"); } function applyVote(seedData, state, winnerId, loserId, pairKey) { const entryById = new Map(state.elo.entries.map((entry) => [entry.id, cloneEntry(entry)])); const winner = entryById.get(winnerId); const loser = entryById.get(loserId); if (!winner || !loser) { throw new Error("Vote referenced an unknown meal id"); } const winnerExpected = expectedScore(winner.rating, loser.rating); const loserExpected = expectedScore(loser.rating, winner.rating); winner.rating = roundRating(winner.rating + state.elo.kFactor * (1 - winnerExpected)); loser.rating = roundRating(loser.rating + state.elo.kFactor * (0 - loserExpected)); winner.wins += 1; loser.losses += 1; return { ...state, voteCount: state.voteCount + 1, lastPairKey: pairKey || createPairKey(winnerId, loserId), updatedAt: new Date().toISOString(), elo: { ...state.elo, entries: seedData.meals.map((meal) => entryById.get(meal.id)), }, }; } function resolveStatePath(statePath) { return statePath ? path.resolve(statePath) : defaultStatePath; } function loadSeedData() { return { meals: loadMeals(), elo: loadEloData(), }; } function readPersistedState(statePath) { if (!fs.existsSync(statePath)) { return null; } return JSON.parse(fs.readFileSync(statePath, "utf8")); } function writePersistedState(statePath, state) { fs.mkdirSync(path.dirname(statePath), { recursive: true }); const nextState = `${JSON.stringify(state, null, 2)}\n`; const tempPath = `${statePath}.tmp`; fs.writeFileSync(tempPath, nextState); fs.renameSync(tempPath, statePath); } function loadRankingsState(options = {}) { const statePath = resolveStatePath(options.statePath); const seedData = loadSeedData(); const storedState = readPersistedState(statePath); return syncStoredState(seedData, storedState); } function saveRankingsState(state, options = {}) { const statePath = resolveStatePath(options.statePath); const seedData = loadSeedData(); const nextState = syncStoredState(seedData, state); writePersistedState(statePath, nextState); return nextState; } function recordVote(vote, options = {}) { if (!vote || typeof vote !== "object" || Array.isArray(vote)) { throw new Error("Vote payload must be an object"); } const { winnerId, loserId, pairKey } = vote; if (typeof winnerId !== "string" || !/^\d+$/.test(winnerId)) { throw new Error("Vote payload is missing a valid winnerId"); } if (typeof loserId !== "string" || !/^\d+$/.test(loserId)) { throw new Error("Vote payload is missing a valid loserId"); } if (winnerId === loserId) { throw new Error("winnerId and loserId must be different"); } const statePath = resolveStatePath(options.statePath); const seedData = loadSeedData(); const storedState = syncStoredState(seedData, readPersistedState(statePath)); const nextState = applyVote(seedData, storedState, winnerId, loserId, pairKey); writePersistedState(statePath, nextState); return nextState; } function resetRankingsState(options = {}) { const statePath = resolveStatePath(options.statePath); const seedData = loadSeedData(); const nextState = createSeedState(seedData); writePersistedState(statePath, nextState); return nextState; } module.exports = { createSeedState, defaultStatePath, loadRankingsState, recordVote, resetRankingsState, saveRankingsState, syncStoredState, };