Mostly using new library, except scoreboard

This commit is contained in:
Neale Pickett 2023-09-13 18:52:52 -06:00
parent 0831c4e3d5
commit 9ea39363b8
18 changed files with 732 additions and 661 deletions

View File

@ -169,7 +169,6 @@ func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time,
// Register associates a team name with a team ID. // Register associates a team name with a team ID.
func (mh *MothRequestHandler) Register(teamName string) error { func (mh *MothRequestHandler) Register(teamName string) error {
// BUG(neale): Register returns an error if a team is already registered; it may make more sense to return success
if teamName == "" { if teamName == "" {
return fmt.Errorf("empty team name") return fmt.Errorf("empty team name")
} }

View File

@ -2,9 +2,9 @@ function randint(max) {
return Math.floor(Math.random() * max) return Math.floor(Math.random() * max)
} }
const MILLISECOND = 1 const Millisecond = 1
const SECOND = MILLISECOND * 1000 const Second = Millisecond * 1000
const FRAMERATE = 24 / SECOND // Fast enough for this tomfoolery const FrameRate = 24 / Second // Fast enough for this tomfoolery
class Point { class Point {
constructor(x, y) { constructor(x, y) {
@ -88,7 +88,7 @@ class QixLine {
* like the video game "qix" * like the video game "qix"
*/ */
class QixBackground { class QixBackground {
constructor(ctx, frameRate = 6/SECOND) { constructor(ctx, frameRate = 6/Second) {
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)
@ -110,7 +110,7 @@ class QixBackground {
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)),
) )
this.frameInterval = MILLISECOND / frameRate this.frameInterval = Millisecond / frameRate
this.nextFrame = 0 this.nextFrame = 0
} }
@ -149,6 +149,12 @@ class QixBackground {
} }
function init() { function init() {
// Don't like the background animation? You can disable it by setting a
// property in localStorage and reloading.
if (localStorage.disableBackgroundAnimation) {
return
}
let canvas = document.createElement("canvas") let canvas = document.createElement("canvas")
canvas.width = 640 canvas.width = 640
canvas.height = 640 canvas.height = 640
@ -159,7 +165,7 @@ function init() {
let qix = new QixBackground(ctx) let qix = new QixBackground(ctx)
// window.requestAnimationFrame is overkill for something this silly // window.requestAnimationFrame is overkill for something this silly
setInterval(() => qix.Animate(), MILLISECOND/FRAMERATE) setInterval(() => qix.Animate(), Millisecond/FrameRate)
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {

View File

@ -29,6 +29,15 @@ a:any-link {
background: red; background: red;
color: white; color: white;
} }
.toast {
background: #333;
color: #eee;
box-shadow: 0px 0px 8px 0px #0b0;
}
.debug {
background: #ccc;
color: black;
}
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
body { body {
background: #b9cbd8; background: #b9cbd8;
@ -45,7 +54,8 @@ a:any-link {
body { body {
font-family: sans-serif; font-family: sans-serif;
background-image: url("bg.png"); background-image: url("bg.png");
background-size: contain; background-size: cover;
background-position: center;
background-blend-mode: soft-light; background-blend-mode: soft-light;
background-attachment: fixed; background-attachment: fixed;
} }
@ -62,7 +72,7 @@ canvas.wallpaper {
} }
main { main {
max-width: 40em; max-width: 40em;
margin: auto; margin: 1em auto;
padding: 1px 3px; padding: 1px 3px;
border-radius: 5px; border-radius: 5px;
} }
@ -85,19 +95,43 @@ input, select {
padding: 0 1em; padding: 0 1em;
border-radius: 8px; border-radius: 8px;
} }
.hidden {
display: none;
}
/** Puzzles list */
.category {
margin: 5px 0;
background: #ccc4;
}
.category h2 {
margin: 0 0.2em;
}
nav ul, .category ul { nav ul, .category ul {
padding: 1em; margin: 0;
padding: 0.2em 1em;
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
} }
nav li, .category li { nav li, .category li {
display: inline; display: inline;
margin: 1em;
} }
iframe#body { .mothball {
border: inherit; float: right;
width: 100%; text-decoration: none;
border-radius: 5px;
background: #ccc;
padding: 4px 8px;
margin: 5px;
} }
img {
/** Puzzle content */
#puzzle {
border-bottom: solid;
padding: 0 0.5em;
}
#puzzle img {
max-width: 100%; max-width: 100%;
} }
input:invalid { input:invalid {
@ -106,9 +140,8 @@ input:invalid {
.answer_ok { .answer_ok {
cursor: help; cursor: help;
} }
#messages {
min-height: 3em; /** Scoreboard */
}
#rankings { #rankings {
width: 100%; width: 100%;
position: relative; position: relative;
@ -138,11 +171,17 @@ input:invalid {
.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;} .cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;}
.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;} .cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;}
.debug {
#devel {
overflow: auto; overflow: auto;
padding: 1em;
border-radius: 10px;
margin: 2em auto;
}
.debug dt {
font-weight: bold;
} }
/** Draggable items, from the draggable plugin */
li[draggable]::before { li[draggable]::before {
content: "↕"; content: "↕";
padding: 0.5em; padding: 0.5em;
@ -160,6 +199,28 @@ li[draggable] {
border: 1px white dashed; border: 1px white dashed;
} }
#cacheButton.disabled {
display: none;
/** Toasts are little pop-up informational messages. */
.toasts {
position: fixed;
z-index: 100;
bottom: 10px;
left: 10px;
text-align: center;
width: calc(100% - 20px);
display: flex;
flex-direction: column;
}
.toast {
border-radius: 0.5em;
padding: 0.2em 2em;
animation: fadeIn ease 1s;
margin: 2px auto;
}
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
} }

