add: persistant storage of elo ranking
All checks were successful
Deploy on push / deploy (push) Has been skipped
All checks were successful
Deploy on push / deploy (push) Has been skipped
This commit is contained in:
241
scripts/lib/rankings-state.js
Normal file
241
scripts/lib/rankings-state.js
Normal file
@@ -0,0 +1,241 @@
|
||||
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,
|
||||
};
|
||||
154
scripts/serve.js
154
scripts/serve.js
@@ -3,9 +3,16 @@ const http = require("http");
|
||||
const path = require("path");
|
||||
|
||||
const { repoRoot } = require("./lib/meals");
|
||||
const {
|
||||
defaultStatePath,
|
||||
loadRankingsState,
|
||||
recordVote,
|
||||
resetRankingsState,
|
||||
} = require("./lib/rankings-state");
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 4321;
|
||||
const MAX_REQUEST_BODY_BYTES = 16 * 1024;
|
||||
const MIME_TYPES = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".gif": "image/gif",
|
||||
@@ -29,6 +36,7 @@ function parseArgs(argv) {
|
||||
const options = {
|
||||
host: process.env.HOST || DEFAULT_HOST,
|
||||
port: Number.parseInt(process.env.PORT || String(DEFAULT_PORT), 10),
|
||||
rankingsStatePath: path.resolve(process.env.RANKINGS_STATE_PATH || defaultStatePath),
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
@@ -44,6 +52,12 @@ function parseArgs(argv) {
|
||||
if (arg === "--port" && value) {
|
||||
options.port = Number.parseInt(value, 10);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--rankings-state-path" && value) {
|
||||
options.rankingsStatePath = path.resolve(value);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,9 +72,15 @@ function getContentType(filePath) {
|
||||
return MIME_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream";
|
||||
}
|
||||
|
||||
function resolveRequestPath(requestUrl) {
|
||||
const url = new URL(requestUrl, "http://localhost");
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
function hasHiddenSegment(pathname) {
|
||||
return pathname.split("/").some((segment) => segment.startsWith(".") && segment.length > 1);
|
||||
}
|
||||
|
||||
function resolveRequestPath(pathname) {
|
||||
if (hasHiddenSegment(pathname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestedPath = pathname === "/" ? "/index.html" : pathname;
|
||||
const absolutePath = path.resolve(repoRoot, `.${requestedPath}`);
|
||||
const withinRepo =
|
||||
@@ -77,6 +97,56 @@ function resolveRequestPath(requestUrl) {
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
function sendJson(response, statusCode, payload) {
|
||||
response.writeHead(statusCode, {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
response.end(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function sendText(response, statusCode, body) {
|
||||
response.writeHead(statusCode, {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function parseJsonBody(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
request.on("data", (chunk) => {
|
||||
totalBytes += chunk.length;
|
||||
|
||||
if (totalBytes > MAX_REQUEST_BODY_BYTES) {
|
||||
reject(new Error("Request body is too large"));
|
||||
request.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
request.on("end", () => {
|
||||
if (chunks.length === 0) {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
||||
} catch (error) {
|
||||
reject(new Error("Request body must be valid JSON"));
|
||||
}
|
||||
});
|
||||
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendFile(response, filePath) {
|
||||
const stream = fs.createReadStream(filePath);
|
||||
|
||||
@@ -87,24 +157,81 @@ function sendFile(response, filePath) {
|
||||
|
||||
stream.pipe(response);
|
||||
stream.on("error", () => {
|
||||
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
response.end("Failed to read file");
|
||||
sendText(response, 500, "Failed to read file");
|
||||
});
|
||||
}
|
||||
|
||||
function createServer() {
|
||||
return http.createServer((request, response) => {
|
||||
const filePath = resolveRequestPath(request.url || "/");
|
||||
async function handleApiRequest(request, response, pathname, options) {
|
||||
if (pathname === "/api/rankings" && request.method === "GET") {
|
||||
sendJson(response, 200, loadRankingsState({ statePath: options.rankingsStatePath }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === "/api/rankings/vote" && request.method === "POST") {
|
||||
const body = await parseJsonBody(request);
|
||||
sendJson(response, 200, recordVote(body, { statePath: options.rankingsStatePath }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === "/api/rankings/reset" && request.method === "POST") {
|
||||
sendJson(response, 200, resetRankingsState({ statePath: options.rankingsStatePath }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === "/api/rankings" || pathname === "/api/rankings/vote" || pathname === "/api/rankings/reset") {
|
||||
sendText(response, 405, "Method not allowed");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/")) {
|
||||
sendText(response, 404, "Not found");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function createServer(options) {
|
||||
return http.createServer(async (request, response) => {
|
||||
let pathname;
|
||||
|
||||
try {
|
||||
pathname = decodeURIComponent(new URL(request.url || "/", "http://localhost").pathname);
|
||||
} catch (error) {
|
||||
sendText(response, 400, "Bad request");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await handleApiRequest(request, response, pathname, options)) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
const statusCode =
|
||||
error.message === "Request body must be valid JSON" ||
|
||||
error.message === "Request body is too large" ||
|
||||
error.message.startsWith("Vote payload") ||
|
||||
error.message === "winnerId and loserId must be different"
|
||||
? 400
|
||||
: 500;
|
||||
|
||||
if (statusCode >= 500) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
sendJson(response, statusCode, { error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = resolveRequestPath(pathname);
|
||||
|
||||
if (!filePath) {
|
||||
response.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
response.end("Forbidden");
|
||||
sendText(response, 403, "Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
response.end("Not found");
|
||||
sendText(response, 404, "Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,10 +241,11 @@ function createServer() {
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const server = createServer();
|
||||
const server = createServer(options);
|
||||
|
||||
server.listen(options.port, options.host, () => {
|
||||
console.log(`Serving ${repoRoot} at http://${options.host}:${options.port}`);
|
||||
console.log(`Rankings state path: ${options.rankingsStatePath}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user