mirror of https://github.com/dirtbags/moth.git
Compare commits
2 Commits
b135069851
...
175b7aaa1b
Author | SHA1 | Date |
---|---|---|
Neale Pickett | 175b7aaa1b | |
Neale Pickett | a82851fee3 |
|
@ -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
|
||||||
|
|
|
@ -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") {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 180 KiB |
|
@ -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;
|
||||||
|
}
|
|
@ -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,
|
||||||
}
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
145
theme/puzzle.mjs
145
theme/puzzle.mjs
|
@ -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 {
|
||||||
|
|
Loading…
Reference in New Issue