43
theme/common.mjs Normal file
View File

@ -0,0 +1,43 @@
/**
* Common functionality
*/
const Millisecond = 1
const Second = Millisecond * 1000
const Minute = Second * 60
/**
* Display a transient message to the user.
*
* @param {String} message Message to display
* @param {Number} timeout How long before removing this message
*/
function Toast(message, timeout=5*Second) {
console.info(message)
for (let toasts of document.querySelectorAll(".toasts")) {
let p = toasts.appendChild(document.createElement("p"))
p.classList.add("toast")
p.textContent = message
setTimeout(() => p.remove(), timeout)
}
}
/**
* Run a function when the DOM has been loaded.
*
* @param {function():void} cb Callback function
*/
function WhenDOMLoaded(cb) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", cb)
} else {
cb()
}
}
export {
Millisecond,
Second,
Minute,
Toast,
WhenDOMLoaded,
}

View File

@ -1,32 +1,34 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<title>MOTH</title> <title>MOTH</title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<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="moth.mjs" type="module"></script> <script src="index.mjs" type="module" async></script>
<script src="background.mjs" type="module"></script> <script src="background.mjs" type="module" async></script>
</head> </head>
<body> <body>
<h1 id="title">MOTH</h1> <h1 class="title">MOTH</h1>
<main> <main>
<div id="messages notification"> <div class="messages notification">
</div> </div>
<form id="login"> <form class="login">
Team ID: <input name="id"> <br> Team ID: <input name="id"> <br>
Team name: <input name="name"> <br> Team name: <input name="name"> <br>
<input type="submit" value="Sign In"> <input type="submit" value="Sign In">
</form> </form>
<div id="puzzles"></div> <div class="puzzles"></div>
<div class="toasts"></div>
</main> </main>
<nav> <nav>
<ul> <ul>
<li><a href="scoreboard.html">Scoreboard</a></li> <li><a href="scoreboard.html" target="_blank">Scoreboard</a></li>
<li><a href="logout.html">Sign Out</a></li> <li><button class="logout">Sign Out</button></li>
</ul> </ul>
</nav> </nav>
</body> </body>

158
theme/index.mjs Normal file
View File

