Some twiddling to prepare for a scoreboard update

This commit is contained in:
Neale Pickett 2023-09-14 19:08:44 -06:00
parent c0761933a9
commit c72d13af32
3 changed files with 114 additions and 72 deletions

View File

@ -39,8 +39,8 @@ class App {
/** /**
* Attempt to log in to the server. * Attempt to log in to the server.
* *
* @param {String} teamID * @param {string} teamID
* @param {String} teamName * @param {string} teamName
*/ */
async Login(teamID, teamName) { async Login(teamID, teamName) {
try { try {
@ -114,7 +114,7 @@ class App {
/** /**
* Render a login box. * Render a login box.
* *
* This just toggles visibility, there's nothing dynamic in a login box. * Just toggles visibility, there's nothing dynamic in a login box.
*/ */
renderLogin(element, visible) { renderLogin(element, visible) {
element.classList.toggle("hidden", !visible) element.classList.toggle("hidden", !visible)
@ -123,7 +123,7 @@ class App {
/** /**
* Render a puzzles box. * Render a puzzles box.
* *
* This updates the list of open puzzles, and adds mothball download links * Displays the list of open puzzles, and adds mothball download links
* if the server is in development mode. * if the server is in development mode.
*/ */
renderPuzzles(element, visible) { renderPuzzles(element, visible) {
@ -177,9 +177,4 @@ function init() {
} }
} }
if (document.readyState === "loading") { common.WhenDOMLoaded(init)
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}

View File

@ -7,8 +7,8 @@ class Hash {
* *
* Used until MOTH v3.5 * Used until MOTH v3.5
* *
* @param {String} buf Input * @param {string} buf Input
* @returns {Number} * @returns {number}
*/ */
static djb2(buf) { static djb2(buf) {
let h = 5381 let h = 5381
@ -23,8 +23,8 @@ class Hash {
/** /**
* Dan Bernstein hash with xor improvement * Dan Bernstein hash with xor improvement
* *
* @param {String} buf Input * @param {string} buf Input
* @returns {Number} * @returns {number}
*/ */
static djb2xor(buf) { static djb2xor(buf) {
let h = 5381 let h = 5381
@ -39,8 +39,8 @@ class Hash {
* *
* Used until MOTH v4.5 * Used until MOTH v4.5
* *
* @param {String} buf Input * @param {string} buf Input
* @returns {Promise.<String>} hex-encoded digest * @returns {Promise.<string>} hex-encoded digest
*/ */
static async sha256(buf) { static async sha256(buf) {
const msgUint8 = new TextEncoder().encode(buf) const msgUint8 = new TextEncoder().encode(buf)
@ -52,8 +52,8 @@ class Hash {
/** /**
* Hex-encode a byte array * Hex-encode a byte array
* *
* @param {Number[]} buf Byte array * @param {number[]} buf Byte array
* @returns {String} * @returns {string}
*/ */
static hexlify(buf) { static hexlify(buf) {
return buf.map(b => b.toString(16).padStart(2, "0")).join("") return buf.map(b => b.toString(16).padStart(2, "0")).join("")
@ -62,8 +62,8 @@ class Hash {
/** /**
* Apply every hash to the input buffer. * Apply every hash to the input buffer.
* *
* @param {String} buf Input * @param {string} buf Input
* @returns {Promise.<String[]>} * @returns {Promise.<string[]>}
*/ */
static async All(buf) { static async All(buf) {
return [ return [
@ -80,19 +80,19 @@ class Hash {
class Award { class Award {
constructor(when, teamid, category, points) { constructor(when, teamid, category, points) {
/** Unix epoch timestamp for this award /** Unix epoch timestamp for this award
* @type {Number} * @type {number}
*/ */
this.When = when this.When = when
/** Team ID this award belongs to /** Team ID this award belongs to
* @type {String} * @type {string}
*/ */
this.TeamID = teamid this.TeamID = teamid
/** Puzzle category for this award /** Puzzle category for this award
* @type {String} * @type {string}
*/ */
this.Category = category this.Category = category
/** Points value of this award /** Points value of this award
* @type {Number} * @type {number}
*/ */
this.Points = points this.Points = points
} }
@ -111,8 +111,8 @@ class Award {
class Puzzle { class Puzzle {
/** /**
* @param {Server} server * @param {Server} server
* @param {String} category * @param {string} category
* @param {Number} points * @param {number} points
*/ */
constructor (server, category, points) { constructor (server, category, points) {
if (points < 1) { if (points < 1) {
@ -173,7 +173,7 @@ class Puzzle {
/** /**
* Get a resource associated with this puzzle. * Get a resource associated with this puzzle.
* *
* @param {String} filename Attachment/Script to retrieve * @param {string} filename Attachment/Script to retrieve
* @returns {Promise.<Response>} * @returns {Promise.<Response>}
*/ */
Get(filename) { Get(filename) {
@ -193,8 +193,8 @@ class Puzzle {
* you still have to pick through a lot of potentially correct answers when * you still have to pick through a lot of potentially correct answers when
* it's done. * it's done.
* *
* @param {String} str User-submitted possible answer * @param {string} str User-submitted possible answer
* @returns {Promise.<Boolean>} * @returns {Promise.<boolean>}
*/ */
async IsPossiblyCorrect(str) { async IsPossiblyCorrect(str) {
let userAnswerHashes = await Hash.All(str) let userAnswerHashes = await Hash.All(str)
@ -215,8 +215,8 @@ class Puzzle {
* The returned promise will fail if anything goes wrong, including the * The returned promise will fail if anything goes wrong, including the
* proposed answer being rejected. * proposed answer being rejected.
* *
* @param {String} proposed Answer to submit * @param {string} proposed Answer to submit
* @returns {Promise.<String>} Success message * @returns {Promise.<string>} Success message
*/ */
SubmitAnswer(proposed) { SubmitAnswer(proposed) {
return this.server.SubmitAnswer(this.Category, this.Points, proposed) return this.server.SubmitAnswer(this.Category, this.Points, proposed)
@ -242,23 +242,23 @@ class State {
/** Configuration */ /** Configuration */
this.Config = { this.Config = {
/** Is the server in development mode? /** Is the server in development mode?
* @type {Boolean} * @type {boolean}
*/ */
Devel: obj.Config.Devel, Devel: obj.Config.Devel,
} }
/** Global messages, in HTML /** Global messages, in HTML
* @type {String} * @type {string}
*/ */
this.Messages = obj.Messages this.Messages = obj.Messages
/** Map from Team ID to Team Name /** Map from Team ID to Team Name
* @type {Object.<String,String>} * @type {Object.<string,string>}
*/ */
this.TeamNames = obj.TeamNames this.TeamNames = obj.TeamNames
/** Map from category name to puzzle point values /** Map from category name to puzzle point values
* @type {Object.<String,Number>} * @type {Object.<string,number>}
*/ */
this.PointsByCategory = obj.Puzzles this.PointsByCategory = obj.Puzzles
@ -271,7 +271,7 @@ class State {
/** /**
* Returns a sorted list of open category names * Returns a sorted list of open category names
* *
* @returns {String[]} List of categories * @returns {string[]} List of categories
*/ */
Categories() { Categories() {
let ret = [] let ret = []
@ -288,8 +288,8 @@ class State {
* The server adds a puzzle with 0 points in every "solved" category, * The server adds a puzzle with 0 points in every "solved" category,
* so this just checks whether there is a 0-point puzzle in the category's point list. * so this just checks whether there is a 0-point puzzle in the category's point list.
* *
* @param {String} category * @param {string} category
* @returns {Boolean} * @returns {boolean}
*/ */
ContainsUnsolved(category) { ContainsUnsolved(category) {
return !this.PointsByCategory[category].includes(0) return !this.PointsByCategory[category].includes(0)
@ -298,7 +298,7 @@ class State {
/** /**
* Is the server in development mode? * Is the server in development mode?
* *
* @returns {Boolean} * @returns {boolean}
*/ */
DevelopmentMode() { DevelopmentMode() {
return this.Config && this.Config.Devel return this.Config && this.Config.Devel
@ -310,7 +310,7 @@ class State {
* The returned list will be sorted by (category, points). * The returned list will be sorted by (category, points).
* If not categories are given, all puzzles will be returned. * If not categories are given, all puzzles will be returned.
* *
* @param {String} categories Limit results to these categories * @param {string} categories Limit results to these categories
* @returns {Puzzle[]} * @returns {Puzzle[]}
*/ */
Puzzles(...categories) { Puzzles(...categories) {
@ -335,8 +335,8 @@ class State {
* Has this puzzle been solved by this team? * Has this puzzle been solved by this team?
* *
* @param {Puzzle} puzzle * @param {Puzzle} puzzle
* @param {String} teamID Team to check, default the logged-in team * @param {string} teamID Team to check, default the logged-in team
* @returns {Boolean} * @returns {boolean}
*/ */
IsSolved(puzzle, teamID="self") { IsSolved(puzzle, teamID="self") {
for (let award of this.PointsLog) { for (let award of this.PointsLog) {
@ -350,6 +350,52 @@ class State {
} }
return false return false
} }
/**
* Map from team ID to points.
*
* A special "max" property contains the highest number of points in this map.
*
* @typedef {Object.<string, number>} TeamPointsDict
* @property {Number} max Highest number of points
*/
/**
* Map from category to PointsDict.
*
* @typedef {Object.<string, TeamPointsDict>} CategoryTeamPointsDict
*/
/**
* Score snapshot.
*
* @typedef {Object} ScoreSnapshot
* @property {number} when Epoch time of this snapshot
* @property {CategoryTeamPointsDict} snapshot
*/
/**
* Replay scores.
*
* @yields {ScoreSnapshot} Snapshot at a point in time
*/
* ScoreHistory() {
/** @type {CategoryTeamPointsDict} */
let categoryTeamPoints = {}
for (let award of this.PointsLog) {
let teamPoints = (categoryTeamPoints[award.Category] ??= {})
let points = teamPoints[award.TeamID] || 0
let max = teamPoints.max || 0
points += award.Points
teamPoints[award.TeamID] = points
teamPoints.max = Math.max(points, max)
/** @type ScoreSnapshot */
let snapshot = {when: award.When, snapshot: categoryTeamPoints}
yield snapshot
}
}
} }
/** /**
@ -360,7 +406,7 @@ class State {
*/ */
class Server { class Server {
/** /**
* @param {String | URL} baseUrl Base URL to server, for constructing API URLs * @param {string | URL} baseUrl Base URL to server, for constructing API URLs
*/ */
constructor(baseUrl) { constructor(baseUrl) {
if (!baseUrl) { if (!baseUrl) {
@ -380,8 +426,8 @@ class Server {
* This always sends teamID. * This always sends teamID.
* If args is set, POST will be used instead of GET * If args is set, POST will be used instead of GET
* *
* @param {String} path Path to API endpoint * @param {string} path Path to API endpoint
* @param {Object.<String,String>} args Key/Values to send in POST data * @param {Object.<string,string>} args Key/Values to send in POST data
* @returns {Promise.<Response>} Response * @returns {Promise.<Response>} Response
*/ */
fetch(path, args={}) { fetch(path, args={}) {
@ -400,8 +446,8 @@ class Server {
/** /**
* Send a request to a JSend API endpoint. * Send a request to a JSend API endpoint.
* *
* @param {String} path Path to API endpoint * @param {string} path Path to API endpoint
* @param {Object.<String,String>} args Key/Values to send in POST * @param {Object.<string,string>} args Key/Values to send in POST
* @returns {Promise.<Object>} JSend Data * @returns {Promise.<Object>} JSend Data
*/ */
async call(path, args={}) { async call(path, args={}) {
@ -434,7 +480,7 @@ class Server {
/** /**
* Are we logged in to the server? * Are we logged in to the server?
* *
* @returns {Boolean} * @returns {boolean}
*/ */
LoggedIn() { LoggedIn() {
return this.TeamID ? true : false return this.TeamID ? true : false
@ -467,9 +513,9 @@ class Server {
* This calls the server's registration endpoint; if the call succeds, or * This calls the server's registration endpoint; if the call succeds, or
* fails with "team already exists", the login is returned as successful. * fails with "team already exists", the login is returned as successful.
* *
* @param {String} teamID * @param {string} teamID
* @param {String} teamName * @param {string} teamName
* @returns {Promise.<String>} Success message from server * @returns {Promise.<string>} Success message from server
*/ */
async Login(teamID, teamName) { async Login(teamID, teamName) {
let data = await this.call("/register", {id: teamID, name: teamName}) let data = await this.call("/register", {id: teamID, name: teamName})
@ -485,10 +531,10 @@ class Server {
* The returned promise will fail if anything goes wrong, including the * The returned promise will fail if anything goes wrong, including the
* proposed answer being rejected. * proposed answer being rejected.
* *
* @param {String} category Category of puzzle * @param {string} category Category of puzzle
* @param {Number} points Point value of puzzle * @param {number} points Point value of puzzle
* @param {String} proposed Answer to submit * @param {string} proposed Answer to submit
* @returns {Promise.<String>} Success message * @returns {Promise.<string>} Success message
*/ */
async SubmitAnswer(category, points, proposed) { async SubmitAnswer(category, points, proposed) {
let data = await this.call("/answer", { let data = await this.call("/answer", {
@ -502,9 +548,9 @@ class Server {
/** /**
* Fetch a file associated with a puzzle. * Fetch a file associated with a puzzle.
* *
* @param {String} category Category of puzzle * @param {string} category Category of puzzle
* @param {Number} points Point value of puzzle * @param {number} points Point value of puzzle
* @param {String} filename * @param {string} filename
* @returns {Promise.<Response>} * @returns {Promise.<Response>}
*/ */
GetContent(category, points, filename) { GetContent(category, points, filename) {
@ -517,8 +563,8 @@ class Server {
* New Puzzle objects only know their category and point value. * New Puzzle objects only know their category and point value.
* See docstrings on the Puzzle object for more information. * See docstrings on the Puzzle object for more information.
* *
* @param {String} category * @param {string} category
* @param {Number} points * @param {number} points
* @returns {Puzzle} * @returns {Puzzle}
*/ */
GetPuzzle(category, points) { GetPuzzle(category, points) {

View File

@ -9,7 +9,7 @@ const server = new moth.Server(".")
/** /**
* Handle a submit event on a form. * Handle a submit event on a form.
* *
* This event will be called when the user submits the form, * Called when the user submits the form,
* either by clicking a "submit" button, * either by clicking a "submit" button,
* or by some other means provided by the browser, * or by some other means provided by the browser,
* like hitting the Enter key. * like hitting the Enter key.
@ -22,7 +22,7 @@ async function formSubmitHandler(event) {
let proposed = data.get("answer") let proposed = data.get("answer")
let message let message
console.group("Submit answer") console.groupCollapsed("Submit answer")
console.info(`Proposed answer: ${proposed}`) console.info(`Proposed answer: ${proposed}`)
try { try {
message = await window.app.puzzle.SubmitAnswer(proposed) message = await window.app.puzzle.SubmitAnswer(proposed)
@ -56,7 +56,7 @@ async function answerInputHandler(event) {
/** /**
* Return the puzzle content element, possibly with everything cleared out of it. * Return the puzzle content element, possibly with everything cleared out of it.
* *
* @param {Boolean} clear Should the element be cleared of children? Default true. * @param {boolean} clear Should the element be cleared of children? Default true.
* @returns {Element} * @returns {Element}
*/ */
function puzzleElement(clear=true) { function puzzleElement(clear=true) {
@ -70,9 +70,10 @@ function puzzleElement(clear=true) {
/** /**
* Display an error in the puzzle area, and also send it to the console. * Display an error in the puzzle area, and also send it to the console.
* *
* This makes it so the user can see a bit more about what the problem is. * Errors are rendered in the puzzle area, so the user can see a bit more about
* what the problem is.
* *
* @param {String} error * @param {string} error
*/ */
function error(error) { function error(error) {
console.error(error) console.error(error)
@ -84,9 +85,9 @@ function error(error) {
/** /**
* Set the answer and invoke input handlers. * Set the answer and invoke input handlers.
* *
* This makes sure the Circle Of Success gets updated. * Makes sure the Circle Of Success gets updated.
* *
* @param {String} s * @param {string} s
*/ */
function SetAnswer(s) { function SetAnswer(s) {
let e = document.querySelector("#answer") let e = document.querySelector("#answer")
@ -125,11 +126,11 @@ function writeObject(e, obj) {
/** /**
* Load the given puzzle. * Load the given puzzle.
* *
* @param {String} category * @param {string} category
* @param {Number} points * @param {number} points
*/ */
async function loadPuzzle(category, points) { async function loadPuzzle(category, points) {
console.group("Loading puzzle:", category, points) console.groupCollapsed("Loading puzzle:", category, points)
let contentBase = new URL(`content/${category}/${points}/`, location) let contentBase = new URL(`content/${category}/${points}/`, location)
// Tell user we're loading // Tell user we're loading