381 lines
9.8 KiB
JavaScript
381 lines
9.8 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 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,
|
|
};
|