@ -0,0 +1,158 @@
/**
* Functionality for index.html (Login / Puzzles list)
*/
import * as moth from "./moth.mjs"
import * as common from "./common.mjs"
class App {
constructor(basePath=".") {
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))
}
for (let e of document.querySelectorAll(".logout")) {
e.addEventListener("click", () => this.Logout())
}
setInterval(() => this.Update(), common.Minute/3)
this.Update()
}
handleLoginSubmit(event) {
event.preventDefault()
console.log(event)
}
/**
* Attempt to log in to the server.
*
* @param {String} teamId
* @param {String} teamName
*/
async Login(teamId, teamName) {
try {
await this.server.Login(teamId, teamName)
common.Toast(`Logged in (team id = ${teamId})`)
this.Update()
}
catch (error) {
common.Toast(error)
}
}
/**
* Log out of the server by clearing the saved Team ID.
*/
async Logout() {
try {
this.server.Reset()
common.Toast("Logged out")
this.Update()
}
catch (error) {
common.Toast(error)
}
}
/**
* Update the entire page.
*
* Fetch a new state, and rebuild all dynamic elements on this bage based on
* what's returned. If we're in development mode and not logged in, auto
* login too.
*/
async Update() {
this.state = await this.server.GetState()
for (let e of document.querySelectorAll(".messages")) {
e.innerHTML = this.state.Messages
}
for (let e of document.querySelectorAll(".login")) {
this.renderLogin(e, !this.server.LoggedIn())
}
for (let e of document.querySelectorAll(".puzzles")) {
this.renderPuzzles(e, this.server.LoggedIn())
}
if (this.state.DevelopmentMode() && !this.server.LoggedIn()) {
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)
}
}
/**
* Render a login box.
*
* This just toggles visibility, there's nothing dynamic in a login box.
*/
renderLogin(element, visible) {
element.classList.toggle("hidden", !visible)
}
/**
* Render a puzzles box.
*
* This updates the list of open puzzles, and adds mothball download links
* if the server is in development mode.
*/
renderPuzzles(element, visible) {
element.classList.toggle("hidden", !visible)
while (element.firstChild) element.firstChild.remove()
for (let cat of this.state.Categories()) {
let pdiv = element.appendChild(document.createElement("div"))
pdiv.classList.add("category")
let h = pdiv.appendChild(document.createElement("h2"))
h.textContent = cat
// Extras if we're running a devel server
if (this.state.DevelopmentMode()) {
let a = h.appendChild(document.createElement('a'))
a.classList.add("mothball")
a.textContent = "📦"
a.href = this.server.URL(`mothballer/${cat}.mb`)
a.title = "Download a compiled puzzle for this category"
}
// List out puzzles in this category
let l = pdiv.appendChild(document.createElement("ul"))
for (let puzzle of this.state.Puzzles(cat)) {
let i = l.appendChild(document.createElement("li"))
let url = new URL("puzzle.html", window.location)
url.hash = `${puzzle.Category}:${puzzle.Points}`
let a = i.appendChild(document.createElement("a"))
a.textContent = puzzle.Points
a.href = url
a.target = "_blank"
}
if (!this.state.HasUnsolved(cat)) {
l.appendChild(document.createElement("li")).textContent = "✿"
}
element.appendChild(pdiv)
}
}
}
function init() {
window.app = {
server: new App()
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}

View File

@ -1,23 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>MOTH</title>
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="basic.css">
<script>
sessionStorage.removeItem("id")
</script>
</head>
<body>
<h1 id="title">MOTH</h1>
<section>
<p>Okay, you've been logged out.</p>
</section>
<nav>
<ul>
<li><a href="index.html">Sign In</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li>
</ul>
</nav>
</body>
</html>

1
theme/moment.min.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,196 +0,0 @@
// jshint asi:true
var devel = false
var teamId
var heartbeatInterval = 40000
function toast(message, timeout=5000) {
let p = document.createElement("p")
p.innerText = message
document.getElementById("messages").appendChild(p)
setTimeout(
e => { p.remove() },
timeout
)
}
function renderNotices(obj) {
let ne = document.getElementById("notices")
if (ne) {
ne.innerHTML = obj
}
}
function renderPuzzles(obj) {
let puzzlesElement = document.createElement('div')
document.getElementById("login").style.display = "none"
// Create a sorted list of category names
let cats = Object.keys(obj)
cats.sort()
if (cats.length == 0) {
toast("No categories to serve!")
}
for (let cat of cats) {
if (cat.startsWith("__")) {
// Skip metadata
continue
}
let puzzles = obj[cat]
let pdiv = document.createElement('div')
pdiv.className = 'category'
let h = document.createElement('h2')
pdiv.appendChild(h)
h.textContent = cat
// Extras if we're running a devel server
if (devel) {
let a = document.createElement('a')
h.insertBefore(a, h.firstChild)
a.textContent = "⬇️"
a.href = "mothballer/" + cat + ".mb"
a.classList.add("mothball")
a.title = "Download a compiled puzzle for this category"
}
// List out puzzles in this category
let l = document.createElement('ul')
pdiv.appendChild(l)
for (let puzzle of puzzles) {
let points = puzzle
let id = null
if (Array.isArray(puzzle)) {
points = puzzle[0]
id = puzzle[1]
}
let i = document.createElement('li')
l.appendChild(i)
i.textContent = " "
if (points === 0) {
// Sentry: there are no more puzzles in this category
i.textContent = "✿"
} else {
let a = document.createElement('a')
i.appendChild(a)
a.textContent = points
let url = new URL("puzzle.html", window.location)
url.searchParams.set("cat", cat)
url.searchParams.set("points", points)
if (id) { url.searchParams.set("pid", id) }
a.href = url.toString()
}
}
puzzlesElement.appendChild(pdiv)
}
// Drop that thing in
let container = document.getElementById("puzzles")
while (container.firstChild) {
container.firstChild.remove()
}
container.appendChild(puzzlesElement)
}
function renderState(obj) {
window.state = obj
devel = obj.Config.Devel
if (devel) {
let params = new URLSearchParams(window.location.search)
sessionStorage.id = "1"
renderPuzzles(obj.Puzzles)
} else if (Object.keys(obj.Puzzles).length > 0) {
renderPuzzles(obj.Puzzles)
}
renderNotices(obj.Messages)
}
function heartbeat() {
let teamId = sessionStorage.id || ""
let url = new URL("state", window.location)
url.searchParams.set("id", teamId)
let fd = new FormData()
fd.append("id", teamId)
fetch(url)
.then(resp => {
if (resp.ok) {
resp.json()
.then(renderState)
.catch(err => {
toast("Error fetching recent state. I'll try again in a moment.")
console.log(err)
})
}
})
.catch(err => {
toast("Error fetching recent state. I'll try again in a moment.")
console.log(err)
})
}
function showPuzzles() {
let spinner = document.createElement("span")
spinner.classList.add("spinner")
document.getElementById("login").style.display = "none"
document.getElementById("puzzles").appendChild(spinner)
}
function login(e) {
e.preventDefault()
let name = document.querySelector("[name=name]").value
let teamId = document.querySelector("[name=id]").value
fetch("register", {
method: "POST",
body: new FormData(e.target),
})
.then(resp => {
if (resp.ok) {
resp.json()
.then(obj => {
if ((obj.status == "success") || (obj.data.short == "Already registered")) {
toast("Logged in")
sessionStorage.id = teamId
showPuzzles()
heartbeat()
} else {
toast(obj.data.description)
}
})
.catch(err => {
toast("Oops, the server has lost its mind. You probably need to tell someone so they can fix it.")
console.log(err, resp)
})
} else {
toast("Oops, something's wrong with the server. Try again in a few seconds.")
console.log(resp)
}
})
.catch(err => {
toast("Oops, something went wrong. Try again in a few seconds.")
console.log(err)
})
}
function init() {
heartbeat()
setInterval(e => heartbeat(), 40000)
document.getElementById("login").addEventListener("submit", login)
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}

View File

@ -208,6 +208,19 @@ class Puzzle {
} }
return false return false
} }
/**
* Submit a proposed answer for points.
*
* The returned promise will fail if anything goes wrong, including the
* proposed answer being rejected.
*
* @param {String} proposed Answer to submit
* @returns {Promise.<String>} Success message
*/
SubmitAnswer(proposed) {
return this.server.SubmitAnswer(this.Category, this.Points, proposed)
}
} }
/** /**
@ -228,23 +241,27 @@ class State {
/** Configuration */ /** Configuration */
this.Config = { this.Config = {
/** Is the server in debug mode? /** Is the server in development mode?
* @type {Boolean} * @type {Boolean}
*/ */
Debug: obj.Config.Debug, Devel: obj.Config.Devel,
} }
/** Global messages, in HTML /** Global messages, in HTML
* @type {String} * @type {String}
*/ */
this.Messages = obj.Messages this.Messages = obj.Messages
/** Map from Team ID to Team Name /** Map from Team ID to Team Name
* @type {Object.<String,String>} * @type {Object.<String,String>}
*/ */
this.TeamNames = obj.TeamNames this.TeamNames = obj.TeamNames
/** Map from category name to puzzle point values /** Map from category name to puzzle point values
* @type {Object.<String,Number>} * @type {Object.<String,Number>}
*/ */
this.PointsByCategory = obj.Puzzles this.PointsByCategory = obj.Puzzles
/** Log of points awarded /** Log of points awarded
* @type {Award[]} * @type {Award[]}
*/ */
@ -278,6 +295,15 @@ class State {
return !this.PointsByCategory[category].includes(0) return !this.PointsByCategory[category].includes(0)
} }
/**
* Is the server in development mode?
*
* @returns {Boolean}
*/
DevelopmentMode() {
return this.Config && this.Config.Devel
}
/** /**
* Return all open puzzles. * Return all open puzzles.
* *
@ -313,10 +339,16 @@ class State {
* and will send a Team ID with every request, if it can find one. * and will send a Team ID with every request, if it can find one.
*/ */
class Server { class Server {
/**
* @param {String | URL} baseUrl Base URL to server, for constructing API URLs
*/
constructor(baseUrl) { constructor(baseUrl) {
if (!baseUrl) {
throw("Must provide baseURL")
}
this.baseUrl = new URL(baseUrl, location) this.baseUrl = new URL(baseUrl, location)
this.teameIdKey = this.baseUrl.toString() + " teamID" this.teamIdKey = this.baseUrl.toString() + " teamID"
this.teamId = localStorage[this.teameIdKey] this.TeamId = localStorage[this.teamIdKey]
} }
/** /**
@ -326,21 +358,29 @@ class Server {
* this function throws an error. * this function throws an error.
* *
* This always sends teamId. * This always sends teamId.
* If body is set, POST will be used instead of GET * If args is set, POST will be used instead of GET
* *
* @param {String} path Path to API endpoint * @param {String} path Path to API endpoint
* @param {Object.<String,String>} body Key/Values to send in POST data * @param {Object.<String,String>} args Key/Values to send in POST data
* @returns {Promise.<Response>} Response * @returns {Promise.<Response>} Response
*/ */
fetch(path, body) { fetch(path, args) {
let url = new URL(path, this.baseUrl) let url = new URL(path, this.baseUrl)
if (this.teamId & (!(body && body.id))) { if (this.TeamId & (!(args && args.id))) {
url.searchParams.set("id", this.teamId) url.searchParams.set("id", this.TeamId)
} }
return fetch(url, {
method: body?"POST":"GET", if (args) {
body, let formData = new FormData()
}) for (let k in args) {
formData.set(k, args[k])
}
return fetch(url, {
method: "POST",
body: formData,
})
}
return fetch(url)
} }
/** /**
@ -356,7 +396,7 @@ class Server {
switch (obj.status) { switch (obj.status) {
case "success": case "success":
return obj.data return obj.data
case "failure": case "fail":
throw new Error(obj.data.description || obj.data.short || obj.data) throw new Error(obj.data.description || obj.data.short || obj.data)
case "error": case "error":
throw new Error(obj.message) throw new Error(obj.message)
@ -365,20 +405,38 @@ class Server {
} }
} }
/**
* Make a new URL for the given resource.
*
* @returns {URL}
*/
URL(url) {
return new URL(url, this.baseUrl)
}
/**
* Are we logged in to the server?
*
* @returns {Boolean}
*/
LoggedIn() {
return this.TeamId ? true : false
}
/** /**
* Forget about any previous Team ID. * Forget about any previous Team ID.
* *
* This is equivalent to logging out. * This is equivalent to logging out.
*/ */
Reset() { Reset() {
localStorage.removeItem(this.teameIdKey) localStorage.removeItem(this.teamIdKey)
this.teamId = null this.TeamId = null
} }
/** /**
* Fetch current contest state. * Fetch current contest state.
* *
* @returns {State} * @returns {Promise.<State>}
*/ */
async GetState() { async GetState() {
let resp = await this.fetch("/state") let resp = await this.fetch("/state")
@ -387,37 +445,41 @@ class Server {
} }
/** /**
* Register a team name with a team ID. * Log in to a team.
* *
* This is similar to, but not exactly the same as, logging in. * This calls the server's registration endpoint; if the call succeds, or
* See MOTH documentation for details. * fails with "team already exists", the login is returned as successful.
* *
* @param {String} teamId * @param {String} teamId
* @param {String} teamName * @param {String} teamName
* @returns {Promise.<String>} Success message from server * @returns {Promise.<String>} Success message from server
*/ */
async Register(teamId, teamName) { async Login(teamId, teamName) {
let data = await this.call("/login", {id: teamId, name: teamName}) let data = await this.call("/register", {id: teamId, name: teamName})
this.teamId = teamId this.TeamId = teamId
this.teamName = teamName this.TeamName = teamName
localStorage[this.teameIdKey] = teamId localStorage[this.teamIdKey] = teamId
return data.description || data.short return data.description || data.short
} }
/** /**
* Submit a puzzle answer for points. * Submit a proposed answer for points.
* *
* The returned promise will fail if anything goes wrong, including the * The returned promise will fail if anything goes wrong, including the
* answer being rejected. * proposed answer being rejected.
* *
* @param {String} category Category of puzzle * @param {String} category Category of puzzle
* @param {Number} points Point value of puzzle * @param {Number} points Point value of puzzle
* @param {String} answer Answer to submit * @param {String} proposed Answer to submit
* @returns {Promise.<Boolean>} Was the answer accepted? * @returns {Promise.<String>} Success message
*/ */
async SubmitAnswer(category, points, answer) { async SubmitAnswer(category, points, proposed) {
await this.call("/answer", {category, points, answer}) let data = await this.call("/answer", {
return true cat: category,
points,
answer: proposed,
})
return data.description || data.short
} }
/** /**

View File

@ -10,29 +10,25 @@
<script src="puzzle.mjs" type="module" async></script> <script src="puzzle.mjs" type="module" async></script>
</head> </head>
<body> <body>
<h1 id="title">[loading]</h1>
<main> <main>
<h1 id="title">[loading]</h1> <section id="puzzle">
<section> <p class="notification">
<div id="puzzle"> Starting script...
<p class="notification"> </p>
Starting script... </section>
</p> <section class="meta"></section>
</div>
<ul id="files"></ul> <ul id="files"></ul>
<p>Puzzle by <span id="authors">[loading]</span></p> <p>Puzzle by <span id="authors">[loading]</span></p>
</section> </section>
<form class="answer"> <form class="answer">
Team ID: <input type="text" name="id"> <br> <label for="answer">Answer:</label>
Answer: <input type="text" name="answer" id="answer"> <span class="answer_ok"></span><br> <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>
<div id="devel" class="notification"></div> <div class="debug" class="notification"></div>
<nav> <div class="toasts"></div>
<ul>
<li><a href="index.html">Puzzles</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li>
</ul>
</nav>
</body> </body>
</html> </html>

View File

@ -1,225 +0,0 @@
// jshint asi:true
// prettify adds classes to various types, returning an HTML string.
function prettify(key, val) {
switch (key) {
case "Body":
return '[HTML]'
}
return val
}
// devel_addin drops a bunch of development extensions into element e.
// It will only modify stuff inside e.
function devel_addin(e) {
let h = e.appendChild(document.createElement("h2"))
h.textContent = "Developer Output"
let log = window.puzzle.Debug.Log || []
if (log.length > 0) {
e.appendChild(document.createElement("h3")).textContent = "Log"
let le = e.appendChild(document.createElement("ul"))
for (let entry of log) {
le.appendChild(document.createElement("li")).textContent = entry
}
}
e.appendChild(document.createElement("h3")).textContent = "Puzzle object"
let hobj = JSON.stringify(window.puzzle, prettify, 2)
let d = e.appendChild(document.createElement("pre"))
d.classList.add("object")
d.innerHTML = hobj
e.appendChild(document.createElement("p")).textContent = "This debugging information will not be available to participants."
}
// Hash routine used in v3.4 and earlier
function djb2hash(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
}
// The routine used to hash answers in compiled puzzle packages
async function sha256Hash(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;
}
// Is the provided answer possibly correct?
async function checkAnswer(answer) {
let answerHashes = []
answerHashes.push(djb2hash(answer))
answerHashes.push(await sha256Hash(answer))
for (let hash of answerHashes) {
for (let correctHash of window.puzzle.AnswerHashes) {
if (hash == correctHash) {
return true
}
}
}
return false
}
// Pop up a message
function toast(message, timeout=5000) {
let p = document.createElement("p")
p.innerText = message
document.getElementById("messages").appendChild(p)
setTimeout(
e => { p.remove() },
timeout
)
}
// When the user submits an answer
function submit(e) {
e.preventDefault()
let data = new FormData(e.target)
window.data = data
fetch("answer", {
method: "POST",
body: data,
})
.then(resp => {
if (resp.ok) {
resp.json()
.then(obj => {
toast(obj.data.description)
})
} else {
toast("Error submitting your answer. Try again in a few seconds.")
console.log(resp)
}
})
.catch(err => {
toast("Error submitting your answer. Try again in a few seconds.")
console.log(err)
})
}
async function loadPuzzle(categoryName, points, puzzleId) {
let puzzle = document.getElementById("puzzle")
let base = "content/" + categoryName + "/" + puzzleId + "/"
let resp = await fetch(base + "puzzle.json")
if (! resp.ok) {
console.log(resp)
let err = await resp.text()
Array.from(puzzle.childNodes).map(e => e.remove())
p = puzzle.appendChild(document.createElement("p"))
p.classList.add("Error")
p.textContent = err
return
}
// Make the whole puzzle available
window.puzzle = await resp.json()
// Populate authors
document.getElementById("authors").textContent = window.puzzle.Authors.join(", ")
// If answers are provided, this is the devel server
if (window.puzzle.Answers.length > 0) {
devel_addin(document.getElementById("devel"))
}
// Load scripts
for (let script of (window.puzzle.Scripts || [])) {
let st = document.createElement("script")
document.head.appendChild(st)
st.src = base + script
}
// List associated files
for (let fn of (window.puzzle.Attachments || [])) {
let li = document.createElement("li")
let a = document.createElement("a")
a.href = base + fn
a.innerText = fn
li.appendChild(a)
document.getElementById("files").appendChild(li)
}
// Prefix `base` to relative URLs in the puzzle body
let doc = new DOMParser().parseFromString(window.puzzle.Body, "text/html")
for (let se of doc.querySelectorAll("[src],[href]")) {
se.outerHTML = se.outerHTML.replace(/(src|href)="([^/]+)"/i, "$1=\"" + base + "$2\"")
}
// If a validation pattern was provided, set that
if (window.puzzle.AnswerPattern) {
document.querySelector("#answer").pattern = window.puzzle.AnswerPattern
}
// Replace puzzle children with what's in `doc`
Array.from(puzzle.childNodes).map(e => e.remove())
Array.from(doc.body.childNodes).map(e => puzzle.appendChild(e))
document.title = categoryName + " " + points
document.querySelector("body > h1").innerText = document.title
document.querySelector("input[name=cat]").value = categoryName
document.querySelector("input[name=points]").value = points
}
// Check to see if the answer might be correct
// This might be better done with the "constraint validation API"
// https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Form_validation#Validating_forms_using_JavaScript
function answerCheck(e) {
let answer = e.target.value
let ok = document.querySelector("#answer_ok")
// You have to provide someplace to put the check
if (! ok) {
return
}
checkAnswer(answer)
.then (correct => {
if (correct) {
ok.textContent = "⭕"
ok.title = "Possibly correct"
} else {
ok.textContent = "❌"
ok.title = "Definitely not correct"
}
})
}
function init() {
let params = new URLSearchParams(window.location.search)
let categoryName = params.get("cat")
let points = params.get("points")
let puzzleId = params.get("pid")
if (categoryName && points) {
loadPuzzle(categoryName, points, puzzleId || points)
}
let teamId = sessionStorage.getItem("id")
if (teamId) {
document.querySelector("input[name=id]").value = teamId
}
if (document.querySelector("#answer")) {
document.querySelector("#answer").addEventListener("input", answerCheck)
}
document.querySelector("form").addEventListener("submit", submit)
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}

View File

@ -1,4 +1,10 @@
/**
* Functionality for puzzle.html (Puzzle display / answer form)
*/
import * as moth from "./moth.mjs" import * as moth from "./moth.mjs"
import * as common from "./common.mjs"
const server = new moth.Server(".")
/** /**
* Handle a submit event on a form. * Handle a submit event on a form.
@ -10,9 +16,22 @@ import * as moth from "./moth.mjs"
* *
* @param {Event} event * @param {Event} event
*/ */
function formSubmitHandler(event) { async function formSubmitHandler(event) {
event.preventDefault() event.preventDefault()
console.log(event) let data = new FormData(event.target)
let proposed = data.get("answer")
let message
console.group("Submit answer")
console.info(`Proposed answer: ${proposed}`)
try {
message = await window.app.puzzle.SubmitAnswer(proposed)
}
catch (err) {
common.Toast(err)
}
common.Toast(message)
console.groupEnd("Submit answer")
} }
/** /**
@ -69,12 +88,40 @@ function error(error) {
* *
* @param {String} s * @param {String} s
*/ */
function setanswer(s) { function SetAnswer(s) {
let e = document.querySelector("#answer") let e = document.querySelector("#answer")
e.value = s e.value = s
e.dispatchEvent(new Event("input")) e.dispatchEvent(new Event("input"))
} }
function writeObject(e, obj) {
let keys = Object.keys(obj)
keys.sort()
for (let key of keys) {
let val = obj[key]
if ((key === "Body") || (!val) || (val.length === 0)) {
continue
}
let d = e.appendChild(document.createElement("dt"))
d.textContent = key
let t = e.appendChild(document.createElement("dd"))
if (Array.isArray(val)) {
let vi = t.appendChild(document.createElement("ul"))
vi.multiple = true
for (let a of val) {
let opt = vi.appendChild(document.createElement("li"))
opt.textContent = a
}
} else if (typeof(val) === "object") {
writeObject(t, val)
} else {
t.textContent = val
}
}
}
/** /**
* Load the given puzzle. * Load the given puzzle.
* *
@ -93,16 +140,17 @@ async function loadPuzzle(category, points) {
} }
} }
let server = new moth.Server()
let puzzle = server.GetPuzzle(category, points) let puzzle = server.GetPuzzle(category, points)
console.time("Populate") console.time("Populate")
await puzzle.Populate() await puzzle.Populate()
console.timeEnd("Populate") console.timeEnd("Populate")
console.info("Tweaking HTML...")
let title = `${category} ${points}` let title = `${category} ${points}`
document.querySelector("title").textContent = title document.querySelector("title").textContent = title
document.querySelector("#title").textContent = title document.querySelector("#title").textContent = title
document.querySelector("#authors").textContent = puzzle.Authors.join(", ") document.querySelector("#authors").textContent = puzzle.Authors.join(", ")
document.querySelector("#answer").pattern = window.puzzle.AnswerPattern
puzzleElement().innerHTML = puzzle.Body puzzleElement().innerHTML = puzzle.Body
console.info("Adding attached scripts...") console.info("Adding attached scripts...")
@ -110,7 +158,7 @@ async function loadPuzzle(category, points) {
let st = document.createElement("script") let st = document.createElement("script")
document.head.appendChild(st) document.head.appendChild(st)
st.src = new URL(script, contentBase) st.src = new URL(script, contentBase)
} }
console.info("Listing attached files...") console.info("Listing attached files...")
for (let fn of (puzzle.Attachments || [])) { for (let fn of (puzzle.Attachments || [])) {
@ -122,6 +170,16 @@ async function loadPuzzle(category, points) {
document.getElementById("files").appendChild(li) document.getElementById("files").appendChild(li)
} }
console.info("Filling debug information...")
for (let e of document.querySelectorAll(".debug")) {
if (puzzle.Answers.length > 0) {
writeObject(e, puzzle)
} else {
e.classList.add("hidden")
}
}
let baseElement = document.head.appendChild(document.createElement("base")) let baseElement = document.head.appendChild(document.createElement("base"))
baseElement.href = contentBase baseElement.href = contentBase
@ -129,11 +187,13 @@ async function loadPuzzle(category, points) {
console.info("window.app.puzzle =", window.app.puzzle) console.info("window.app.puzzle =", window.app.puzzle)
console.groupEnd() console.groupEnd()
return puzzle
} }
function init() { async function init() {
window.app = {} window.app = {}
window.setanswer = setanswer window.setanswer = (str => SetAnswer(str))
for (let form of document.querySelectorAll("form.answer")) { for (let form of document.querySelectorAll("form.answer")) {
form.addEventListener("submit", formSubmitHandler) form.addEventListener("submit", formSubmitHandler)
@ -158,12 +218,7 @@ function init() {
return return
} }
loadPuzzle(category, points) window.app.puzzle = await loadPuzzle(category, points)
.catch(err => error(err))
} }
if (document.readyState === "loading") { common.WhenDOMLoaded(init)
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}

91
theme/scoreboard.css Normal file
View File

@ -0,0 +1,91 @@
/* GHC displays: 1024x1820 */
@media screen and (max-aspect-ratio: 4/5) and (min-height: 1600px) {
html {
font-size: 20pt;
}
}
#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;
}
.qrcode {
width: 30vw;
}
.examples {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.examples > div {
margin: 0.5em;
max-width: 40%;
}
#rankings {
width: 100%;
position: relative;
background-color: rgba(0, 0, 0, 0.8);
}
#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;
}
#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;}

View File

@ -3,22 +3,17 @@
<head> <head>
<title>Scoreboard</title> <title>Scoreboard</title>
<link rel="stylesheet" href="basic.css"> <link rel="stylesheet" href="basic.css">
<link rel="stylesheet" href="scoreboard.css">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<script src="moment.min.js" async></script> <script src="https://cdn.jsdelivr.net/npm/luxon@1.26.0"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" async></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@3.0.2"></script>
<script src="scoreboard.js" async></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@0.2.1"></script>
<script type="module" src="scoreboard.mjs"></script>
</head> </head>
<body class="wide"> <body class="wide">
<h4 id="location"></h4>
<section class="rotate"> <section class="rotate">
<div id="chart"></div> <div id="chart"><canvas></canvas></div>
<div id="rankings"></div> <div id="rankings"></div>
</section> </section>
<nav>
<ul>
<li><a href="index.html">Puzzles</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li>
</ul>
</nav>
</body> </body>
</html> </html>

View File

@ -1,8 +1,19 @@
// jshint asi:true // jshint asi:true
function scoreboardInit() { // 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)
chartColors = [ const MILLISECOND = 1
const SECOND = 1000 * MILLISECOND
const MINUTE = 60 * SECOND
// If all else fails...
setInterval(() => location.reload(), 30 * SECOND)
function scoreboardInit() {
let chartColors = [
"rgb(255, 99, 132)", "rgb(255, 99, 132)",
"rgb(255, 159, 64)", "rgb(255, 159, 64)",
"rgb(255, 205, 86)", "rgb(255, 205, 86)",
@ -12,12 +23,70 @@ function scoreboardInit() {
"rgb(201, 203, 207)" "rgb(201, 203, 207)"
] ]
function update(state) { for (let q of document.querySelectorAll("[data-url]")) {
window.state = state 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(),
})
}
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
}
}
})
}
async function refresh() {
let resp = await fetch("../state")
let state = await resp.json()
for (let rotate of document.querySelectorAll(".rotate")) { for (let rotate of document.querySelectorAll(".rotate")) {
rotate.appendChild(rotate.firstElementChild) rotate.appendChild(rotate.firstElementChild)
} }
window.scrollTo(0,0)
let element = document.getElementById("rankings") let element = document.getElementById("rankings")
let teamNames = state.TeamNames let teamNames = state.TeamNames
@ -28,12 +97,12 @@ function scoreboardInit() {
// //
// We have been doing some variation on this "everybody backs up the server state" trick since 2009. // We have been doing some variation on this "everybody backs up the server state" trick since 2009.
// We have needed it 0 times. // We have needed it 0 times.
let stateHistory = JSON.parse(localStorage.getItem("stateHistory")) || [] let pointsHistory = JSON.parse(localStorage.getItem("pointsHistory")) || []
if (stateHistory.length >= 20) { if (pointsHistory.length >= 20) {
stateHistory.shift() pointsHistory.shift()
} }
stateHistory.push(state) pointsHistory.push(pointsLog)
localStorage.setItem("stateHistory", JSON.stringify(stateHistory)) localStorage.setItem("pointsHistory", JSON.stringify(pointsHistory))
let teams = {} let teams = {}
let highestCategoryScore = {} // map[string]int let highestCategoryScore = {} // map[string]int
@ -89,7 +158,7 @@ function scoreboardInit() {
overall += team.categoryScore[cat] / highestCategoryScore[cat] overall += team.categoryScore[cat] / highestCategoryScore[cat]
} }
team.historyLine.push({t: new Date(timestamp * 1000), y: overall}) team.historyLine.push({x: timestamp * 1000, y: overall})
} }
// Compute overall scores based on current highest // Compute overall scores based on current highest
@ -150,14 +219,21 @@ function scoreboardInit() {
element.appendChild(row) element.appendChild(row)
} }
let datasets = [] if (!chart) {
return
}
/*
* Update chart
*/
chart.data.datasets = []
for (let i in winners) { for (let i in winners) {
if (i > 5) { if (i > 5) {
break break
} }
let team = winners[i] let team = winners[i]
let color = chartColors[i % chartColors.length] let color = chartColors[i % chartColors.length]
datasets.push({ chart.data.datasets.push({
label: team.name, label: team.name,
backgroundColor: color, backgroundColor: color,
borderColor: color, borderColor: color,
@ -166,68 +242,8 @@ function scoreboardInit() {
fill: false fill: false
}) })
} }
let config = { chart.update()
type: "line", window.chart = chart
data: {
datasets: datasets
},
options: {
responsive: true,
scales: {
xAxes: [{
display: true,
type: "time",
time: {
tooltipFormat: "ll HH:mm"
},
scaleLabel: {
display: true,
labelString: "Time"
}
}],
yAxes: [{
display: true,
scaleLabel: {
display: true,
labelString: "Points"
}
}]
},
tooltips: {
mode: "index",
intersect: false
},
hover: {
mode: "nearest",
intersect: true
}
}
}
let chart = document.querySelector("#chart")
if (chart) {
let canvas = chart.querySelector("canvas")
if (! canvas) {
canvas = document.createElement("canvas")
chart.appendChild(canvas)
}
let myline = new Chart(canvas.getContext("2d"), config)
myline.update()
}
}
function refresh() {
fetch("state")
.then(resp => {
return resp.json()
})
.then(obj => {
update(obj)
})
.catch(err => {
console.log(err)
})
} }
function init() { function init() {
@ -237,7 +253,7 @@ function scoreboardInit() {
location.textContent = base location.textContent = base
} }
setInterval(refresh, 60000) setInterval(refresh, 20 * SECOND)
refresh() refresh()
} }

