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:
      + + +
      +