From f7945fcf3b6cbaf8c3a733d0445c41b4be2b8af9 Mon Sep 17 00:00:00 2001 From: Ken Knudsen Date: Fri, 13 Aug 2021 00:22:15 +0000 Subject: [PATCH 01/34] Added responsive design elements and separated the scores from the team names to reduce overlap. Use side-by-side view on large screens. --- theme/basic.css | 95 +++++++++++++++++++++++++++++++++++++++++++++ theme/scoreboard.js | 8 +++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/theme/basic.css b/theme/basic.css index 14a5a1e..9a802a3 100644 --- a/theme/basic.css +++ b/theme/basic.css @@ -78,7 +78,34 @@ input:invalid { opacity: 0.8; position: absolute; right: 0.2em; + background-color: #292929; + background-blend-mode: darken; + padding: 0em 0.2em; + border-top-left-radius: 0.5em; + border-bottom-left-radius: 0.5em; + margin:0em; + height: 1.5em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition-property: max-width; + transition-duration: 2s; + transition-delay: 0s; } + +#rankings span.teamname:hover { + max-width: 100%; +} + +#rankings span.teampoints { + font-size:100%; + height:1.2em; + margin:0em; + padding:0em; + width:99%; +} + + #rankings div * {white-space: nowrap;} .cat0, .cat8, .cat16 {background-color: #a6cee3; color: black;} .cat1, .cat9, .cat17 {background-color: #1f78b4; color: white;} @@ -90,6 +117,74 @@ input:invalid { .cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;} +/* Responsive design */ +/* Defaults */ + #rankings span.teampoints { + max-width:89%; + } + #rankings span.teamname { + max-width:10%; + } + + +/* Monitors with large enough screens to do side by side */ +@media only screen and (min-width: 170em) { + #chart, #rankings { + width: 49%; + display:inline-block; + vertical-align:middle; + } + +} + +/* Monitor +@media only screen and (max-width: 130em) { + #chart, #rankings { + width: 49%; + display:inline-block; + vertical-align: middle; + } + + #rankings span.teampoints { + max-width:89%; + } + #rankings span.teamname { + max-width:10%; + } +} + +/* Laptop size screen */ +@media only screen and (max-width: 100em) { + #rankings span.teampoints { + max-width:84%; + } + #rankings span.teamname { + max-width:15%; + } +} + +/* Roughly Tablet size */ +@media only screen and (max-width: 70em) { + #rankings span.teampoints { + max-width:79%; + } + #rankings span.teamname { + max-width:20%; + } +} + +/* Small screens phone size */ +@media only screen and (max-width: 40em) { + #rankings span.teampoints { + max-width:65%; + } + #rankings span.teamname { + max-width:34%; + } +} + + + #devel { background-color: #eee; color: black; diff --git a/theme/scoreboard.js b/theme/scoreboard.js index 104efcb..040cf7a 100644 --- a/theme/scoreboard.js +++ b/theme/scoreboard.js @@ -125,6 +125,10 @@ function scoreboardInit() { for (let team of winners) { let row = document.createElement("div") let ncat = 0 + + let teamPoints=document.createElement("span") + teamPoints.classList.add("teampoints") + for (let category in highestCategoryScore) { let catHigh = highestCategoryScore[category] let catTeam = team.categoryScore[category] || 0 @@ -138,9 +142,11 @@ function scoreboardInit() { bar.textContent = category + ": " + catTeam bar.title = bar.textContent - row.appendChild(bar) + teamPoints.appendChild(bar) ncat += 1 } + + row.appendChild(teamPoints) let te = document.createElement("span") te.classList.add("teamname") From fcfa11b01237aebbe741cf025981f5ee2def8a77 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Fri, 1 Sep 2023 17:59:09 -0600 Subject: [PATCH 02/34] Initial work on #190 --- theme/moth.mjs | 83 ++++++++++++++++++++++++++++++++++++++++++++++++ theme/puzzle.mjs | 0 2 files changed, 83 insertions(+) create mode 100644 theme/moth.mjs create mode 100644 theme/puzzle.mjs diff --git a/theme/moth.mjs b/theme/moth.mjs new file mode 100644 index 0000000..daa5ad8 --- /dev/null +++ b/theme/moth.mjs @@ -0,0 +1,83 @@ +class Server { + constructor(baseUrl) { + this.baseUrl = new URL(baseUrl) + this.teamId = null + } + + /** + * Fetch a MOTH resource. + * + * This is just a convenience wrapper to always send teamId. + * If body is set, POST will be used instead of GET + * + * @param {String} path Path to API endpoint + * @param {Object} body Key/Values to send in POST data + * @returns {Promise} Response + */ + fetch(path, body) { + let url = new URL(path, this.baseUrl) + if (this.teamId & (!(body && body.id))) { + url.searchParams.set("id", this.teamId) + } + return fetch(url, { + method: body?"POST":"GET", + body, + }) + } + + /** + * Send a request to a JSend API endpoint. + * + * @param {String} path Path to API endpoint + * @param {Object} args Key/Values to send in POST + * @returns JSend Data + */ + async postJSend(path, args) { + let resp = await this.fetch(path, args) + if (!resp.ok) { + throw new Error(resp.statusText) + } + let obj = await resp.json() + switch (obj.status) { + case "success": + return obj.data + case "failure": + throw new Error(obj.data.description || obj.data.short || obj.data) + case "error": + throw new Error(obj.message) + default: + throw new Error(`Unknown JSend status: ${obj.status}`) + } + } + + /** + * Register a team name with a team ID. + * + * This is similar to, but not exactly the same as, logging in. + * See MOTH documentation for details. + * + * @param {String} teamId + * @param {String} teamName + * @returns {String} Success message from server + */ + async Register(teamId, teamName) { + let data = await postJSend("/login", {id: teamId, name: teamName}) + this.teamId = teamId + this.teamName = teamName + return data.description || data.short + } + + /** + * Fetch current contest status. + * + * @returns {Object} Contest status + */ + async Status() { + let data = await this.postJSend("/status") + return data + } +} + +export { + Server +} \ No newline at end of file diff --git a/theme/puzzle.mjs b/theme/puzzle.mjs new file mode 100644 index 0000000..e69de29 From 99d7245c498cbbe31ac3b380e3e398455048aebf Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 7 Sep 2023 16:16:46 -0600 Subject: [PATCH 03/34] Full moth.mjs, and an example to use it --- theme/moth.mjs | 341 ++++++++++++++++++++++++++++++++++++++--- theme/reports/ksa.html | 39 +++++ theme/reports/ksa.mjs | 49 ++++++ 3 files changed, 410 insertions(+), 19 deletions(-) create mode 100644 theme/reports/ksa.html create mode 100644 theme/reports/ksa.mjs diff --git a/theme/moth.mjs b/theme/moth.mjs index daa5ad8..26c5552 100644 --- a/theme/moth.mjs +++ b/theme/moth.mjs @@ -1,18 +1,284 @@ +/** + * A point award. + */ +class Award { + constructor(when, teamid, category, points) { + /** Unix epoch timestamp for this award + * @type {Number} + */ + this.When = when + /** Team ID this award belongs to + * @type {String} + */ + this.TeamID = teamid + /** Puzzle category for this award + * @type {String} + */ + this.Category = category + /** Points value of this award + * @type {Number} + */ + this.Points = points + } +} + +/** + * A puzzle. + * + * A new Puzzle only knows its category and point value. + * If you want to populate it with meta-information, you must call Get(). + */ +class Puzzle { + /** + * + * @param {Server} server + * @param {String} category + * @param {Number} points + */ + constructor (server, category, points) { + if (points < 1) { + throw(`Invalid points value: ${points}`) + } + + /** Server where this puzzle lives + * @type {Server} + */ + this.server = server + /** Category this puzzle belongs to + * @type {String} + */ + this.Category = category + /** Point value of this puzzle + * @type {Number} + */ + this.Points = points + } + + /** Error returned trying to fetch this puzzle */ + Error = { + /** Status code provided by server */ + Status: 0, + /** Status text provided by server */ + StatusText: "", + /** Full text of server error */ + Body: "", + } + /** Hashes of answers + * @type {String[]} + */ + AnswerHashes = [] + /** Pattern that answer should match + * @type {String[]} + */ + AnswerPattern = "" + /** Accepted answers + * @type {String[]} + */ + Answers = [] + /** Other files attached to this puzzles + * @type {String[]} + */ + Attachments = [] + /** This puzzle's authors + * @type {String[]} + */ + Authors = [] + /** HTML body of this puzzle */ + Body = "" + /** Debugging information */ + Debug = { + Errors: [], + Hints: [], + Log: [], + Notes: "", + Summary: "", + } + /** KSAs met by solving this puzzle + * @type {String[]} + */ + KSAs = [] + /** Learning objective for this puzzle */ + Objective = "" + /** ECMAScript scripts needed for this puzzle + * @type {String[]} + */ + Scripts = [] + /** Criteria for succeeding at this puzzle */ + Success = { + /** Acceptable Minimum criteria for success */ + Minimum: "", + /** Criteria for demonstrating mastery of this puzzle */ + Mastery: "", + } + + /** + * Populate this Puzzle object with meta-information from the server. + */ + async Populate() { + let resp = await this.Get("puzzle.json") + if (!resp.ok) { + let body = await resp.text() + this.Error = { + Status: resp.status, + StatusText: resp.statusText, + Body: body, + } + throw(this.Error) + } + let obj = await resp.json() + Object.assign(this, obj) + + // Make sure lists are lists + this.AnswerHashes ||= [] + this.Answers ||= [] + this.Attachments ||= [] + this.Authors ||= [] + this.Debug.Errors ||= [] + this.Debug.Hints ||= [] + this.Debug.Log ||= [] + this.KSAs ||= [] + this.Scripts ||= [] + } + + /** + * Get a resource associated with this puzzle. + * + * @param {String} filename Attachment/Script to retrieve + * @returns {Promise} + */ + Get(filename) { + return this.server.GetContent(this.Category, this.Points, filename) + } +} + +/** + * MOTH instance state. + * + * @property {Object} Config + * @property {Boolean} Config.Enabled Are points log updates enabled? + * @property {String} Messages Global broadcast messages, in HTML + * @property {Object.} TeamNames Mapping from IDs to team names + * @property {Object.} PointsByCategory Map from category name to open puzzle point values + * @property {Award[]} PointsLog Log of points awarded + */ +class State { + /** + * @param {Server} server Server where we got this + * @param {Object} obj Raw state data + */ + constructor(server, obj) { + for (let key of ["Config", "Messages", "TeamNames", "PointsLog"]) { + if (!obj[key]) { + throw(`Missing state property: ${key}`) + } + } + this.server = server + + /** Configuration */ + this.Config = { + /** Is the server in debug mode? + * @type {Boolean} + */ + Debug: obj.Config.Debug, + } + /** Global messages, in HTML + * @type {String} + */ + this.Messages = obj.Messages + /** Map from Team ID to Team Name + * @type {Object.} + */ + this.TeamNames = obj.TeamNames + /** Map from category name to puzzle point values + * @type {Object. new Award(t,i,c,p)) + } + + /** + * Returns a sorted list of open category names + * + * @returns {String[]} List of categories + */ + Categories() { + let ret = [] + for (let category in this.PointsByCategory) { + ret.push(category) + } + ret.sort() + return ret + } + + /** + * Check whether a category has unsolved puzzles. + * + * 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. + * + * @param {String} category + * @returns {Boolean} + */ + HasUnsolved(category) { + return !this.PointsByCategory[category].includes(0) + } + + /** + * Return all open puzzles. + * + * The returned list will be sorted by (category, points). + * If not categories are given, all puzzles will be returned. + * + * @param {String} categories Limit results to these categories + * @returns {Puzzle[]} + */ + Puzzles(...categories) { + if (categories.length == 0) { + categories = this.Categories() + } + let ret = [] + for (let category of categories) { + for (let points of this.PointsByCategory[category]) { + if (0 == points) { + // This means all potential puzzles in the category are open + continue + } + let p = new Puzzle(this.server, category, points) + ret.push(p) + } + } + return ret + } +} + +/** + * A MOTH Server interface. + * + * This uses localStorage to remember Team ID, + * and will send a Team ID with every request, if it can find one. + */ class Server { constructor(baseUrl) { - this.baseUrl = new URL(baseUrl) - this.teamId = null + this.baseUrl = new URL(baseUrl, location) + this.teameIdKey = this.baseUrl.toString() + " teamID" + this.teamId = localStorage[this.teameIdKey] } /** * Fetch a MOTH resource. * - * This is just a convenience wrapper to always send teamId. + * If anything other than a 2xx code is returned, + * this function throws an error. + * + * This always sends teamId. * If body is set, POST will be used instead of GET * * @param {String} path Path to API endpoint - * @param {Object} body Key/Values to send in POST data - * @returns {Promise} Response + * @param {Object} body Key/Values to send in POST data + * @returns {Promise} Response */ fetch(path, body) { let url = new URL(path, this.baseUrl) @@ -29,14 +295,11 @@ class Server { * Send a request to a JSend API endpoint. * * @param {String} path Path to API endpoint - * @param {Object} args Key/Values to send in POST - * @returns JSend Data + * @param {Object} args Key/Values to send in POST + * @returns {Promise} JSend Data */ - async postJSend(path, args) { + async call(path, args) { let resp = await this.fetch(path, args) - if (!resp.ok) { - throw new Error(resp.statusText) - } let obj = await resp.json() switch (obj.status) { case "success": @@ -50,6 +313,27 @@ class Server { } } + /** + * Forget about any previous Team ID. + * + * This is equivalent to logging out. + */ + Reset() { + localStorage.removeItem(this.teameIdKey) + this.teamId = null + } + + /** + * Fetch current contest state. + * + * @returns {State} + */ + async GetState() { + let resp = await this.fetch("/state") + let obj = await resp.json() + return new State(this, obj) + } + /** * Register a team name with a team ID. * @@ -58,23 +342,42 @@ class Server { * * @param {String} teamId * @param {String} teamName - * @returns {String} Success message from server + * @returns {Promise} Success message from server */ async Register(teamId, teamName) { - let data = await postJSend("/login", {id: teamId, name: teamName}) + let data = await this.call("/login", {id: teamId, name: teamName}) this.teamId = teamId this.teamName = teamName + localStorage[this.teameIdKey] = teamId return data.description || data.short } /** - * Fetch current contest status. - * - * @returns {Object} Contest status + * Submit a puzzle answer for points. + * + * The returned promise will fail if anything goes wrong, including the + * answer being rejected. + * + * @param {String} category Category of puzzle + * @param {Number} points Point value of puzzle + * @param {String} answer Answer to submit + * @returns {Promise} Was the answer accepted? */ - async Status() { - let data = await this.postJSend("/status") - return data + async SubmitAnswer(category, points, answer) { + await this.call("/answer", {category, points, answer}) + return true + } + + /** + * Fetch a file associated with a puzzle. + * + * @param {String} category Category of puzzle + * @param {Number} points Point value of puzzle + * @param {String} filename + * @returns {Promise} + */ + GetContent(category, points, filename) { + return this.fetch(`/content/${category}/${points}/${filename}`) } } diff --git a/theme/reports/ksa.html b/theme/reports/ksa.html new file mode 100644 index 0000000..592a355 --- /dev/null +++ b/theme/reports/ksa.html @@ -0,0 +1,39 @@ + + + + KSA Report + + + + +

KSA Report

+

+ This report shows all KSAs covered by this server so far. + This is not a report on your progress, but rather + what you would have covered if you had worked every exercise available. +

+ + + +
+ + + + + + + + + + + +
CategoryPointsKSAsErrors
+ + \ No newline at end of file diff --git a/theme/reports/ksa.mjs b/theme/reports/ksa.mjs new file mode 100644 index 0000000..82a555e --- /dev/null +++ b/theme/reports/ksa.mjs @@ -0,0 +1,49 @@ +import * as moth from "../moth.mjs" + +function doing(what) { + for (let e of document.querySelectorAll(".doing")) { + if (what) { + e.style.display = "inherit" + } else { + e.style.display = "none" + } + for (let p of e.querySelectorAll("p")) { + p.textContent = what + } + } +} + +async function init() { + let server = new moth.Server("../") + + doing("Retrieving server state") + let state = await server.GetState() + + doing("Retrieving all puzzles") + let puzzles = state.Puzzles() + for (let p of puzzles) { + await p.Populate().catch(x => {}) + } + + doing("Filling table") + let puzzlerowTemplate = document.querySelector("template#puzzlerow") + for (let tbody of document.querySelectorAll("tbody")) { + for (let puzzle of puzzles) { + let row = puzzlerowTemplate.content.cloneNode(true) + row.querySelector(".category").textContent = puzzle.Category + row.querySelector(".points").textContent = puzzle.Points + row.querySelector(".ksas").textContent = puzzle.KSAs.join(" ") + row.querySelector(".error").textContent = puzzle.Error.Body + tbody.appendChild(row) + } + } + + doing() +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init) +} else { + init() +} + \ No newline at end of file From 47671b9a121d4f832efbee1712db7cf85aa1cb8c Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 7 Sep 2023 16:32:06 -0600 Subject: [PATCH 04/34] jsdoc fixes (maybe?) --- theme/moth.mjs | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/theme/moth.mjs b/theme/moth.mjs index 26c5552..9369494 100644 --- a/theme/moth.mjs +++ b/theme/moth.mjs @@ -144,7 +144,7 @@ class Puzzle { * Get a resource associated with this puzzle. * * @param {String} filename Attachment/Script to retrieve - * @returns {Promise} + * @returns {Promise.} */ Get(filename) { return this.server.GetContent(this.Category, this.Points, filename) @@ -153,13 +153,6 @@ class Puzzle { /** * MOTH instance state. - * - * @property {Object} Config - * @property {Boolean} Config.Enabled Are points log updates enabled? - * @property {String} Messages Global broadcast messages, in HTML - * @property {Object.} TeamNames Mapping from IDs to team names - * @property {Object.} PointsByCategory Map from category name to open puzzle point values - * @property {Award[]} PointsLog Log of points awarded */ class State { /** @@ -190,7 +183,7 @@ class State { */ this.TeamNames = obj.TeamNames /** Map from category name to puzzle point values - * @type {Object.} */ this.PointsByCategory = obj.Puzzles /** Log of points awarded @@ -277,8 +270,8 @@ class Server { * If body is set, POST will be used instead of GET * * @param {String} path Path to API endpoint - * @param {Object} body Key/Values to send in POST data - * @returns {Promise} Response + * @param {Object.} body Key/Values to send in POST data + * @returns {Promise.} Response */ fetch(path, body) { let url = new URL(path, this.baseUrl) @@ -295,8 +288,8 @@ class Server { * Send a request to a JSend API endpoint. * * @param {String} path Path to API endpoint - * @param {Object} args Key/Values to send in POST - * @returns {Promise} JSend Data + * @param {Object.} args Key/Values to send in POST + * @returns {Promise.} JSend Data */ async call(path, args) { let resp = await this.fetch(path, args) @@ -342,7 +335,7 @@ class Server { * * @param {String} teamId * @param {String} teamName - * @returns {Promise} Success message from server + * @returns {Promise.} Success message from server */ async Register(teamId, teamName) { let data = await this.call("/login", {id: teamId, name: teamName}) @@ -361,7 +354,7 @@ class Server { * @param {String} category Category of puzzle * @param {Number} points Point value of puzzle * @param {String} answer Answer to submit - * @returns {Promise} Was the answer accepted? + * @returns {Promise.} Was the answer accepted? */ async SubmitAnswer(category, points, answer) { await this.call("/answer", {category, points, answer}) @@ -374,7 +367,7 @@ class Server { * @param {String} category Category of puzzle * @param {Number} points Point value of puzzle * @param {String} filename - * @returns {Promise} + * @returns {Promise.} */ GetContent(category, points, filename) { return this.fetch(`/content/${category}/${points}/${filename}`) From 8ff91e79ec0ca394d3ee6ea4769c0fd53f8f9000 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 7 Sep 2023 17:29:21 -0600 Subject: [PATCH 05/34] Refer to server docs for Puzzle fields --- pkg/transpile/puzzle.go | 46 ++++++++++++++++------ theme/moth.mjs | 86 ++++++++++------------------------------- theme/reports/ksa.mjs | 14 +++---- 3 files changed, 59 insertions(+), 87 deletions(-) diff --git a/pkg/transpile/puzzle.go b/pkg/transpile/puzzle.go index 44d0ab5..e4045ce 100644 --- a/pkg/transpile/puzzle.go +++ b/pkg/transpile/puzzle.go @@ -37,23 +37,45 @@ type PuzzleDebug struct { Summary string } -// Puzzle contains everything about a puzzle that a client would see. +// Puzzle contains everything about a puzzle that a client will see. type Puzzle struct { - Debug PuzzleDebug - Authors []string - Attachments []string - Scripts []string - Body string + // Debug contains debugging information, omitted in mothballs + Debug PuzzleDebug + + // Authors names all authors of this puzzle + Authors []string + + // Attachments is a list of filenames used by this puzzle + Attachments []string + + // Scripts is a list of EMCAScript files needed by the client for this puzzle + Scripts []string + + // Body is the HTML rendering of this puzzle + Body string + + // AnswerPattern contains the pattern (regular expression?) used to match valid answers AnswerPattern string - AnswerHashes []string - Objective string - KSAs []string - Success struct { + + // AnswerHashes contains hashes of all answers for this puzzle + AnswerHashes []string + + // Objective is the learning objective for this puzzle + Objective string + + // KSAs lists all KSAs achieved upon successfull completion of this puzzle + KSAs []string + + // Success lists the criteria for successfully understanding this puzzle + Success struct { + // Acceptable describes the minimum work required to be considered successfully understanding this puzzle's concepts Acceptable string - Mastery string + + // Mastery describes the work required to be considered mastering this puzzle's conceptss + Mastery string } - // Answers will be empty in a mothball + // Answers lists all acceptable answers, omitted in mothballs Answers []string } diff --git a/theme/moth.mjs b/theme/moth.mjs index 9369494..3a31b18 100644 --- a/theme/moth.mjs +++ b/theme/moth.mjs @@ -26,11 +26,14 @@ class Award { * A puzzle. * * A new Puzzle only knows its category and point value. - * If you want to populate it with meta-information, you must call Get(). + * If you want to populate it with meta-information, you must call Populate(). + * + * Parameters created by Populate are described in the server source code: + * {@link https://pkg.go.dev/github.com/dirtbags/moth/v4/pkg/transpile#Puzzle} + * */ class Puzzle { /** - * * @param {Server} server * @param {String} category * @param {Number} points @@ -44,71 +47,22 @@ class Puzzle { * @type {Server} */ this.server = server - /** Category this puzzle belongs to - * @type {String} - */ - this.Category = category - /** Point value of this puzzle - * @type {Number} - */ - this.Points = points - } + + /** Category this puzzle belongs to */ + this.Category = String(category) + + /** Point value of this puzzle */ + this.Points = Number(points) - /** Error returned trying to fetch this puzzle */ - Error = { - /** Status code provided by server */ - Status: 0, - /** Status text provided by server */ - StatusText: "", - /** Full text of server error */ - Body: "", - } - /** Hashes of answers - * @type {String[]} - */ - AnswerHashes = [] - /** Pattern that answer should match - * @type {String[]} - */ - AnswerPattern = "" - /** Accepted answers - * @type {String[]} - */ - Answers = [] - /** Other files attached to this puzzles - * @type {String[]} - */ - Attachments = [] - /** This puzzle's authors - * @type {String[]} - */ - Authors = [] - /** HTML body of this puzzle */ - Body = "" - /** Debugging information */ - Debug = { - Errors: [], - Hints: [], - Log: [], - Notes: "", - Summary: "", - } - /** KSAs met by solving this puzzle - * @type {String[]} - */ - KSAs = [] - /** Learning objective for this puzzle */ - Objective = "" - /** ECMAScript scripts needed for this puzzle - * @type {String[]} - */ - Scripts = [] - /** Criteria for succeeding at this puzzle */ - Success = { - /** Acceptable Minimum criteria for success */ - Minimum: "", - /** Criteria for demonstrating mastery of this puzzle */ - Mastery: "", + /** Error returned trying to fetch this puzzle */ + this.Error = { + /** Status code provided by server */ + Status: 0, + /** Status text provided by server */ + StatusText: "", + /** Full text of server error */ + Body: "", + } } /** diff --git a/theme/reports/ksa.mjs b/theme/reports/ksa.mjs index 82a555e..3cc786e 100644 --- a/theme/reports/ksa.mjs +++ b/theme/reports/ksa.mjs @@ -20,19 +20,15 @@ async function init() { let state = await server.GetState() doing("Retrieving all puzzles") - let puzzles = state.Puzzles() - for (let p of puzzles) { - await p.Populate().catch(x => {}) - } - - doing("Filling table") let puzzlerowTemplate = document.querySelector("template#puzzlerow") - for (let tbody of document.querySelectorAll("tbody")) { - for (let puzzle of puzzles) { + let puzzles = state.Puzzles() + for (let puzzle of puzzles) { + await puzzle.Populate().catch(x => {}) + for (let tbody of document.querySelectorAll("tbody")) { let row = puzzlerowTemplate.content.cloneNode(true) row.querySelector(".category").textContent = puzzle.Category row.querySelector(".points").textContent = puzzle.Points - row.querySelector(".ksas").textContent = puzzle.KSAs.join(" ") + row.querySelector(".ksas").textContent = (puzzle.KSAs || []).join(" ") row.querySelector(".error").textContent = puzzle.Error.Body tbody.appendChild(row) } From a896788cc5f979fba0b45d3dd4602c787c598cbd Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Fri, 8 Sep 2023 11:31:41 -0600 Subject: [PATCH 06/34] Also list KSAs by Category --- theme/reports/ksa.html | 4 +++- theme/reports/ksa.mjs | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/theme/reports/ksa.html b/theme/reports/ksa.html index 592a355..268925c 100644 --- a/theme/reports/ksa.html +++ b/theme/reports/ksa.html @@ -12,9 +12,11 @@ This is not a report on your progress, but rather what you would have covered if you had worked every exercise available.

- +
+

All KSAs by Category

+
diff --git a/theme/reports/ksa.mjs b/theme/reports/ksa.mjs index 3cc786e..1409b2d 100644 --- a/theme/reports/ksa.mjs +++ b/theme/reports/ksa.mjs @@ -24,6 +24,20 @@ async function init() { let puzzles = state.Puzzles() for (let puzzle of puzzles) { await puzzle.Populate().catch(x => {}) + } + + doing("Filling tables") + let KSAsByCategory = {} + for (let puzzle of puzzles) { + let KSAs = KSAsByCategory[puzzle.Category] + if (!KSAs) { + KSAs = new Set() + KSAsByCategory[puzzle.Category] = KSAs + } + for (let KSA of (puzzle.KSAs || [])) { + KSAs.add(KSA) + } + for (let tbody of document.querySelectorAll("tbody")) { let row = puzzlerowTemplate.content.cloneNode(true) row.querySelector(".category").textContent = puzzle.Category @@ -34,6 +48,20 @@ async function init() { } } + doing("Filling KSAs By Category") + for (let div of document.querySelectorAll(".KSAsByCategory")) { + for (let category of state.Categories()) { + let KSAs = [...KSAsByCategory[category]] + KSAs.sort() + + div.appendChild(document.createElement("h3")).textContent = category + let ul = div.appendChild(document.createElement("ul")) + for (let k of KSAs) { + ul.appendChild(document.createElement("li")).textContent = k + } + } + } + doing() } From 551afe04a5b8b2ba373dba934755591009facb23 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Fri, 8 Sep 2023 18:05:51 -0600 Subject: [PATCH 07/34] Puzzle start using new lib +bg animation --- theme/background.mjs | 126 +++++++++++++++++++++++++++++++++++++++++++ theme/basic.css | 107 ++++++++++++++++++------------------ theme/moth.mjs | 14 +++++ theme/puzzle.html | 44 +++++++-------- theme/puzzle.mjs | 60 +++++++++++++++++++++ 5 files changed, 275 insertions(+), 76 deletions(-) create mode 100644 theme/background.mjs diff --git a/theme/background.mjs b/theme/background.mjs new file mode 100644 index 0000000..f2d1509 --- /dev/null +++ b/theme/background.mjs @@ -0,0 +1,126 @@ +function randint(max) { + return Math.floor(Math.random() * max) +} + +const MILLISECOND = 1 +const SECOND = MILLISECOND * 1000 + +class Line { + /** + * @param {CanvasRenderingContext2D} ctx canvas context + * @param {Number} hue Hue, in % of one circle [0,tau) + * @param {Number} a First point of line + * @param {Number} b Second point of line + */ + constructor(ctx, hue, a, b) { + this.ctx = ctx + this.hue = hue + this.a = a + this.b = b + } + + bounce(point, v) { + let ret = [ + point[0] + v[0], + point[1] + v[1], + ] + if ((ret[0] > this.ctx.canvas.width) || (ret[0] < 0)) { + v[0] *= -1 + ret[0] += v[0] * 2 + } + if ((ret[1] > this.ctx.canvas.height) || (ret[1] < 0)) { + v[1] *= -1 + ret[1] += v[1] * 2 + } + return ret + } + + Add(hue, a, b) { + return new Line( + this.ctx, + (this.hue + hue) % 1.0, + this.bounce(this.a, a), + this.bounce(this.b, b), + ) + } + + Draw() { + this.ctx.save() + this.ctx.strokeStyle = `hwb(${this.hue}turn 0% 50%)` + this.ctx.beginPath() + this.ctx.moveTo(this.a[0], this.a[1]) + this.ctx.lineTo(this.b[0], this.b[1]) + this.ctx.stroke() + this.ctx.restore() + } +} + +class LengoBackground { + constructor() { + this.canvas = document.createElement("canvas") + document.body.insertBefore(this.canvas, document.body.firstChild) + this.canvas.style.position = "fixed" + this.canvas.style.zIndex = -1000 + this.canvas.style.opacity = 0.3 + this.canvas.style.top = 0 + this.canvas.style.left = 0 + this.canvas.style.width = "99vw" + this.canvas.style.height = "99vh" + this.canvas.width = 2000 + this.canvas.height = 2000 + this.ctx = this.canvas.getContext("2d") + this.ctx.lineWidth = 1 + + this.lines = [] + for (let i = 0; i < 18; i++) { + this.lines.push( + new Line(this.ctx, 0, [0, 0], [0, 0]) + ) + } + this.velocities = { + hue: 0.001, + a: [20 + randint(10), 20 + randint(10)], + b: [5 + randint(10), 5 + randint(10)], + } + this.nextFrame = performance.now()-1 + this.frameInterval = 100 * MILLISECOND + + //addEventListener("resize", e => this.resizeEvent()) + //this.resizeEvent() + //this.animate(this.nextFrame) + setInterval(() => this.animate(this.nextFrame+1), SECOND/6) + } + + + /** + * Animate one frame + * + * @param {DOMHighResTimeStamp} timestamp + */ + animate(timestamp) { + if (timestamp >= this.nextFrame) { + this.lines.shift() + let lastLine = this.lines.pop() + let nextLine = lastLine.Add(this.velocities.hue, this.velocities.a, this.velocities.b) + this.lines.push(lastLine) + this.lines.push(nextLine) + + this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height) + for (let line of this.lines) { + line.Draw() + } + this.nextFrame += this.frameInterval + } + //requestAnimationFrame((ts) => this.animate(ts)) + } +} + +function init() { + new LengoBackground() +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init) +} else { + init() +} diff --git a/theme/basic.css b/theme/basic.css index 14a5a1e..42d1b5c 100644 --- a/theme/basic.css +++ b/theme/basic.css @@ -1,29 +1,58 @@ -/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */ +/* + * Colors + * + * This uses the alpha channel to apply hue tinting to elements, to get a + * similar effect in light or dark mode. + * + * http://paletton.com/#uid=33x0u0klrl-4ON9dhtKtAdqMQ4T + */ body { - font-family: sans-serif; - max-width: 40em; - background: #282a33; - color: #f6efdc; + background: #010e19; + color: #edd488; } -body.wide { - max-width: 100%; +main { + background: #000d; } -a:any-link { - color: #8b969a; +h1, h2, h3, h4, h5, h6 { + color: #cb2408cc; } h1 { - background: #5e576b; - color: #9e98a8; + background: #cb240844; } -.Fail, .Error, #messages { - background: #3a3119; - color: #ffcc98; +a:any-link { + color: #b9cbd8; } -.Fail:before { - content: "Fail: "; +.notification { + background: #ac8f3944; } -.Error:before { - content: "Error: "; +.error { + background: red; + color: white; +} +@media (prefers-color-scheme: light) { + body { + background: #b9cbd8; + color: black; + } + main { + background: #fffd; + } + a:any-link { + color: #092b45; + } +} + +body { + font-family: sans-serif; +} +main { + max-width: 40em; + margin: auto; + padding: 1px 3px; + border-radius: 5px; +} +h1 { + padding: 3px; } p { margin: 1em 0em; @@ -36,9 +65,11 @@ input, select { margin: 0.2em; max-width: 30em; } -nav { - border: solid black 2px; +.notification, .error { + padding: 0 1em; + border-radius: 8px; } + nav ul, .category ul { padding: 1em; } @@ -58,7 +89,6 @@ input:invalid { } #messages { min-height: 3em; - border: solid black 2px; } #rankings { width: 100%; @@ -91,40 +121,7 @@ input:invalid { #devel { - background-color: #eee; - color: black; - overflow: scroll; -} -#devel .string { - color: #9c27b0; -} -#devel .body { - background-color: #ffc107; -} -.kvpair { - border: solid black 2px; -} - -.spinner { - display: inline-block; - width: 64px; - height: 64px; - display: block; - width: 46px; - height: 46px; - margin: 1px; - border-radius: 50%; - border: 5px solid #fff; - border-color: #fff transparent #fff transparent; - animation: rotate 1.2s linear infinite; -} -@keyframes rotate { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } + overflow: auto; } li[draggable]::before { diff --git a/theme/moth.mjs b/theme/moth.mjs index 3a31b18..f76f5e0 100644 --- a/theme/moth.mjs +++ b/theme/moth.mjs @@ -326,6 +326,20 @@ class Server { GetContent(category, points, filename) { return this.fetch(`/content/${category}/${points}/${filename}`) } + + /** + * Return a Puzzle object. + * + * New Puzzle objects only know their category and point value. + * See docstrings on the Puzzle object for more information. + * + * @param {String} category + * @param {Number} points + * @returns {Puzzle} + */ + GetPuzzle(category, points) { + return new Puzzle(this, category, points) + } } export { diff --git a/theme/puzzle.html b/theme/puzzle.html index 37206f6..46c1ad9 100644 --- a/theme/puzzle.html +++ b/theme/puzzle.html @@ -1,32 +1,34 @@ - + Puzzle - - + + -

Puzzle

-
-
-
    -

    Puzzle by

    -
    -
    -
    - - - - Team ID:
    - Answer:
    - - -
    +
    +

    [loading]

    +
    +
    +

    + Starting script... +

    +
    +
      +

      Puzzle by [loading]

      +
      +
      + + + Team ID:
      + Answer:
      + + +
      +
      - - - - - - - - - - - -
      CategoryPointsKSAsErrors
      + + + + + + \ No newline at end of file diff --git a/theme/reports/ksa.mjs b/theme/reports/ksa.mjs index 1409b2d..d7f5b79 100644 --- a/theme/reports/ksa.mjs +++ b/theme/reports/ksa.mjs @@ -1,73 +1,140 @@ import * as moth from "../moth.mjs" +import * as common from "../common.mjs" -function doing(what) { +const server = new moth.Server("../") + +/** + * Update "doing" indicators + * + * @param {String | null} what Text to display, or null to not update text + * @param {Number | null} finished Percentage complete to display, or null to not update progress + */ +function doing(what, finished = null) { for (let e of document.querySelectorAll(".doing")) { + e.classList.remove("hidden") if (what) { - e.style.display = "inherit" + e.textContent = what + } + if (finished) { + e.value = finished } else { - e.style.display = "none" - } - for (let p of e.querySelectorAll("p")) { - p.textContent = what + e.removeAttribute("value") } } } +function done() { + for (let e of document.querySelectorAll(".doing")) { + e.classList.add("hidden") + } +} + +async function GetNice() { + let NiceElementsByIdentifier = {} + let resp = await fetch("NICEFramework2017.json") + let obj = await resp.json() + for (let e of obj.elements) { + NiceElementsByIdentifier[e.element_identifier] = e + } + return NiceElementsByIdentifier +} + +/** + * Fetch a puzzle, and fill its KSAs and rows. + * + * This is done once per puzzle, in an asynchronous function, allowing the + * application to perform multiple blocking operations simultaneously. + */ +async function FetchAndFill(puzzle, KSAs, rows) { + try { + await puzzle.Populate() + } + catch (error) { + // Keep on going with whatever Populate was able to fill + } + for (let KSA of (puzzle.KSAs || [])) { + KSAs.add(KSA) + } + + for (let row of rows) { + row.querySelector(".category").textContent = puzzle.Category + row.querySelector(".points").textContent = puzzle.Points + row.querySelector(".ksas").textContent = (puzzle.KSAs || []).join(" ") + row.querySelector(".error").textContent = puzzle.Error.Body + } +} async function init() { - let server = new moth.Server("../") + doing("Fetching NICE framework data") + let nicePromise = GetNice() doing("Retrieving server state") let state = await server.GetState() doing("Retrieving all puzzles") + let KSAsByCategory = {} let puzzlerowTemplate = document.querySelector("template#puzzlerow") let puzzles = state.Puzzles() - for (let puzzle of puzzles) { - await puzzle.Populate().catch(x => {}) + let promises = [] + for (let category of state.Categories()) { + KSAsByCategory[category] = new Set() } - - doing("Filling tables") - let KSAsByCategory = {} + let pending = puzzles.length for (let puzzle of puzzles) { - let KSAs = KSAsByCategory[puzzle.Category] - if (!KSAs) { - KSAs = new Set() - KSAsByCategory[puzzle.Category] = KSAs - } - for (let KSA of (puzzle.KSAs || [])) { - KSAs.add(KSA) - } - + // Make space in the table, so everything fills in sorted order + let rows = [] for (let tbody of document.querySelectorAll("tbody")) { - let row = puzzlerowTemplate.content.cloneNode(true) - row.querySelector(".category").textContent = puzzle.Category - row.querySelector(".points").textContent = puzzle.Points - row.querySelector(".ksas").textContent = (puzzle.KSAs || []).join(" ") - row.querySelector(".error").textContent = puzzle.Error.Body + let row = puzzlerowTemplate.content.cloneNode(true).firstElementChild tbody.appendChild(row) + rows.push(row) + } + + // Queue up a fetch, and update progress bar + let promise = FetchAndFill(puzzle, KSAsByCategory[puzzle.Category], rows) + promises.push(promise) + promise.then(() => doing(null, 1 - (--pending / puzzles.length))) + + if (promises.length > 50) { + // Chrome runs out of resources if you queue up too many of these at once + await Promise.all(promises) + promises = [] } } + await Promise.all(promises) + + doing("Retrieving NICE identifiers") + let NiceElementsByIdentifier = await nicePromise + doing("Filling KSAs By Category") + let allKSAs = new Set() for (let div of document.querySelectorAll(".KSAsByCategory")) { for (let category of state.Categories()) { + doing(`Filling KSAs for category: ${category}`) let KSAs = [...KSAsByCategory[category]] KSAs.sort() div.appendChild(document.createElement("h3")).textContent = category let ul = div.appendChild(document.createElement("ul")) for (let k of KSAs) { - ul.appendChild(document.createElement("li")).textContent = k + let ksa = k.split(/\s+/)[0] + let ne = NiceElementsByIdentifier[ksa] || { text: "???" } + let text = `${ksa}: ${ne.text}` + ul.appendChild(document.createElement("li")).textContent = text + allKSAs.add(text) } } } - doing() + doing("Filling KSAs") + for (let e of document.querySelectorAll(".allKSAs")) { + let KSAs = [...allKSAs] + KSAs.sort() + for (let text of KSAs) { + e.appendChild(document.createElement("li")).textContent = text + } + } + + done() } -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init) -} else { - init() -} - \ No newline at end of file +common.WhenDOMLoaded(init) From c72d13af327eedb353a327234cb074d405a30d79 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 14 Sep 2023 19:08:44 -0600 Subject: [PATCH 18/34] Some twiddling to prepare for a scoreboard update --- theme/index.mjs | 15 ++--- theme/moth.mjs | 146 +++++++++++++++++++++++++++++++---------------- theme/puzzle.mjs | 25 ++++---- 3 files changed, 114 insertions(+), 72 deletions(-) diff --git a/theme/index.mjs b/theme/index.mjs index 886982e..9bc79a2 100644 --- a/theme/index.mjs +++ b/theme/index.mjs @@ -39,8 +39,8 @@ class App { /** * Attempt to log in to the server. * - * @param {String} teamID - * @param {String} teamName + * @param {string} teamID + * @param {string} teamName */ async Login(teamID, teamName) { try { @@ -114,7 +114,7 @@ class App { /** * 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) { element.classList.toggle("hidden", !visible) @@ -123,7 +123,7 @@ class App { /** * 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. */ renderPuzzles(element, visible) { @@ -177,9 +177,4 @@ function init() { } } -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init) -} else { - init() -} - \ No newline at end of file +common.WhenDOMLoaded(init) diff --git a/theme/moth.mjs b/theme/moth.mjs index 1be980a..bc5d190 100644 --- a/theme/moth.mjs +++ b/theme/moth.mjs @@ -7,8 +7,8 @@ class Hash { * * Used until MOTH v3.5 * - * @param {String} buf Input - * @returns {Number} + * @param {string} buf Input + * @returns {number} */ static djb2(buf) { let h = 5381 @@ -23,8 +23,8 @@ class Hash { /** * Dan Bernstein hash with xor improvement * - * @param {String} buf Input - * @returns {Number} + * @param {string} buf Input + * @returns {number} */ static djb2xor(buf) { let h = 5381 @@ -39,8 +39,8 @@ class Hash { * * Used until MOTH v4.5 * - * @param {String} buf Input - * @returns {Promise.} hex-encoded digest + * @param {string} buf Input + * @returns {Promise.} hex-encoded digest */ static async sha256(buf) { const msgUint8 = new TextEncoder().encode(buf) @@ -52,8 +52,8 @@ class Hash { /** * Hex-encode a byte array * - * @param {Number[]} buf Byte array - * @returns {String} + * @param {number[]} buf Byte array + * @returns {string} */ static hexlify(buf) { return buf.map(b => b.toString(16).padStart(2, "0")).join("") @@ -62,8 +62,8 @@ class Hash { /** * Apply every hash to the input buffer. * - * @param {String} buf Input - * @returns {Promise.} + * @param {string} buf Input + * @returns {Promise.} */ static async All(buf) { return [ @@ -80,19 +80,19 @@ class Hash { class Award { constructor(when, teamid, category, points) { /** Unix epoch timestamp for this award - * @type {Number} + * @type {number} */ this.When = when /** Team ID this award belongs to - * @type {String} + * @type {string} */ this.TeamID = teamid /** Puzzle category for this award - * @type {String} + * @type {string} */ this.Category = category /** Points value of this award - * @type {Number} + * @type {number} */ this.Points = points } @@ -111,8 +111,8 @@ class Award { class Puzzle { /** * @param {Server} server - * @param {String} category - * @param {Number} points + * @param {string} category + * @param {number} points */ constructor (server, category, points) { if (points < 1) { @@ -173,7 +173,7 @@ class Puzzle { /** * Get a resource associated with this puzzle. * - * @param {String} filename Attachment/Script to retrieve + * @param {string} filename Attachment/Script to retrieve * @returns {Promise.} */ Get(filename) { @@ -193,8 +193,8 @@ class Puzzle { * you still have to pick through a lot of potentially correct answers when * it's done. * - * @param {String} str User-submitted possible answer - * @returns {Promise.} + * @param {string} str User-submitted possible answer + * @returns {Promise.} */ async IsPossiblyCorrect(str) { let userAnswerHashes = await Hash.All(str) @@ -215,8 +215,8 @@ class Puzzle { * The returned promise will fail if anything goes wrong, including the * proposed answer being rejected. * - * @param {String} proposed Answer to submit - * @returns {Promise.} Success message + * @param {string} proposed Answer to submit + * @returns {Promise.} Success message */ SubmitAnswer(proposed) { return this.server.SubmitAnswer(this.Category, this.Points, proposed) @@ -242,23 +242,23 @@ class State { /** Configuration */ this.Config = { /** Is the server in development mode? - * @type {Boolean} + * @type {boolean} */ Devel: obj.Config.Devel, } /** Global messages, in HTML - * @type {String} + * @type {string} */ this.Messages = obj.Messages /** Map from Team ID to Team Name - * @type {Object.} + * @type {Object.} */ this.TeamNames = obj.TeamNames /** Map from category name to puzzle point values - * @type {Object.} + * @type {Object.} */ this.PointsByCategory = obj.Puzzles @@ -271,7 +271,7 @@ class State { /** * Returns a sorted list of open category names * - * @returns {String[]} List of categories + * @returns {string[]} List of categories */ Categories() { let ret = [] @@ -288,8 +288,8 @@ class State { * 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. * - * @param {String} category - * @returns {Boolean} + * @param {string} category + * @returns {boolean} */ ContainsUnsolved(category) { return !this.PointsByCategory[category].includes(0) @@ -298,7 +298,7 @@ class State { /** * Is the server in development mode? * - * @returns {Boolean} + * @returns {boolean} */ DevelopmentMode() { return this.Config && this.Config.Devel @@ -310,7 +310,7 @@ class State { * The returned list will be sorted by (category, points). * 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[]} */ Puzzles(...categories) { @@ -335,8 +335,8 @@ class State { * Has this puzzle been solved by this team? * * @param {Puzzle} puzzle - * @param {String} teamID Team to check, default the logged-in team - * @returns {Boolean} + * @param {string} teamID Team to check, default the logged-in team + * @returns {boolean} */ IsSolved(puzzle, teamID="self") { for (let award of this.PointsLog) { @@ -350,6 +350,52 @@ class State { } return false } + + /** + * Map from team ID to points. + * + * A special "max" property contains the highest number of points in this map. + * + * @typedef {Object.} TeamPointsDict + * @property {Number} max Highest number of points + */ + + /** + * Map from category to PointsDict. + * + * @typedef {Object.} 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 { /** - * @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) { if (!baseUrl) { @@ -380,8 +426,8 @@ class Server { * This always sends teamID. * If args is set, POST will be used instead of GET * - * @param {String} path Path to API endpoint - * @param {Object.} args Key/Values to send in POST data + * @param {string} path Path to API endpoint + * @param {Object.} args Key/Values to send in POST data * @returns {Promise.} Response */ fetch(path, args={}) { @@ -400,8 +446,8 @@ class Server { /** * Send a request to a JSend API endpoint. * - * @param {String} path Path to API endpoint - * @param {Object.} args Key/Values to send in POST + * @param {string} path Path to API endpoint + * @param {Object.} args Key/Values to send in POST * @returns {Promise.} JSend Data */ async call(path, args={}) { @@ -434,7 +480,7 @@ class Server { /** * Are we logged in to the server? * - * @returns {Boolean} + * @returns {boolean} */ LoggedIn() { return this.TeamID ? true : false @@ -467,9 +513,9 @@ class Server { * This calls the server's registration endpoint; if the call succeds, or * fails with "team already exists", the login is returned as successful. * - * @param {String} teamID - * @param {String} teamName - * @returns {Promise.} Success message from server + * @param {string} teamID + * @param {string} teamName + * @returns {Promise.} Success message from server */ async Login(teamID, 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 * proposed answer being rejected. * - * @param {String} category Category of puzzle - * @param {Number} points Point value of puzzle - * @param {String} proposed Answer to submit - * @returns {Promise.} Success message + * @param {string} category Category of puzzle + * @param {number} points Point value of puzzle + * @param {string} proposed Answer to submit + * @returns {Promise.} Success message */ async SubmitAnswer(category, points, proposed) { let data = await this.call("/answer", { @@ -502,9 +548,9 @@ class Server { /** * Fetch a file associated with a puzzle. * - * @param {String} category Category of puzzle - * @param {Number} points Point value of puzzle - * @param {String} filename + * @param {string} category Category of puzzle + * @param {number} points Point value of puzzle + * @param {string} filename * @returns {Promise.} */ GetContent(category, points, filename) { @@ -517,8 +563,8 @@ class Server { * New Puzzle objects only know their category and point value. * See docstrings on the Puzzle object for more information. * - * @param {String} category - * @param {Number} points + * @param {string} category + * @param {number} points * @returns {Puzzle} */ GetPuzzle(category, points) { diff --git a/theme/puzzle.mjs b/theme/puzzle.mjs index 8c5fd7d..c53b6a1 100644 --- a/theme/puzzle.mjs +++ b/theme/puzzle.mjs @@ -9,7 +9,7 @@ const server = new moth.Server(".") /** * 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, * or by some other means provided by the browser, * like hitting the Enter key. @@ -22,7 +22,7 @@ async function formSubmitHandler(event) { let proposed = data.get("answer") let message - console.group("Submit answer") + console.groupCollapsed("Submit answer") console.info(`Proposed answer: ${proposed}`) try { 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. * - * @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} */ function puzzleElement(clear=true) { @@ -69,10 +69,11 @@ function puzzleElement(clear=true) { /** * 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. - * - * @param {String} error + * + * Errors are rendered in the puzzle area, so the user can see a bit more about + * what the problem is. + * + * @param {string} error */ function error(error) { console.error(error) @@ -84,9 +85,9 @@ function error(error) { /** * 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) { let e = document.querySelector("#answer") @@ -125,11 +126,11 @@ function writeObject(e, obj) { /** * Load the given puzzle. * - * @param {String} category - * @param {Number} points + * @param {string} category + * @param {number} 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) // Tell user we're loading From f49eb3ed46ad47c416985c18874fc9a071691ef4 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Fri, 15 Sep 2023 12:34:31 -0600 Subject: [PATCH 19/34] =?UTF-8?q?Change=20answer=20hash=20algorithm=20to?= =?UTF-8?q?=20SHA1=E2=82=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++-- pkg/transpile/puzzle.go | 6 +++--- pkg/transpile/puzzle_test.go | 6 ++++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc8422..5fd80c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [v4.6.0] - unreleased ### Changed -- We are now using djb2xor instead of sha256 to hash puzzle answers -- Lots of work on the built-in theme +- Answer hashes are now the first 4 characters of the hex-encoded SHA1 digest +- Reworked the built-in theme - [moth.mjs](theme/moth.mjs) is now the standard MOTH library for ECMAScript - Devel mode no longer accepts an empty team ID diff --git a/pkg/transpile/puzzle.go b/pkg/transpile/puzzle.go index e4045ce..23d8a90 100644 --- a/pkg/transpile/puzzle.go +++ b/pkg/transpile/puzzle.go @@ -4,7 +4,7 @@ import ( "bufio" "bytes" "context" - "crypto/sha256" + "crypto/sha1" "encoding/json" "errors" "fmt" @@ -85,9 +85,9 @@ func (puzzle *Puzzle) computeAnswerHashes() { } puzzle.AnswerHashes = make([]string, len(puzzle.Answers)) for i, answer := range puzzle.Answers { - sum := sha256.Sum256([]byte(answer)) + sum := sha1.Sum([]byte(answer)) hexsum := fmt.Sprintf("%x", sum) - puzzle.AnswerHashes[i] = hexsum + puzzle.AnswerHashes[i] = hexsum[:4] } } diff --git a/pkg/transpile/puzzle_test.go b/pkg/transpile/puzzle_test.go index 47db867..d6b9022 100644 --- a/pkg/transpile/puzzle_test.go +++ b/pkg/transpile/puzzle_test.go @@ -23,6 +23,12 @@ func TestPuzzle(t *testing.T) { if (len(p.Answers) == 0) || (p.Answers[0] != "YAML answer") { t.Error("Answers are wrong", p.Answers) } + if len(p.Answers) != len(p.AnswerHashes) { + t.Error("Answer hashes length does not match answers length") + } + if len(p.AnswerHashes[0]) != 4 { + t.Error("Answer hash is wrong length") + } if (len(p.Authors) != 3) || (p.Authors[1] != "Buster") { t.Error("Authors are wrong", p.Authors) } From d18de0fe8b33ca74a8b2d333b20733bd6d91e6f1 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Fri, 15 Sep 2023 15:17:07 -0600 Subject: [PATCH 20/34] working scoreboard --- docs/scoring.md | 73 +++++++++ theme/basic.css | 16 +- theme/moth.mjs | 198 +++++++++++++++++++------ theme/scoreboard.html | 1 - theme/scoreboard.mjs | 334 ++++++++++-------------------------------- 5 files changed, 309 insertions(+), 313 deletions(-) create mode 100644 docs/scoring.md diff --git a/docs/scoring.md b/docs/scoring.md new file mode 100644 index 0000000..885f657 --- /dev/null +++ b/docs/scoring.md @@ -0,0 +1,73 @@ +Scoring +======= + +MOTH does not carry any notion of who is winning: we consider this a user +interface issue. The server merely provides a timestamped log of point awards. + +The bundled scoreboard provides one way to interpret the scores: this is the +main algorithm we use at Cyber Fire events. We use other views of the scoreboard +in other contexts, though! Here are some ideas: + + +Percentage of Each Category +--------------------- + +This is implemented in the scoreboard distributed with MOTH, and is how our +primary score calculation at Cyber Fire. + +For each category: + +* Divide the team's score in this category by the highest score in this category +* Add that to the team's overall score + +This means the highest theoretical score in any event is the number of open +categories. + +This algorithm means that point values only matter relative to other point +values within that category. A category with 5 total points is worth the same as +a category with 5000 total points, and a 2 point puzzle in the first category is +worth as much as a 2000 point puzzle in the second. + +One interesting effect here is that a team solving a previously-unsolved puzzle +will reduce everybody else's ranking in that category, because it increases the +divisor for calculating that category's score. + +Cyber Fire used to not display overall score: we would only show each team's +relative ranking per category. We may go back to this at some point! + + +Category Completion +---------------- + +Cyber Fire also has a scoreboard called the "class" scoreboard, which lists each +team, and which puzzles they have completed. This provides instructors with a +graphical overview of how people are progressing through content. We can provide +assistance to the general group when we see that a large number of teams are +stuck on a particular puzzle, and we can provide individual assistance if we see +that someone isn't keeping up with the class. + + +Monarch Of The Hill +---------------- + +You could also implement a "winner takes all" approach: any team with the +maximum number of points in a category gets 1 point, and all other teams get 0. + + +Time Bonuses +----------- + +If you wanted to provide extra points to whichever team solves a puzzle first, +this is possible with the log. You could either boost a puzzle's point value or +decay it; either by timestamp, or by how many teams had solved it prior. + + +Bonkers Scoring +------------- + +Other zany options exist: + +* The first team to solve a puzzle with point value divisible by 7 gets double + points. +* [Tokens](tokens.md) with negative point values could be introduced, allowing + teams to manipulate other teams' scores, if they know the team ID. diff --git a/theme/basic.css b/theme/basic.css index 90be911..d67ad72 100644 --- a/theme/basic.css +++ b/theme/basic.css @@ -118,18 +118,18 @@ input:invalid { width: 100%; position: relative; } - -#rankings span { - font-size: 75%; - display: inline-block; - overflow: hidden; - height: 1.7em; +#rankings div:nth-child(6n){ + background-color: #8881; +} +#rankings div:nth-child(6n+3) { + background-color: #0f01; } #rankings span.teamname { + height: auto; font-size: inherit; color: white; - text-shadow: 0 0 3px black; - opacity: 0.8; + background-color: #000e; + border-radius: 3px; position: absolute; right: 0.2em; } diff --git a/theme/moth.mjs b/theme/moth.mjs index bc5d190..a617753 100644 --- a/theme/moth.mjs +++ b/theme/moth.mjs @@ -21,7 +21,7 @@ class Hash { } /** - * Dan Bernstein hash with xor improvement + * Dan Bernstein hash with xor * * @param {string} buf Input * @returns {number} @@ -49,15 +49,30 @@ class Hash { return this.hexlify(hashArray); } - /** - * Hex-encode a byte array - * - * @param {number[]} buf Byte array - * @returns {string} - */ - static hexlify(buf) { - return buf.map(b => b.toString(16).padStart(2, "0")).join("") - } + /** + * SHA 1, but only the first 4 hexits (2 octets). + * + * Git uses this technique with 7 hexits (default) as a "short identifier". + * + * @param {string} buf Input + */ + static async sha1_slice(buf, end=4) { + const msgUint8 = new TextEncoder().encode(buf) + const hashBuffer = await crypto.subtle.digest("SHA-1", msgUint8) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hexits = this.hexlify(hashArray) + return hexits.slice(0, end) + } + + /** + * Hex-encode a byte array + * + * @param {number[]} buf Byte array + * @returns {string} + */ + static hexlify(buf) { + return buf.map(b => b.toString(16).padStart(2, "0")).join("") + } /** * Apply every hash to the input buffer. @@ -68,8 +83,8 @@ class Hash { static async All(buf) { return [ String(this.djb2(buf)), - String(this.djb2xor(buf)), await this.sha256(buf), + await this.sha1_slice(buf), ] } } @@ -223,6 +238,111 @@ class Puzzle { } } +/** + * A snapshot of scores. + */ +class Scores { + constructor() { + /** + * Timestamp of this score snapshot + * @type number + */ + this.Timestamp = 0 + + /** + * All categories present in this snapshot. + * + * ECMAScript sets preserve order, so iterating over this will yield + * categories as they were added to the points log. + * + * @type {Set.} + */ + this.Categories = new Set() + + /** + * All team IDs present in this snapshot + * @type {Set.} + */ + this.TeamIDs = new Set() + + /** + * Highest score in each category + * @type {Object.} + */ + this.MaxPoints = {} + + this.categoryTeamPoints = {} + } + + /** + * Return a sorted list of category names + * + * @returns {string[]} + */ + SortedCategories() { + let categories = [...this.Categories] + categories.sort((a,b) => a.localeCompare(b, "en", {sensitivity: "base"})) + return categories + } + + /** + * Add an award to a team's score. + * + * Updates this.Timestamp to the award's timestamp. + * + * @param {Award} award + */ + Add(award) { + this.Timestamp = award.Timestamp + this.Categories.add(award.Category) + this.TeamIDs.add(award.TeamID) + + let teamPoints = (this.categoryTeamPoints[award.Category] ??= {}) + let points = (teamPoints[award.TeamID] || 0) + award.Points + teamPoints[award.TeamID] = points + + let max = this.MaxPoints[award.Category] || 0 + this.MaxPoints[award.Category] = Math.max(max, points) + } + + /** + * Get a team's score within a category. + * + * @param {string} category + * @param {string} teamID + * @returns {number} + */ + GetPoints(category, teamID) { + let teamPoints = this.categoryTeamPoints[category] || {} + return teamPoints[teamID] || 0 + } + + /** + * Calculate a team's score in a category, using the Cyber Fire algorithm. + * + *@param {string} category + * @param {string} teamID + */ + CyFiCategoryScore(category, teamID) { + return this.GetPoints(category, teamID) / this.MaxPoints[category] + } + + /** + * Calculate a team's overall score, using the Cyber Fire algorithm. + * + *@param {string} category + * @param {string} teamID + * @returns {number} + */ + CyFiScore(teamID) { + let score = 0 + for (let category of this.Categories) { + score += this.CyFiCategoryScore(category, teamID) + } + return score + } +} + /** * MOTH instance state. */ @@ -351,51 +471,33 @@ class State { return false } - /** - * Map from team ID to points. - * - * A special "max" property contains the highest number of points in this map. - * - * @typedef {Object.} TeamPointsDict - * @property {Number} max Highest number of points - */ - - /** - * Map from category to PointsDict. - * - * @typedef {Object.} 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 + * MOTH has no notion of who is "winning", we consider this a user interface + * decision. There are lots of interesting options: see + * [scoring]{@link ../docs/scoring.md} for more. + * + * @yields {Scores} Snapshot at a point in time */ * ScoreHistory() { - /** @type {CategoryTeamPointsDict} */ - let categoryTeamPoints = {} + let scores = new Scores() 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 + scores.Add(award) + yield scores } } + + /** + * Calculate the current scores. + * + * @returns {Scores} + */ + CurrentScore() { + let scores + for (scores of this.ScoreHistory()); + return scores + } } /** diff --git a/theme/scoreboard.html b/theme/scoreboard.html index 8b1f787..74af715 100644 --- a/theme/scoreboard.html +++ b/theme/scoreboard.html @@ -12,7 +12,6 @@
      -
      diff --git a/theme/scoreboard.mjs b/theme/scoreboard.mjs index e2d7cfc..8f7ce6f 100644 --- a/theme/scoreboard.mjs +++ b/theme/scoreboard.mjs @@ -1,267 +1,89 @@ -// jshint asi:true +import * as moth from "./moth.mjs" +import * as common from "./common.mjs" -// import { Chart, registerables } from "https://cdn.jsdelivr.net/npm/chart.js@3.0.2" -// import {DateTime} from "https://cdn.jsdelivr.net/npm/luxon@1.26.0" -// import "https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@0.1.1" -// Chart.register(...registerables) +const server = new moth.Server(".") +const ReplayDuration = 3 * common.Second +const MaxFrameRate = 24 +/** Don't let any team's score exceed this percentage width */ +const MaxScoreWidth = 95 -const MILLISECOND = 1 -const SECOND = 1000 * MILLISECOND -const MINUTE = 60 * SECOND +/** + * Returns a promise that resolves after timeout. + * + * @param {Number} timeout How long to sleep (milliseconds) + * @returns {Promise} + */ +function sleep(timeout) { + return new Promise(resolve => setTimeout(resolve, timeout)); +} -// If all else fails... -setInterval(() => location.reload(), 30 * SECOND) +/** + * Pull new points log, and update the scoreboard. + * + * The update is animated, because I think that looks cool. + */ +async function update() { + let state = await server.GetState() + let rankingsElement = document.querySelector("#rankings") + let logSize = state.PointsLog.length -function scoreboardInit() { - let chartColors = [ - "rgb(255, 99, 132)", - "rgb(255, 159, 64)", - "rgb(255, 205, 86)", - "rgb(75, 192, 192)", - "rgb(54, 162, 235)", - "rgb(153, 102, 255)", - "rgb(201, 203, 207)" - ] - - for (let q of document.querySelectorAll("[data-url]")) { - let url = new URL(q.dataset.url, document.location) - q.textContent = url.hostname - if (url.port) { - q.textContent += `:${url.port}` - } - if (url.pathname != "/") { - q.textContent += url.pathname - } - } - for (let q of document.querySelectorAll(".qrcode")) { - let url = new URL(q.dataset.url, document.location) - let qr = new QRious({ - element: q, - value: url.toString(), - }) + // Figure out the timing so that we can replay the scoreboard in about + // ReplayDuration, but no more than 24 frames per second. + let frameModulo = 1 + let delay = 0 + while (delay < (common.Second / MaxFrameRate)) { + frameModulo += 1 + delay = ReplayDuration / (logSize / frameModulo) } - let chart - let canvas = document.querySelector("#chart canvas") - if (canvas) { - chart = new Chart(canvas.getContext("2d"), { - type: "line", - options: { - responsive: true, - scales: { - x: { - type: "time", - time: { - // XXX: the manual says this should do something, it does something in the samples, IDK - tooltipFormat: "HH:mm" - }, - title: { - display: true, - text: "Time" - } - }, - y: { - title: { - display: true, - text: "Points" - } - } - }, - tooltips: { - mode: "index", - intersect: false - }, - hover: { - mode: "nearest", - intersect: true + let frame = 0 + for (let scores of state.ScoreHistory()) { + frame += 1 + if ((frame < state.PointsLog.length) && (frame % frameModulo)) { + continue + } + + while (rankingsElement.firstChild) rankingsElement.firstChild.remove() + + let sortedTeamIDs = [...scores.TeamIDs] + sortedTeamIDs.sort((a, b) => scores.CyFiScore(a) - scores.CyFiScore(b)) + sortedTeamIDs.reverse() + + let topScore = scores.CyFiScore(sortedTeamIDs[0]) + for (let teamID of sortedTeamIDs) { + let teamName = state.TeamNames[teamID] + + let row = rankingsElement.appendChild(document.createElement("div")) + + let heading = row.appendChild(document.createElement("span")) + heading.textContent = teamName + heading.classList.add("teamname") + + let categoryNumber = 0 + for (let category of scores.Categories) { + let score = scores.CyFiCategoryScore(category, teamID) + if (!score) { + continue } - } - }) + + let block = row.appendChild(document.createElement("span")) + let points = scores.GetPoints(category, teamID) + let width = MaxScoreWidth * score / topScore + + block.textContent = category + block.title = `${points} points` + block.style.width = `${width}%` + block.classList.add(`cat${categoryNumber}`) + categoryNumber += 1 + } + } + await sleep(delay) } - - async function refresh() { - let resp = await fetch("../state") - let state = await resp.json() - - for (let rotate of document.querySelectorAll(".rotate")) { - rotate.appendChild(rotate.firstElementChild) - } - window.scrollTo(0,0) - - let element = document.getElementById("rankings") - let teamNames = state.TeamNames - let pointsLog = state.PointsLog - - // Every machine that's displaying the scoreboard helpfully stores the last 20 values of - // points.json for us, in case of catastrophe. Thanks, y'all! - // - // We have been doing some variation on this "everybody backs up the server state" trick since 2009. - // We have needed it 0 times. - let pointsHistory = JSON.parse(localStorage.getItem("pointsHistory")) || [] - if (pointsHistory.length >= 20) { - pointsHistory.shift() - } - pointsHistory.push(pointsLog) - localStorage.setItem("pointsHistory", JSON.stringify(pointsHistory)) - - let teams = {} - let highestCategoryScore = {} // map[string]int - - // Initialize data structures - for (let teamId in teamNames) { - teams[teamId] = { - categoryScore: {}, // map[string]int - overallScore: 0, // int - historyLine: [], // []{x: int, y: int} - name: teamNames[teamId], - id: teamId - } - } - - // Dole out points - for (let entry of pointsLog) { - let timestamp = entry[0] - let teamId = entry[1] - let category = entry[2] - let points = entry[3] - - let team = teams[teamId] - - let score = team.categoryScore[category] || 0 - score += points - team.categoryScore[category] = score - - let highest = highestCategoryScore[category] || 0 - if (score > highest) { - highestCategoryScore[category] = score - } - } - - for (let teamId in teamNames) { - teams[teamId].categoryScore = {} - } - - for (let entry of pointsLog) { - let timestamp = entry[0] - let teamId = entry[1] - let category = entry[2] - let points = entry[3] - - let team = teams[teamId] - - let score = team.categoryScore[category] || 0 - score += points - team.categoryScore[category] = score - - let overall = 0 - for (let cat in team.categoryScore) { - overall += team.categoryScore[cat] / highestCategoryScore[cat] - } - - team.historyLine.push({x: timestamp * 1000, y: overall}) - } - - // Compute overall scores based on current highest - for (let teamId in teams) { - let team = teams[teamId] - team.overallScore = 0 - for (let cat in team.categoryScore) { - team.overallScore += team.categoryScore[cat] / highestCategoryScore[cat] - } - } - - // Sort by team score - function teamCompare(a, b) { - return a.overallScore - b.overallScore - } - - // Figure out how to order each team on the scoreboard - let winners = [] - for (let teamId in teams) { - winners.push(teams[teamId]) - } - winners.sort(teamCompare) - winners.reverse() - - // Let's make some better names for things we've computed - let winningScore = winners[0].overallScore - let numCategories = Object.keys(highestCategoryScore).length - - // Clear out the element we're about to populate - Array.from(element.childNodes).map(e => e.remove()) - - let maxWidth = 100 / winningScore - for (let team of winners) { - let row = document.createElement("div") - let ncat = 0 - for (let category in highestCategoryScore) { - let catHigh = highestCategoryScore[category] - let catTeam = team.categoryScore[category] || 0 - let catPct = catTeam / catHigh - let width = maxWidth * catPct - - let bar = document.createElement("span") - bar.classList.add("category") - bar.classList.add("cat" + ncat) - bar.style.width = width + "%" - bar.textContent = category + ": " + catTeam - bar.title = bar.textContent - - row.appendChild(bar) - ncat += 1 - } - - let te = document.createElement("span") - te.classList.add("teamname") - te.textContent = team.name - row.appendChild(te) - - element.appendChild(row) - } - - if (!chart) { - return - } - - /* - * Update chart - */ - chart.data.datasets = [] - for (let i in winners) { - if (i > 5) { - break - } - let team = winners[i] - let color = chartColors[i % chartColors.length] - chart.data.datasets.push({ - label: team.name, - backgroundColor: color, - borderColor: color, - data: team.historyLine, - lineTension: 0, - fill: false - }) - } - chart.update() - window.chart = chart - } - - function init() { - let base = window.location.href.replace("scoreboard.html", "") - let location = document.querySelector("#location") - if (location) { - location.textContent = base - } - - setInterval(refresh, 20 * SECOND) - refresh() - } - - init() } -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", scoreboardInit) -} else { - scoreboardInit() +function init() { + setInterval(update, common.Minute) + update() } + +common.WhenDOMLoaded(init) \ No newline at end of file From bb4859e7a96c382eea70e8bf7cfb9ec7ec275c90 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Fri, 15 Sep 2023 16:09:08 -0600 Subject: [PATCH 21/34] URL in scoreboard (configurable) --- theme/basic.css | 35 ++--------------------------------- theme/common.mjs | 21 +++++++++++++++++++++ theme/config.json | 1 + theme/index.mjs | 6 ++---- theme/moth.mjs | 1 + theme/puzzle.mjs | 4 ++-- theme/scoreboard.css | 33 ++++++++++++++++++++++++++++----- theme/scoreboard.html | 7 +++---- theme/scoreboard.mjs | 10 ++++++++-- 9 files changed, 68 insertions(+), 50 deletions(-) diff --git a/theme/basic.css b/theme/basic.css index d67ad72..667e99d 100644 --- a/theme/basic.css +++ b/theme/basic.css @@ -113,36 +113,7 @@ input:invalid { cursor: help; } -/** Scoreboard */ -#rankings { - width: 100%; - position: relative; -} -#rankings div:nth-child(6n){ - background-color: #8881; -} -#rankings div:nth-child(6n+3) { - background-color: #0f01; -} -#rankings span.teamname { - height: auto; - font-size: inherit; - color: white; - background-color: #000e; - border-radius: 3px; - position: absolute; - right: 0.2em; -} -#rankings div * {white-space: nowrap;} -.cat0, .cat8, .cat16 {background-color: #a6cee3; color: black;} -.cat1, .cat9, .cat17 {background-color: #1f78b4; color: white;} -.cat2, .cat10, .cat18 {background-color: #b2df8a; color: black;} -.cat3, .cat11, .cat19 {background-color: #33a02c; color: white;} -.cat4, .cat12, .cat20 {background-color: #fb9a99; color: black;} -.cat5, .cat13, .cat21 {background-color: #e31a1c; color: white;} -.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;} -.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;} - +/** Development mode information */ .debug { overflow: auto; padding: 1em; @@ -203,11 +174,9 @@ li[draggable] { } @media (prefers-color-scheme: light) { - /* - * This uses the alpha channel to apply hue tinting to elements, to get a + /* We uses the alpha channel to apply hue tinting to elements, to get a * similar effect in light or dark mode. That means there aren't a whole lot of * things to change between light and dark mode. - * */ body { background-color: #b9cbd8; diff --git a/theme/common.mjs b/theme/common.mjs index c797475..c9e49a8 100644 --- a/theme/common.mjs +++ b/theme/common.mjs @@ -5,6 +5,9 @@ const Millisecond = 1 const Second = Millisecond * 1000 const Minute = Second * 60 +/** URL to the top of this MOTH server */ +const BaseURL = new URL(".", location) + /** * Display a transient message to the user. * @@ -53,11 +56,29 @@ function Truthy(s) { return true } + +/** + * Fetch the configuration object for this theme. + * + * @returns {Promise.} + */ +async function Config() { + let resp = await fetch( + new URL("config.json", BaseURL), + { + cache: "no-cache" + }, + ) + return resp.json() +} + export { Millisecond, Second, Minute, + BaseURL, Toast, WhenDOMLoaded, Truthy, + Config, } diff --git a/theme/config.json b/theme/config.json index 9f39211..1d5a0a1 100644 --- a/theme/config.json +++ b/theme/config.json @@ -1,4 +1,5 @@ { "TrackSolved": true, + "URLInScoreboard": true, "__sentry__": "this is here so you don't have to remember to take the comma off the last item" } \ No newline at end of file diff --git a/theme/index.mjs b/theme/index.mjs index 9bc79a2..e9de612 100644 --- a/theme/index.mjs +++ b/theme/index.mjs @@ -6,7 +6,6 @@ import * as common from "./common.mjs" class App { constructor(basePath=".") { - this.configURL = new URL("config.json", location) this.config = {} this.server = new moth.Server(basePath) @@ -74,8 +73,7 @@ class App { * load, since configuration should (hopefully) change less frequently. */ async UpdateConfig() { - let resp = await fetch(this.configURL) - this.config = await resp.json() + this.config = await common.Config() } /** @@ -150,7 +148,7 @@ class App { for (let puzzle of this.state.Puzzles(cat)) { let i = l.appendChild(document.createElement("li")) - let url = new URL("puzzle.html", window.location) + let url = new URL("puzzle.html", common.BaseURL) url.hash = `${puzzle.Category}:${puzzle.Points}` let a = i.appendChild(document.createElement("a")) a.textContent = puzzle.Points diff --git a/theme/moth.mjs b/theme/moth.mjs index a617753..c1db74b 100644 --- a/theme/moth.mjs +++ b/theme/moth.mjs @@ -542,6 +542,7 @@ class Server { return fetch(url, { method: "POST", body, + cache: "no-cache", }) } diff --git a/theme/puzzle.mjs b/theme/puzzle.mjs index c53b6a1..6f886d5 100644 --- a/theme/puzzle.mjs +++ b/theme/puzzle.mjs @@ -131,7 +131,7 @@ function writeObject(e, obj) { */ async function loadPuzzle(category, points) { console.groupCollapsed("Loading puzzle:", category, points) - let contentBase = new URL(`content/${category}/${points}/`, location) + let contentBase = new URL(`content/${category}/${points}/`, common.BaseURL) // Tell user we're loading puzzleElement().appendChild(document.createElement("progress")) @@ -209,7 +209,7 @@ async function init() { // Make all links absolute, because we're going to be changing the base URL for (let e of document.querySelectorAll("[href]")) { - e.href = new URL(e.href, location) + e.href = new URL(e.href, common.BaseURL) } let hashpart = location.hash.split("#")[1] || "" diff --git a/theme/scoreboard.css b/theme/scoreboard.css index 470e72d..a3f55d1 100644 --- a/theme/scoreboard.css +++ b/theme/scoreboard.css @@ -47,6 +47,18 @@ text-align: center; } +.location { + color: #acf; + background-color: #0008; + position: fixed; + right: 30vw; + bottom: 0; + padding: 1em; + margin: 0; + font-size: 1.2rem; + font-weight:bold; + text-decoration: underline; +} .qrcode { width: 30vw; } @@ -60,23 +72,34 @@ max-width: 40%; } +/** Scoreboard */ #rankings { width: 100%; - position: relative; - background-color: rgba(0, 0, 0, 0.8); + position: relative; + background-color: #000c; +} +#rankings div { + height: 1.4rem; +} +#rankings div:nth-child(6n){ + background-color: #ccc1; +} +#rankings div:nth-child(6n+3) { + background-color: #0f01; } #rankings span { font-size: 75%; display: inline-block; overflow: hidden; - height: 1.7em; + height: 1.4em; } #rankings span.teamname { + height: auto; font-size: inherit; color: white; - text-shadow: 0 0 3px black; - opacity: 0.8; + background-color: #000e; + border-radius: 3px; position: absolute; right: 0.2em; } diff --git a/theme/scoreboard.html b/theme/scoreboard.html index 74af715..1e14424 100644 --- a/theme/scoreboard.html +++ b/theme/scoreboard.html @@ -10,9 +10,8 @@ - -
      -
      -
      + +
      +
      diff --git a/theme/scoreboard.mjs b/theme/scoreboard.mjs index 8f7ce6f..5d2e161 100644 --- a/theme/scoreboard.mjs +++ b/theme/scoreboard.mjs @@ -2,8 +2,8 @@ import * as moth from "./moth.mjs" import * as common from "./common.mjs" const server = new moth.Server(".") -const ReplayDuration = 3 * common.Second -const MaxFrameRate = 24 +const ReplayDuration = 0.3 * common.Second +const MaxFrameRate = 60 /** Don't let any team's score exceed this percentage width */ const MaxScoreWidth = 95 @@ -23,6 +23,12 @@ function sleep(timeout) { * The update is animated, because I think that looks cool. */ async function update() { + let config = await common.Config() + for (let e of document.querySelectorAll(".location")) { + e.textContent = common.BaseURL + e.classList.toggle("hidden", !config.URLInScoreboard) + } + let state = await server.GetState() let rankingsElement = document.querySelector("#rankings") let logSize = state.PointsLog.length From 768600e48e6f869c0a62e7c3281c2f3d6d40840f Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Fri, 15 Sep 2023 16:13:09 -0600 Subject: [PATCH 22/34] Logout in devel mode generates a new TeamID --- theme/index.mjs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/theme/index.mjs b/theme/index.mjs index e9de612..231c845 100644 --- a/theme/index.mjs +++ b/theme/index.mjs @@ -10,12 +10,6 @@ class App { this.server = new moth.Server(basePath) - let uuid = Math.floor(Math.random() * 1000000).toString(16) - this.fakeRegistration = { - TeamID: uuid, - TeamName: `Team ${uuid}`, - } - for (let form of document.querySelectorAll("form.login")) { form.addEventListener("submit", event => this.handleLoginSubmit(event)) } @@ -103,9 +97,10 @@ class App { } if (this.state.DevelopmentMode() && !this.server.LoggedIn()) { + let teamID = Math.floor(Math.random() * 1000000).toString(16) common.Toast("Automatically logging in to devel server") - console.info("Logging in with generated Team ID and Team Name", this.fakeRegistration) - return this.Login(this.fakeRegistration.TeamID, this.fakeRegistration.TeamName) + console.info(`Logging in with generated Team ID: ${teamID}`) + return this.Login(teamID, `Team ${teamID}`) } } From 5350cf73a08d5ed16db3cf7e462e868aab1012bc Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Tue, 19 Sep 2023 16:48:24 -0600 Subject: [PATCH 23/34] leadership sprint bugfixes * Messages now in config.json * puzzle.html: display errors --- theme/config.json | 1 + theme/index.mjs | 9 +++++---- theme/moth.mjs | 2 +- theme/puzzle.mjs | 22 +++++++++++++++++----- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/theme/config.json b/theme/config.json index 1d5a0a1..d32b227 100644 --- a/theme/config.json +++ b/theme/config.json @@ -1,5 +1,6 @@ { "TrackSolved": true, "URLInScoreboard": true, + "Messages": "", "__sentry__": "this is here so you don't have to remember to take the comma off the last item" } \ No newline at end of file diff --git a/theme/index.mjs b/theme/index.mjs index 231c845..d30c013 100644 --- a/theme/index.mjs +++ b/theme/index.mjs @@ -68,6 +68,10 @@ class App { */ async UpdateConfig() { this.config = await common.Config() + + for (let e of document.querySelectorAll(".messages")) { + e.innerHTML = this.config.Messages || "" + } } /** @@ -79,9 +83,6 @@ class App { */ async UpdateState() { this.state = await this.server.GetState() - for (let e of document.querySelectorAll(".messages")) { - e.innerHTML = this.state.Messages - } // Update elements with data-track-solved for (let e of document.querySelectorAll("[data-track-solved]")) { @@ -133,7 +134,7 @@ class App { if (this.state.DevelopmentMode()) { let a = h.appendChild(document.createElement('a')) a.classList.add("mothball") - a.textContent = "📦" + a.textContent = "⬇️" a.href = this.server.URL(`mothballer/${cat}.mb`) a.title = "Download a compiled puzzle for this category" } diff --git a/theme/moth.mjs b/theme/moth.mjs index c1db74b..e59dfcb 100644 --- a/theme/moth.mjs +++ b/theme/moth.mjs @@ -352,7 +352,7 @@ class State { * @param {Object} obj Raw state data */ constructor(server, obj) { - for (let key of ["Config", "Messages", "TeamNames", "PointsLog"]) { + for (let key of ["Config", "TeamNames", "PointsLog"]) { if (!obj[key]) { throw(`Missing state property: ${key}`) } diff --git a/theme/puzzle.mjs b/theme/puzzle.mjs index 6f886d5..ef830a3 100644 --- a/theme/puzzle.mjs +++ b/theme/puzzle.mjs @@ -142,9 +142,24 @@ async function loadPuzzle(category, points) { } let puzzle = server.GetPuzzle(category, points) + console.time("Populate") - await puzzle.Populate() - console.timeEnd("Populate") + try { + await puzzle.Populate() + } + catch { + let error = puzzleElement().appendChild(document.createElement("pre")) + error.classList.add("notification", "error") + error.textContent = puzzle.Error.Body + return + } + finally { + console.timeEnd("Populate") + } + + console.info(`Setting base tag to ${contentBase}`) + let baseElement = document.head.appendChild(document.createElement("base")) + baseElement.href = contentBase console.info("Tweaking HTML...") let title = `${category} ${points}` @@ -183,9 +198,6 @@ async function loadPuzzle(category, points) { } } - let baseElement = document.head.appendChild(document.createElement("base")) - baseElement.href = contentBase - window.app.puzzle = puzzle console.info("window.app.puzzle =", window.app.puzzle) From 3282ad22b0ab90c26cfdd93e51169824ec401dba Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 27 Sep 2023 16:10:31 -0600 Subject: [PATCH 24/34] Scores, not Score --- .gitlab-ci.yml | 2 +- theme/moth.mjs | 4 ++-- theme/scoreboard.mjs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 058787d..abfb2c5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -4,7 +4,7 @@ stages: Run unit tests: stage: test - image: &goimage golang:1.18 + image: &goimage golang:1.21 only: refs: - main diff --git a/theme/moth.mjs b/theme/moth.mjs index e59dfcb..dd23081 100644 --- a/theme/moth.mjs +++ b/theme/moth.mjs @@ -480,7 +480,7 @@ class State { * * @yields {Scores} Snapshot at a point in time */ - * ScoreHistory() { + * ScoresHistory() { let scores = new Scores() for (let award of this.PointsLog) { scores.Add(award) @@ -493,7 +493,7 @@ class State { * * @returns {Scores} */ - CurrentScore() { + CurrentScores() { let scores for (scores of this.ScoreHistory()); return scores diff --git a/theme/scoreboard.mjs b/theme/scoreboard.mjs index 5d2e161..e600292 100644 --- a/theme/scoreboard.mjs +++ b/theme/scoreboard.mjs @@ -43,7 +43,7 @@ async function update() { } let frame = 0 - for (let scores of state.ScoreHistory()) { + for (let scores of state.ScoresHistory()) { frame += 1 if ((frame < state.PointsLog.length) && (frame % frameModulo)) { continue From 12979a55a3e35e94e6fc1dc07c3dc274328127d2 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 27 Sep 2023 16:14:23 -0600 Subject: [PATCH 25/34] We're not doing github builds any more --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index b78bab7..f2ce709 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ Dirtbags Monarch Of The Hill Server ===================== -![Build badge](https://github.com/dirtbags/moth/workflows/Build/Test/Push/badge.svg) ![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth) Monarch Of The Hill (MOTH) is a puzzle server. From 1ca2ec284fdc3b749c4c8929864d7f6e663c121c Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 27 Sep 2023 16:16:04 -0600 Subject: [PATCH 26/34] make the report card link to the report card --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f2ce709..183e405 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Dirtbags Monarch Of The Hill Server ===================== -![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth) +[![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth)](https://goreportcard.com/report/github.com/dirtbags/moth) Monarch Of The Hill (MOTH) is a puzzle server. We (the authors) have used it for instructional and contest events called From b293a9f0e9383353eeea7f32d44fca52da95f7be Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 27 Sep 2023 17:15:51 -0600 Subject: [PATCH 27/34] Add merged scoreboard.css --- theme/scoreboard.css | 110 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/theme/scoreboard.css b/theme/scoreboard.css index a3f55d1..fccb15d 100644 --- a/theme/scoreboard.css +++ b/theme/scoreboard.css @@ -5,6 +5,116 @@ } } +#rankings span { + font-size: 75%; + display: inline-block; + overflow: hidden; + height: 1.7em; +} +#rankings span.teamname { + font-size: inherit; + color: white; + text-shadow: 0 0 3px black; + opacity: 0.8; + position: absolute; + right: 0.2em; + background-color: #292929; + background-blend-mode: darken; + padding: 0em 0.2em; + border-top-left-radius: 0.5em; + border-bottom-left-radius: 0.5em; + margin:0em; + height: 1.5em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition-property: max-width; + transition-duration: 2s; + transition-delay: 0s; +} + +#rankings span.teamname:hover { + max-width: 100%; +} + +#rankings span.teampoints { + font-size:100%; + height:1.2em; + margin:0em; + padding:0em; + width:99%; +} + +/* Responsive design */ +/* Defaults */ + #rankings span.teampoints { + max-width:89%; + } + #rankings span.teamname { + max-width:10%; + } + + +/* Monitors with large enough screens to do side by side */ +@media only screen and (min-width: 170em) { + #chart, #rankings { + width: 49%; + display:inline-block; + vertical-align:middle; + } + +} + +/* Monitor +@media only screen and (max-width: 130em) { + #chart, #rankings { + width: 49%; + display:inline-block; + vertical-align: middle; + } + + #rankings span.teampoints { + max-width:89%; + } + #rankings span.teamname { + max-width:10%; + } +} + +/* Laptop size screen */ +@media only screen and (max-width: 100em) { + #rankings span.teampoints { + max-width:84%; + } + #rankings span.teamname { + max-width:15%; + } +} + +/* Roughly Tablet size */ +@media only screen and (max-width: 70em) { + #rankings span.teampoints { + max-width:79%; + } + #rankings span.teamname { + max-width:20%; + } +} + +/* Small screens phone size */ +@media only screen and (max-width: 40em) { + #rankings span.teampoints { + max-width:65%; + } + #rankings span.teamname { + max-width:34%; + } +} + + + + + #chart { background-color: rgba(0, 0, 0, 0.8); } From b863955fdce5d6eef983b9cdfefa7521dae53399 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 27 Sep 2023 17:56:40 -0600 Subject: [PATCH 28/34] Fully integrated --- theme/scoreboard.css | 194 ++++++------------------------------------- theme/scoreboard.mjs | 13 +-- 2 files changed, 34 insertions(+), 173 deletions(-) diff --git a/theme/scoreboard.css b/theme/scoreboard.css index fccb15d..2d8a48d 100644 --- a/theme/scoreboard.css +++ b/theme/scoreboard.css @@ -5,158 +5,9 @@ } } -#rankings span { - font-size: 75%; - display: inline-block; - overflow: hidden; - height: 1.7em; -} -#rankings span.teamname { - font-size: inherit; - color: white; - text-shadow: 0 0 3px black; - opacity: 0.8; - position: absolute; - right: 0.2em; - background-color: #292929; - background-blend-mode: darken; - padding: 0em 0.2em; - border-top-left-radius: 0.5em; - border-bottom-left-radius: 0.5em; - margin:0em; - height: 1.5em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - transition-property: max-width; - transition-duration: 2s; - transition-delay: 0s; -} - -#rankings span.teamname:hover { - max-width: 100%; -} - -#rankings span.teampoints { - font-size:100%; - height:1.2em; - margin:0em; - padding:0em; - width:99%; -} - -/* Responsive design */ -/* Defaults */ - #rankings span.teampoints { - max-width:89%; - } - #rankings span.teamname { - max-width:10%; - } - - -/* Monitors with large enough screens to do side by side */ -@media only screen and (min-width: 170em) { - #chart, #rankings { - width: 49%; - display:inline-block; - vertical-align:middle; - } - -} - -/* Monitor -@media only screen and (max-width: 130em) { - #chart, #rankings { - width: 49%; - display:inline-block; - vertical-align: middle; - } - - #rankings span.teampoints { - max-width:89%; - } - #rankings span.teamname { - max-width:10%; - } -} - -/* Laptop size screen */ -@media only screen and (max-width: 100em) { - #rankings span.teampoints { - max-width:84%; - } - #rankings span.teamname { - max-width:15%; - } -} - -/* Roughly Tablet size */ -@media only screen and (max-width: 70em) { - #rankings span.teampoints { - max-width:79%; - } - #rankings span.teamname { - max-width:20%; - } -} - -/* Small screens phone size */ -@media only screen and (max-width: 40em) { - #rankings span.teampoints { - max-width:65%; - } - #rankings span.teamname { - max-width:34%; - } -} - - -#chart { - background-color: rgba(0, 0, 0, 0.8); -} -.logo { - text-align: center; - background-color: rgba(255, 255, 255, 0.2); - font-family: Montserrat, sans-serif; - font-weight: 500; - border-radius: 10px; - font-size: 1.2em; -} -.cyber { - color: black; -} -.fire { - color: #d94a1f; -} -.announcement.floating { - position: fixed; - bottom: 0; - width: 100hw; - max-width: inherit; -} -.announcement { - background-color: rgba(255,255,255,0.5); - color: black; - padding: 0.25em; - border-radius: 5px; - max-width: 20em; - text-align: center; - display: flex; - align-items: flex-end; - justify-content: space-around; - font-size: 1.3em; - flex-wrap: wrap; -} -.announcement div { - margin: 1em; - max-width: 45vw; - text-align: center; - -} .location { color: #acf; background-color: #0008; @@ -169,18 +20,6 @@ font-weight:bold; text-decoration: underline; } -.qrcode { - width: 30vw; -} -.examples { - display: flex; - flex-wrap: wrap; - justify-content: space-between; -} -.examples > div { - margin: 0.5em; - max-width: 40%; -} /** Scoreboard */ #rankings { @@ -189,20 +28,23 @@ background-color: #000c; } #rankings div { - height: 1.4rem; + height: 1.2rem; + display: flex; + align-items: center; } #rankings div:nth-child(6n){ - background-color: #ccc1; + background-color: #ccc3; } #rankings div:nth-child(6n+3) { - background-color: #0f01; + background-color: #0f03; } #rankings span { - font-size: 75%; - display: inline-block; - overflow: hidden; - height: 1.4em; + display: inline-block; + overflow: hidden; +} +#rankings span.category { + font-size: 80%; } #rankings span.teamname { height: auto; @@ -213,6 +55,22 @@ position: absolute; right: 0.2em; } +#rankings span.teamname:hover, +#rankings span.category:hover { + width: inherit; + max-width: 100%; +} + +@media only screen and (max-width: 450px) { + #rankings span.teamname { + max-width: 6em; + text-overflow: ellipsis; + } + span.teampoints { + max-width: 80%; + } +} + #rankings div * {white-space: nowrap;} .cat0, .cat8, .cat16 {background-color: #a6cee3; color: black;} .cat1, .cat9, .cat17 {background-color: #1f78b4; color: white;} diff --git a/theme/scoreboard.mjs b/theme/scoreboard.mjs index e600292..5eaf0d3 100644 --- a/theme/scoreboard.mjs +++ b/theme/scoreboard.mjs @@ -61,25 +61,28 @@ async function update() { let row = rankingsElement.appendChild(document.createElement("div")) - let heading = row.appendChild(document.createElement("span")) - heading.textContent = teamName - heading.classList.add("teamname") + let teamname = row.appendChild(document.createElement("span")) + teamname.textContent = teamName + teamname.classList.add("teamname") let categoryNumber = 0 + let teampoints = row.appendChild(document.createElement("span")) + teampoints.classList.add("teampoints") for (let category of scores.Categories) { let score = scores.CyFiCategoryScore(category, teamID) if (!score) { continue } - let block = row.appendChild(document.createElement("span")) + let block = teampoints.appendChild(document.createElement("span")) let points = scores.GetPoints(category, teamID) let width = MaxScoreWidth * score / topScore block.textContent = category block.title = `${points} points` block.style.width = `${width}%` - block.classList.add(`cat${categoryNumber}`) + block.classList.add("category", `cat${categoryNumber}`) + categoryNumber += 1 } } From 43aec24d638c6df85f73dcad89770016fc118000 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 27 Sep 2023 17:57:30 -0600 Subject: [PATCH 29/34] more cleanup --- theme/scoreboard.css | 3 --- 1 file changed, 3 deletions(-) diff --git a/theme/scoreboard.css b/theme/scoreboard.css index 2d8a48d..7381764 100644 --- a/theme/scoreboard.css +++ b/theme/scoreboard.css @@ -5,9 +5,6 @@ } } - - - .location { color: #acf; background-color: #0008; From 90716313530cb1636a91b928f9594b6ce5cff156 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 27 Sep 2023 17:58:29 -0600 Subject: [PATCH 30/34] more cleanup --- theme/basic.css | 1 - 1 file changed, 1 deletion(-) diff --git a/theme/basic.css b/theme/basic.css index 43cbf70..667e99d 100644 --- a/theme/basic.css +++ b/theme/basic.css @@ -113,7 +113,6 @@ input:invalid { cursor: help; } - /** Development mode information */ .debug { overflow: auto; From 3d8c47d3167d0f6cecdd82d98f0c4ef16d65e77c Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 27 Sep 2023 18:17:11 -0600 Subject: [PATCH 31/34] Integrate Ken's "monarch of the category" --- theme/scoreboard.css | 5 +++++ theme/scoreboard.mjs | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/theme/scoreboard.css b/theme/scoreboard.css index 7381764..c95fa65 100644 --- a/theme/scoreboard.css +++ b/theme/scoreboard.css @@ -57,6 +57,11 @@ width: inherit; max-width: 100%; } +.topscore::before { + content: "✩"; + font-size: 75%; + vertical-align: top; +} @media only screen and (max-width: 450px) { #rankings span.teamname { diff --git a/theme/scoreboard.mjs b/theme/scoreboard.mjs index 5eaf0d3..e01a6a2 100644 --- a/theme/scoreboard.mjs +++ b/theme/scoreboard.mjs @@ -74,7 +74,8 @@ async function update() { continue } - let block = teampoints.appendChild(document.createElement("span")) + // XXX: Figure out how to do this properly with flexbox + let block = row.appendChild(document.createElement("span")) let points = scores.GetPoints(category, teamID) let width = MaxScoreWidth * score / topScore @@ -82,6 +83,7 @@ async function update() { block.title = `${points} points` block.style.width = `${width}%` block.classList.add("category", `cat${categoryNumber}`) + block.classList.toggle("topscore", score == 1) categoryNumber += 1 } From eb786ba1842828bcd21f42f0dc9833908d1b820e Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 28 Sep 2023 12:42:25 -0600 Subject: [PATCH 32/34] More scoreboard configurables --- theme/config.json | 11 +++++++++-- theme/scoreboard.mjs | 38 +++++++++++++++++++++++++++----------- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/theme/config.json b/theme/config.json index d32b227..bbe7c6c 100644 --- a/theme/config.json +++ b/theme/config.json @@ -1,6 +1,13 @@ { "TrackSolved": true, - "URLInScoreboard": true, + "Scoreboard": { + "DisplayServerURL": true, + "ShowCategoryLeaders": true, + "ReplayHistory": true, + "ReplayFPS": 30, + "ReplayDurationMS": 2000, + "": "" + }, "Messages": "", - "__sentry__": "this is here so you don't have to remember to take the comma off the last item" + "": "this is here so you don't have to remember to take the comma off the last item" } \ No newline at end of file diff --git a/theme/scoreboard.mjs b/theme/scoreboard.mjs index e01a6a2..e5fe5bd 100644 --- a/theme/scoreboard.mjs +++ b/theme/scoreboard.mjs @@ -2,10 +2,8 @@ import * as moth from "./moth.mjs" import * as common from "./common.mjs" const server = new moth.Server(".") -const ReplayDuration = 0.3 * common.Second -const MaxFrameRate = 60 /** Don't let any team's score exceed this percentage width */ -const MaxScoreWidth = 95 +const MaxScoreWidth = 90 /** * Returns a promise that resolves after timeout. @@ -23,10 +21,26 @@ function sleep(timeout) { * The update is animated, because I think that looks cool. */ async function update() { - let config = await common.Config() + let config = {} + try { + config = await common.Config() + } + catch (err) { + console.warn("Parsing config.json:", err) + } + + // Pull configuration settings + let ScoreboardConfig = config.Scoreboard ?? {} + let ReplayHistory = ScoreboardConfig.ReplayHistory ?? false + let ReplayDurationMS = ScoreboardConfig.ReplayDurationMS ?? 300 + let ReplayFPS = ScoreboardConfig.ReplayFPS ?? 24 + if (!config.Scoreboard) { + console.warn("config.json has empty Scoreboard section") + } + for (let e of document.querySelectorAll(".location")) { e.textContent = common.BaseURL - e.classList.toggle("hidden", !config.URLInScoreboard) + e.classList.toggle("hidden", !ScoreboardConfig.DisplayServerURL) } let state = await server.GetState() @@ -34,19 +48,21 @@ async function update() { let logSize = state.PointsLog.length // Figure out the timing so that we can replay the scoreboard in about - // ReplayDuration, but no more than 24 frames per second. + // ReplayDurationMS, but no more than 24 frames per second. let frameModulo = 1 let delay = 0 - while (delay < (common.Second / MaxFrameRate)) { + while (delay < (common.Second / ReplayFPS)) { frameModulo += 1 - delay = ReplayDuration / (logSize / frameModulo) + delay = ReplayDurationMS / (logSize / frameModulo) } let frame = 0 for (let scores of state.ScoresHistory()) { frame += 1 - if ((frame < state.PointsLog.length) && (frame % frameModulo)) { - continue + if (frame < state.PointsLog.length) { // Always render the last frame + if (!ReplayHistory || (frame % frameModulo)) { // Skip if we're not animating, or if we need to drop frames + continue + } } while (rankingsElement.firstChild) rankingsElement.firstChild.remove() @@ -83,7 +99,7 @@ async function update() { block.title = `${points} points` block.style.width = `${width}%` block.classList.add("category", `cat${categoryNumber}`) - block.classList.toggle("topscore", score == 1) + block.classList.toggle("topscore", (score == 1) && ScoreboardConfig.ShowCategoryLeaders) categoryNumber += 1 } From 6ff379e0f405f72e3001f218066897b6c58677ed Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 28 Sep 2023 12:59:51 -0600 Subject: [PATCH 33/34] try to prevent future bad decisions --- theme/scoreboard.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/theme/scoreboard.mjs b/theme/scoreboard.mjs index e5fe5bd..ffc6b6e 100644 --- a/theme/scoreboard.mjs +++ b/theme/scoreboard.mjs @@ -7,7 +7,11 @@ const MaxScoreWidth = 90 /** * Returns a promise that resolves after timeout. - * + * + * This uses setTimeout instead of some other fancy thing like + * requestAnimationFrame, because who actually cares about scoreboard update + * framerate? + * * @param {Number} timeout How long to sleep (milliseconds) * @returns {Promise} */ From 0abb44c48c9ad5848306f6edd278191826dd1309 Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Thu, 28 Sep 2023 18:16:18 -0600 Subject: [PATCH 34/34] Actually implement login, LOL --- theme/basic.css | 6 ++++++ theme/index.html | 2 +- theme/index.mjs | 3 ++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/theme/basic.css b/theme/basic.css index 667e99d..808af78 100644 --- a/theme/basic.css +++ b/theme/basic.css @@ -52,6 +52,12 @@ input { background-color: #ccc4; color: inherit; } +input:hover { + background-color: #8884; +} +input:active { + background-color: inherit; +} .notification, .error { padding: 0 1em; border-radius: 8px; diff --git a/theme/index.html b/theme/index.html index ee6853b..cf325e3 100644 --- a/theme/index.html +++ b/theme/index.html @@ -10,7 +10,7 @@ -

      MOTH

      +

      MOTH

      diff --git a/theme/index.mjs b/theme/index.mjs index d30c013..4b91d5e 100644 --- a/theme/index.mjs +++ b/theme/index.mjs @@ -26,7 +26,8 @@ class App { handleLoginSubmit(event) { event.preventDefault() - console.log(event) + let f = new FormData(event.target) + this.Login(f.get("id"), f.get("name")) } /**