View File

@ -1,45 +1,29 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<title>Redeem Token</title> <title>Redeem Token</title>
<link rel="stylesheet" href="basic.css"> <link rel="stylesheet" href="basic.css">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<script src="puzzle.js"></script> <script src="token.mjs" type="module" async></script>
<script>
function tokenInput(e) {
let vals = e.target.value.split(":")
document.querySelector("input[name=cat]").value = vals[0]
document.querySelector("input[name=points]").value = vals[1]
document.querySelector("input[name=answer]").value = vals[2]
}
function tokenInit() {
document.querySelector("input[name=token]").addEventListener("input", tokenInput)
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", tokenInit)
} else {
tokenInit()
}
</script>
</head> </head>
<body> <body>
<h1>Redeem Token</h1> <h1>Redeem Token</h1>
<div id="messages"></div> <main>
<form id="tokenForm"> <p>
<input type="hidden" name="cat"> Have you found a token?
<input type="hidden" name="points"> </p>
<input type="hidden" name="answer"> <p></p>
Team ID: <input type="text" name="id"> <br> Tokens look like
Token: <input type="text" name="token"> <br> <code>category:5:xylep-radar-nanox</code>
<p>
Tokens may be redeemed here for points in their category.
Tokens can appear anywhere: online, on slips of paper, projected onto screens…
</p>
</main>
<form class="token"</form>
<label for="token">Token:</label> <input type="text" name="token" id="token"> <br>
<input type="submit" value="Submit"> <input type="submit" value="Submit">
</form> </form>
<nav> <div class="toasts"></div>
<ul>
<li><a href="puzzle-list.html">Puzzles</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li>
</ul>
</nav>
</body> </body>
</html> </html>

48
theme/token.mjs Normal file
View File

@ -0,0 +1,48 @@
/**
* Functionality for token.html
*/
import * as moth from "./moth.mjs"
import * as common from "./common.mjs"
const server = new moth.Server(".")
/**
* Handle a submit event on a form.
*
* @param {SubmitEvent} event
*/
async function formSubmitHandler(event) {
event.preventDefault()
let formData = new FormData(event.target)
let token = formData.get("token")
let vals = token.split(":")
let category = vals[0]
let points = Number(vals[1])
let proposed = vals[2]
if (!category || !points || !proposed) {
console.info("Not a token:", vals)
common.Toast("This is not a properly-formed token")
return
}
try {
let message = await server.SubmitAnswer(category, points, proposed)
common.Toast(message)
}
catch (error) {
if (error.message == "incorrect answer") {
common.Toast("Unknown token")
} else {
console.error(error)
common.Toast(error)
}
}
}
function init() {
for (let form of document.querySelectorAll("form.token")) {
form.addEventListener("submit", formSubmitHandler)
}
}
common.WhenDOMLoaded(init)