Compare commits

..

2 Commits

Author SHA1 Message Date
Neale Pickett 175b7aaa1b CoS hover cursor fix 2023-09-12 17:32:34 -06:00
Neale Pickett a82851fee3 Lots more (circle of success!) 2023-09-12 17:30:36 -06:00
9 changed files with 265 additions and 51 deletions

View File

@ -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/), 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). 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 ## [v4.4.9] - 2022-05-12
### Changed ### Changed
- Added a performance optimization for events with a large number of teams - Added a performance optimization for events with a large number of teams

View File

@ -87,7 +87,7 @@ class QixLine {
* like the video game "qix" * like the video game "qix"
*/ */
class QixBackground { class QixBackground {
constructor(ctx) { constructor(ctx, frameInterval = SECOND/6) {
this.ctx = ctx this.ctx = ctx
this.min = new Point(0, 0) this.min = new Point(0, 0)
this.max = new Point(this.ctx.canvas.width, this.ctx.canvas.height) this.max = new Point(this.ctx.canvas.width, this.ctx.canvas.height)
@ -105,17 +105,25 @@ class QixBackground {
} }
this.velocity = new QixLine( this.velocity = new QixLine(
0.001, 0.001,
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 / 200), 1 + randint(this.box.y / 200)), 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() this.lines.shift()
let lastLine = this.lines[this.lines.length - 1] let lastLine = this.lines[this.lines.length - 1]
let nextLine = new QixLine( let nextLine = new QixLine(
@ -129,7 +137,7 @@ class QixBackground {
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height) this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
for (let line of this.lines) { for (let line of this.lines) {
this.ctx.save() 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.beginPath()
this.ctx.moveTo(line.a.x, line.a.y) this.ctx.moveTo(line.a.x, line.a.y)
this.ctx.lineTo(line.b.x, line.b.y) this.ctx.lineTo(line.b.x, line.b.y)
@ -148,7 +156,9 @@ function init() {
let ctx = canvas.getContext("2d") let ctx = canvas.getContext("2d")
new QixBackground(ctx) let qix = new QixBackground(ctx)
setInterval(() => qix.Animate(), SECOND/6)
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {

View File

@ -22,17 +22,6 @@ h1 {
a:any-link { a:any-link {
color: #b9cbd8; 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 { .notification {
background: #ac8f3944; background: #ac8f3944;
} }
@ -55,6 +44,21 @@ canvas.wallpaper {
body { body {
font-family: sans-serif; 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 { main {
max-width: 40em; max-width: 40em;
@ -98,6 +102,9 @@ img {
input:invalid { input:invalid {
border-color: red; border-color: red;
} }
.answer_ok {
cursor: help;
}
#messages { #messages {
min-height: 3em; min-height: 3em;
} }

BIN
theme/bg.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

20
theme/hash.mjs Normal file
View File

@ -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;
}

View File

@ -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. * A point award.
*/ */
@ -103,6 +165,23 @@ class Puzzle {
Get(filename) { Get(filename) {
return this.server.GetContent(this.Category, this.Points, 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 { export {
Server Hash,
Server,
} }

View File

@ -6,8 +6,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<link rel="icon" href="luna-moth.svg"> <link rel="icon" href="luna-moth.svg">
<link rel="stylesheet" href="basic.css"> <link rel="stylesheet" href="basic.css">
<script src="puzzle.mjs" type="module"></script> <script src="background.mjs" type="module" async></script>
<script src="background.mjs" type="module"></script> <script src="puzzle.mjs" type="module" async></script>
</head> </head>
<body> <body>
<main> <main>
@ -23,7 +23,7 @@
</section> </section>
<form class="answer"> <form class="answer">
Team ID: <input type="text" name="id"> <br> Team ID: <input type="text" name="id"> <br>
Answer: <input type="text" name="answer"> <span id="answer_ok"></span><br> Answer: <input type="text" name="answer" id="answer"> <span class="answer_ok"></span><br>
<input type="submit" value="Submit"> <input type="submit" value="Submit">
</form> </form>
</main> </main>

View File

@ -19,7 +19,7 @@ function devel_addin(e) {
if (log.length > 0) { if (log.length > 0) {
e.appendChild(document.createElement("h3")).textContent = "Log" e.appendChild(document.createElement("h3")).textContent = "Log"
let le = e.appendChild(document.createElement("ul")) let le = e.appendChild(document.createElement("ul"))
for (entry of log) { for (let entry of log) {
le.appendChild(document.createElement("li")).textContent = entry le.appendChild(document.createElement("li")).textContent = entry
} }
} }

View File

@ -1,13 +1,45 @@
import * as moth from "./moth.mjs" import * as moth from "./moth.mjs"
/** Stores the current puzzle, globally */ /**
let puzzle = null * Handle a submit event on a form.
*
function submit(event) { * 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() event.preventDefault()
console.log(event) 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) { function puzzleElement(clear=true) {
let e = document.querySelector("#puzzle") let e = document.querySelector("#puzzle")
if (clear) { if (clear) {
@ -16,33 +48,100 @@ function puzzleElement(clear=true) {
return e 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.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) { async function loadPuzzle(category, points) {
let server = new moth.Server() console.group("Loading puzzle:", category, points)
puzzle = server.GetPuzzle(category, points) let contentBase = new URL(`content/${category}/${points}/`, location)
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() {
// Tell user we're loading // Tell user we're loading
puzzleElement().appendChild(document.createElement("progress")) puzzleElement().appendChild(document.createElement("progress"))
for (let qs of ["#authors", "#title", "title"]) { for (let qs of ["#authors", "#title", "title"]) {
for (let e of document.querySelectorAll(qs)) { for (let e of document.querySelectorAll(qs)) {
e.textContent = "[loading]" 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 hashpart = location.hash.split("#")[1] || ""
let catpoints = hashpart.split(":") let catpoints = hashpart.split(":")
let category = catpoints[0] let category = catpoints[0]
@ -56,14 +155,6 @@ function hashchange() {
.catch(err => error(err)) .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") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init) document.addEventListener("DOMContentLoaded", init)
} else { } else {