Files
gallery/scripts/lib/rankings-state.js
Ryan Chou 9f60ab3cca
All checks were successful
Deploy on push / deploy (push) Has been skipped
add: persistant storage of elo ranking
2026-03-22 21:18:49 -07:00

242 lines
6.5 KiB
JavaScript

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