diff --git a/CHANGELOG.md b/CHANGELOG.md index b87666a..e12853c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [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 +- [moth.mjs](theme/moth.mjs) is now the standard MOTH library for ECMAScript + ## [v4.4.9] - 2022-05-12 ### Changed - Added a performance optimization for events with a large number of teams diff --git a/theme/background.mjs b/theme/background.mjs index 43ace38..5859fb5 100644 --- a/theme/background.mjs +++ b/theme/background.mjs @@ -87,7 +87,7 @@ class QixLine { * like the video game "qix" */ class QixBackground { - constructor(ctx) { + constructor(ctx, frameInterval = SECOND/6) { this.ctx = ctx this.min = new Point(0, 0) this.max = new Point(this.ctx.canvas.width, this.ctx.canvas.height) @@ -105,17 +105,25 @@ class QixBackground { } this.velocity = new QixLine( 0.001, - new Point(1 + randint(this.box.x / 200), 1 + randint(this.box.y / 200)), - new Point(1 + randint(this.box.x / 200), 1 + randint(this.box.y / 200)), + new Point(1 + randint(this.box.x / 100), 1 + randint(this.box.y / 100)), + new Point(1 + randint(this.box.x / 100), 1 + randint(this.box.y / 100)), ) - setInterval(() => this.animate(), SECOND/6) + this.frameInterval = frameInterval + this.nextFrame = 0 } /** - * Animate one frame + * Maybe draw a frame */ - animate() { + Animate() { + let now = performance.now() + if (now < this.nextFrame) { + // Not today, satan + return + } + this.nextFrame = now + this.frameInterval + this.lines.shift() let lastLine = this.lines[this.lines.length - 1] let nextLine = new QixLine( @@ -129,7 +137,7 @@ class QixBackground { this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height) for (let line of this.lines) { this.ctx.save() - this.ctx.strokeStyle = `hwb(${line.hue}turn 0% 50%)` + this.ctx.strokeStyle = `hwb(${line.hue}turn 0% 0%)` this.ctx.beginPath() this.ctx.moveTo(line.a.x, line.a.y) this.ctx.lineTo(line.b.x, line.b.y) @@ -148,7 +156,9 @@ function init() { let ctx = canvas.getContext("2d") - new QixBackground(ctx) + let qix = new QixBackground(ctx) + setInterval(() => qix.Animate(), SECOND/6) + } if (document.readyState === "loading") { diff --git a/theme/basic.css b/theme/basic.css index 9eab8b8..4635d93 100644 --- a/theme/basic.css +++ b/theme/basic.css @@ -22,17 +22,6 @@ h1 { a:any-link { color: #b9cbd8; } -canvas.wallpaper { - position: fixed; - display: block; - z-index: -1000; - top: 0; - left: 0; - height: 100vh; - width: 100vw; - opacity: 0.3; - image-rendering: pixelated; -} .notification { background: #ac8f3944; } @@ -55,6 +44,21 @@ canvas.wallpaper { body { font-family: sans-serif; + background-image: url("bg.png"); + background-size: contain; + background-blend-mode: soft-light; + background-attachment: fixed; +} +canvas.wallpaper { + position: fixed; + display: block; + z-index: -1000; + top: 0; + left: 0; + height: 100vh; + width: 100vw; + opacity: 0.2; + image-rendering: pixelated; } main { max-width: 40em; diff --git a/theme/bg.png b/theme/bg.png new file mode 100755 index 0000000..5578151 Binary files /dev/null and b/theme/bg.png differ diff --git a/theme/hash.mjs b/theme/hash.mjs new file mode 100644 index 0000000..08c8e73 --- /dev/null +++ b/theme/hash.mjs @@ -0,0 +1,20 @@ +// Dan Bernstein hash v1 +// Used until MOTH v3.5 +function djb2(buf) { + let h = 5381 + for (let c of (new TextEncoder()).encode(buf)) { // Encode as UTF-8 and read in each byte + // JavaScript converts everything to a signed 32-bit integer when you do bitwise operations. + // So we have to do "unsigned right shift" by zero to get it back to unsigned. + h = (((h * 33) + c) & 0xffffffff) >>> 0 + } + return h + } + + // Used until MOTH v4.5 + async function sha256(message) { + const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array + const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); // hash the message + const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string + return hashHex; + } \ No newline at end of file diff --git a/theme/moth.mjs b/theme/moth.mjs index f76f5e0..5bd2911 100644 --- a/theme/moth.mjs +++ b/theme/moth.mjs @@ -1,3 +1,65 @@ +/** + * Hash/digest functions + */ +class Hash { + /** + * Dan Bernstein hash + * + * Used until MOTH v3.5 + * + * @param {String} buf Input + * @returns {Number} + */ + static djb2(buf) { + let h = 5381 + for (let c of (new TextEncoder()).encode(buf)) { // Encode as UTF-8 and read in each byte + // JavaScript converts everything to a signed 32-bit integer when you do bitwise operations. + // So we have to do "unsigned right shift" by zero to get it back to unsigned. + h = (((h * 33) + c) & 0xffffffff) >>> 0 + } + return h + } + + /** + * Dan Bernstein hash with xor improvement + * + * @param {String} buf Input + * @returns {Number} + */ + static djb2xor(buf) { + let h = 5381 + for (let c of (new TextEncoder()).encode(buf)) { + h = h * 33 ^ c + } + return h + } + + /** + * SHA 256 + * + * Used until MOTH v4.5 + * + * @param {String} buf Input + * @returns {String} hex-encoded digest + */ + static async sha256(buf) { + const msgUint8 = new TextEncoder().encode(buf) + const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return this.hexlify(hashArray); + } + + /** + * Hex-encode a byte array + * + * @param {Number[]} buf Byte array + * @returns {String} + */ + static async hexlify(buf) { + return buf.map(b => b.toString(16).padStart(2, "0")).join("") + } +} + /** * A point award. */ @@ -103,6 +165,23 @@ class Puzzle { Get(filename) { return this.server.GetContent(this.Category, this.Points, filename) } + + async IsPossiblyCorrect(str) { + let userAnswerHashes = [ + Hash.djb2(str), + Hash.djb2xor(str), + await Hash.sha256(str), + ] + + for (let pah of this.AnswerHashes) { + for (let uah of userAnswerHashes) { + if (pah == uah) { + return true + } + } + } + return false + } } /** @@ -343,5 +422,6 @@ class Server { } export { - Server + Hash, + Server, } \ No newline at end of file diff --git a/theme/puzzle.html b/theme/puzzle.html index acc42bc..a9f0963 100644 --- a/theme/puzzle.html +++ b/theme/puzzle.html @@ -6,8 +6,8 @@ - - + +
@@ -23,7 +23,7 @@
Team ID:
- Answer:
+ Answer:
diff --git a/theme/puzzle.js b/theme/puzzle.js index 9913878..ab65341 100644 --- a/theme/puzzle.js +++ b/theme/puzzle.js @@ -19,7 +19,7 @@ function devel_addin(e) { if (log.length > 0) { e.appendChild(document.createElement("h3")).textContent = "Log" let le = e.appendChild(document.createElement("ul")) - for (entry of log) { + for (let entry of log) { le.appendChild(document.createElement("li")).textContent = entry } } diff --git a/theme/puzzle.mjs b/theme/puzzle.mjs index 43fa17d..ce15f27 100644 --- a/theme/puzzle.mjs +++ b/theme/puzzle.mjs @@ -1,13 +1,45 @@ import * as moth from "./moth.mjs" -/** Stores the current puzzle, globally */ -let puzzle = null - -function submit(event) { +/** + * Handle a submit event on a form. + * + * This event will be 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. + * + * @param {Event} event + */ +function formSubmitHandler(event) { event.preventDefault() console.log(event) } +/** + * Handle an input event on the answer field. + * + * @param {Event} event + */ +async function answerInputHandler(event) { + let answer = event.target.value + let correct = await window.app.puzzle.IsPossiblyCorrect(answer) + for (let ok of event.target.parentElement.querySelectorAll(".answer_ok")) { + if (correct) { + ok.textContent = "⭕" + ok.title = "Possibly correct" + } else { + ok.textContent = "❌" + ok.title = "Definitely not correct" + } + } +} + +/** + * Return the puzzle content element, possibly with everything cleared out of it. + * + * @param {Boolean} clear Should the element be cleared of children? Default true. + * @returns {Element} + */ function puzzleElement(clear=true) { let e = document.querySelector("#puzzle") if (clear) { @@ -16,33 +48,100 @@ function puzzleElement(clear=true) { return e } -function error(message) { - let e = puzzleElement().appendChild(document.createElement("p")) +/** + * 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 + */ +function error(error) { + console.error(error) + let e = puzzleElement().appendChild(document.createElement("pre")) e.classList.add("error") - e.textContent = message + e.textContent = error.Body || error } +/** + * Set the answer and invoke input handlers. + * + * This makes sure the Circle Of Success gets updated. + * + * @param {String} s + */ +function setanswer(s) { + let e = document.querySelector("#answer") + e.value = s + e.dispatchEvent(new Event("input")) +} + +/** + * Load the given puzzle. + * + * @param {String} category + * @param {Number} points + */ async function loadPuzzle(category, points) { - let server = new moth.Server() - puzzle = server.GetPuzzle(category, points) - await puzzle.Populate() - - let title = `${category} ${points}` - document.querySelector("title").textContent = title - document.querySelector("#title").textContent = title - document.querySelector("#authors").textContent = puzzle.Authors.join(", ") - puzzleElement().innerHTML = puzzle.Body -} - -function hashchange() { + console.group("Loading puzzle:", category, points) + let contentBase = new URL(`content/${category}/${points}/`, location) + // Tell user we're loading puzzleElement().appendChild(document.createElement("progress")) for (let qs of ["#authors", "#title", "title"]) { for (let e of document.querySelectorAll(qs)) { e.textContent = "[loading]" } + } + + let server = new moth.Server() + let puzzle = server.GetPuzzle(category, points) + console.time("Puzzle load") + await puzzle.Populate() + console.timeEnd("Puzzle load") + + let title = `${category} ${points}` + document.querySelector("title").textContent = title + document.querySelector("#title").textContent = title + document.querySelector("#authors").textContent = puzzle.Authors.join(", ") + puzzleElement().innerHTML = puzzle.Body + + console.info("Adding attached scripts...") + for (let script of (puzzle.Scripts || [])) { + let st = document.createElement("script") + document.head.appendChild(st) + st.src = new URL(script, contentBase) + } + + console.info("Listing attached files...") + for (let fn of (puzzle.Attachments || [])) { + let li = document.createElement("li") + let a = document.createElement("a") + a.href = new URL(fn, contentBase) + a.innerText = fn + li.appendChild(a) + document.getElementById("files").appendChild(li) } + + window.app.puzzle = puzzle + console.info("window.app.puzzle =", window.app.puzzle) + + console.groupEnd() +} + +function init() { + window.app = {} + window.setanswer = setanswer + + for (let form of document.querySelectorAll("form.answer")) { + form.addEventListener("submit", formSubmitHandler) + for (let e of form.querySelectorAll("[name=answer]")) { + e.addEventListener("input", answerInputHandler) + } + } + // There isn't a more graceful way to "unload" scripts attached to the current puzzle + window.addEventListener("hashchange", () => location.reload()) + let hashpart = location.hash.split("#")[1] || "" let catpoints = hashpart.split(":") let category = catpoints[0] @@ -56,14 +155,6 @@ function hashchange() { .catch(err => error(err)) } -function init() { - for (let e of document.querySelectorAll("form.answer")) { - e.addEventListener("submit", submit) - } - window.addEventListener("hashchange", hashchange) - hashchange() -} - if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init) } else {