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 isValidMealId(id) { return typeof id === "string" && /^\d+$/.test(id); } 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, undo: 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 syncStateCore(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, undo: null, elo: { defaultRating: seedData.elo.defaultRating, kFactor: seedData.elo.kFactor, entries, }, }; } function createPairKey(leftId, rightId) { return [leftId, rightId].sort().join(":"); } function createUndoSnapshot(state) { return { voteCount: state.voteCount, lastPairKey: state.lastPairKey, updatedAt: state.updatedAt, elo: { defaultRating: state.elo.defaultRating, kFactor: state.elo.kFactor, entries: state.elo.entries.map(cloneEntry), }, }; } function resolveUndoPairIds(winnerId, loserId, leftId, rightId) { const validPair = isValidMealId(leftId) && isValidMealId(rightId) && leftId !== rightId && [leftId, rightId].includes(winnerId) && [leftId, rightId].includes(loserId); if (validPair) { return { leftId, rightId }; } return { leftId: winnerId, rightId: loserId }; } function syncUndo(seedData, storedUndo) { if (!storedUndo || typeof storedUndo !== "object" || Array.isArray(storedUndo)) { return null; } const { leftId, rightId, winnerId, loserId, snapshot } = storedUndo; if ( !isValidMealId(leftId) || !isValidMealId(rightId) || !isValidMealId(winnerId) || !isValidMealId(loserId) || leftId === rightId || winnerId === loserId ) { return null; } const pairIds = new Set([leftId, rightId]); const mealIds = new Set(seedData.meals.map((meal) => meal.id)); if ( !pairIds.has(winnerId) || !pairIds.has(loserId) || ![leftId, rightId, winnerId, loserId].every((id) => mealIds.has(id)) ) { return null; } return { pairKey: createPairKey(leftId, rightId), leftId, rightId, winnerId, loserId, snapshot: createUndoSnapshot(syncStateCore(seedData, snapshot)), }; } function syncStoredState(seedData, storedState) { const nextState = syncStateCore(seedData, storedState); return { ...nextState, undo: syncUndo(seedData, storedState?.undo), }; } function restoreUndoSnapshot(seedData, snapshot) { return { ...syncStateCore(seedData, snapshot), undo: null, }; } function applyVote(seedData, state, vote) { const { winnerId, loserId, pairKey, leftId, rightId } = vote; const entryById = new Map(state.elo.entries.map((entry) => [entry.id, cloneEntry(entry)])); const winner = entryById.get(winnerId); const loser = entryById.get(loserId); const undoPair = resolveUndoPairIds(winnerId, loserId, leftId, rightId); const resolvedPairKey = typeof pairKey === "string" && pairKey.length > 0 ? pairKey : createPairKey(undoPair.leftId, undoPair.rightId); 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: resolvedPairKey, updatedAt: new Date().toISOString(), undo: { pairKey: createPairKey(undoPair.leftId, undoPair.rightId), leftId: undoPair.leftId, rightId: undoPair.rightId, winnerId, loserId, snapshot: createUndoSnapshot(state), }, elo: { ...state.elo, entries: seedData.meals.map((meal) => entryById.get(meal.id)), }, }; } function undoVote(seedData, state) { if (!state.undo) { return state; } return restoreUndoSnapshot(seedData, state.undo.snapshot); } 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, leftId, rightId } = vote; if (!isValidMealId(winnerId)) { throw new Error("Vote payload is missing a valid winnerId"); } if (!isValidMealId(loserId)) { throw new Error("Vote payload is missing a valid loserId"); } if (winnerId === loserId) { throw new Error("winnerId and loserId must be different"); } if (leftId !== undefined || rightId !== undefined) { if (!isValidMealId(leftId) || !isValidMealId(rightId) || leftId === rightId) { throw new Error("Vote payload is missing a valid left/right pair"); } if (![leftId, rightId].includes(winnerId) || ![leftId, rightId].includes(loserId)) { throw new Error("Vote payload leftId/rightId must match winnerId/loserId"); } } const statePath = resolveStatePath(options.statePath); const seedData = loadSeedData(); const storedState = syncStoredState(seedData, readPersistedState(statePath)); const nextState = applyVote(seedData, storedState, { winnerId, loserId, pairKey, leftId, rightId, }); writePersistedState(statePath, nextState); return nextState; } function undoLastRankingsVote(options = {}) { const statePath = resolveStatePath(options.statePath); const seedData = loadSeedData(); const storedState = syncStoredState(seedData, readPersistedState(statePath)); const nextState = undoVote(seedData, storedState); 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, undoLastRankingsVote, };