Compare commits

..

3 Commits

Author SHA1 Message Date
Neale Pickett d87be0bfcb Color twiddling 2023-09-13 19:24:05 -06:00
Neale Pickett 13c17873d8 CSS twiddling 2023-09-13 19:10:25 -06:00
Neale Pickett 9ea39363b8 Mostly using new library, except scoreboard 2023-09-13 18:52:52 -06:00
18 changed files with 773 additions and 707 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.
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 == "" {
return fmt.Errorf("empty team name")
}

View File

@ -2,9 +2,9 @@ function randint(max) {
return Math.floor(Math.random() * max)
}
const MILLISECOND = 1
const SECOND = MILLISECOND * 1000
const FRAMERATE = 24 / SECOND // Fast enough for this tomfoolery
const Millisecond = 1
const Second = Millisecond * 1000
const FrameRate = 24 / Second // Fast enough for this tomfoolery
class Point {
constructor(x, y) {
@ -88,7 +88,7 @@ class QixLine {
* like the video game "qix"
*/
class QixBackground {
constructor(ctx, frameRate = 6/SECOND) {
constructor(ctx, frameRate = 6/Second) {
this.ctx = ctx
this.min = new Point(0, 0)
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)),
)
this.frameInterval = MILLISECOND / frameRate
this.frameInterval = Millisecond / frameRate
this.nextFrame = 0
}
@ -149,6 +149,12 @@ class QixBackground {
}
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")
canvas.width = 640
canvas.height = 640
@ -159,7 +165,7 @@ function init() {
let qix = new QixBackground(ctx)
// window.requestAnimationFrame is overkill for something this silly
setInterval(() => qix.Animate(), MILLISECOND/FRAMERATE)
setInterval(() => qix.Animate(), Millisecond/FrameRate)
}
if (document.readyState === "loading") {

View File

@ -1,53 +1,12 @@
/*
* 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 {
background: #010e19;
color: #edd488;
}
main {
background: #000d;
}
h1, h2, h3, h4, h5, h6 {
color: #cb2408cc;
}
h1 {
background: #cb240844;
}
a:any-link {
color: #b9cbd8;
}
.notification {
background: #ac8f3944;
}
.error {
background: red;
color: white;
}
@media (prefers-color-scheme: light) {
body {
background: #b9cbd8;
color: black;
}
main {
background: #fffd;
}
a:any-link {
color: #092b45;
}
}
/* Color palette: http://paletton.com/#uid=33x0u0klrl-4ON9dhtKtAdqMQ4T */
body {
font-family: sans-serif;
background-image: url("bg.png");
background-size: contain;
background: #010e19 url("bg.png") center fixed;
background-size: cover;
background-blend-mode: soft-light;
background-attachment: fixed;
background-color: #010e19;
color: #edd488;
}
canvas.wallpaper {
position: fixed;
@ -62,16 +21,24 @@ canvas.wallpaper {
}
main {
max-width: 40em;
margin: auto;
margin: 1em auto;
padding: 1px 3px;
border-radius: 5px;
background: #000d;
}
h1, h2, h3, h4, h5, h6 {
color: #cb2408cc;
}
h1 {
background: #cb240844;
padding: 3px;
}
p {
margin: 1em 0em;
}
a:any-link {
color: #b9cbd8;
}
form, pre {
margin: 1em;
overflow-x: auto;
@ -81,34 +48,69 @@ input, select {
margin: 0.2em;
max-width: 30em;
}
input {
background-color: #ccc4;
color: inherit;
}
.notification, .error {
padding: 0 1em;
border-radius: 8px;
}
.notification {
background: #ac8f3944;
}
.error {
background: red;
color: white;
}
.hidden {
display: none;
}
/** Puzzles list */
.category {
margin: 5px 0;
background: #ccc4;
}
.category h2 {
margin: 0 0.2em;
}
nav ul, .category ul {
padding: 1em;
margin: 0;
padding: 0.2em 1em;
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
}
nav li, .category li {
display: inline;
margin: 1em;
}
iframe#body {
border: inherit;
width: 100%;
.mothball {
float: right;
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%;
}
input:invalid {
border-color: red;
background-color: #800;
color: white;
}
.answer_ok {
cursor: help;
}
#messages {
min-height: 3em;
}
/** Scoreboard */
#rankings {
width: 100%;
position: relative;
@ -138,11 +140,19 @@ input:invalid {
.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;}
.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;}
#devel {
.debug {
overflow: auto;
padding: 1em;
border-radius: 10px;
margin: 2em auto;
background: #cccc;
color: black;
}
.debug dt {
font-weight: bold;
}
/** Draggable items, from the draggable plugin */
li[draggable]::before {
content: "↕";
padding: 0.5em;
@ -160,6 +170,50 @@ li[draggable] {
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;
background: #333;
color: #eee;
box-shadow: 0px 0px 8px 0px #0b0;
}
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}
@media (prefers-color-scheme: light) {
/*
* This uses the alpha channel to apply hue tinting to elements, to get a
* similar effect in light or dark mode. That means there aren't a whole lot of
* things to change between light and dark mode.
*
*/
body {
background-color: #b9cbd8;
color: black;
}
main {
background-color: #fffd;
}
a:any-link {
color: #092b45;
}
}

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>
<html>
<html lang="en">
<head>
<title>MOTH</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<link rel="icon" href="luna-moth.svg">
<link rel="stylesheet" href="basic.css">
<script src="moth.mjs" type="module"></script>
<script src="background.mjs" type="module"></script>
<script src="index.mjs" type="module" async></script>
<script src="background.mjs" type="module" async></script>
</head>
<body>
<h1 id="title">MOTH</h1>
<h1 class="title">MOTH</h1>
<main>
<div id="messages notification">
<div class="messages notification">
</div>
<form id="login">
<form class="login">
Team ID: <input name="id"> <br>
Team name: <input name="name"> <br>
<input type="submit" value="Sign In">
</form>
<div id="puzzles"></div>
<div class="puzzles"></div>
<div class="toasts"></div>
</main>
<nav>
<ul>
<li><a href="scoreboard.html">Scoreboard</a></li>
<li><a href="logout.html">Sign Out</a></li>
<li><a href="scoreboard.html" target="_blank">Scoreboard</a></li>
<li><button class="logout">Sign Out</button></li>
</ul>
</nav>
</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
}
/**
* 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 */
this.Config = {
/** Is the server in debug mode?
/** Is the server in development mode?
* @type {Boolean}
*/
Debug: obj.Config.Debug,
Devel: obj.Config.Devel,
}
/** Global messages, in HTML
* @type {String}
*/
this.Messages = obj.Messages
/** Map from Team ID to Team Name
* @type {Object.<String,String>}
*/
this.TeamNames = obj.TeamNames
/** Map from category name to puzzle point values
* @type {Object.<String,Number>}
*/
this.PointsByCategory = obj.Puzzles
/** Log of points awarded
* @type {Award[]}
*/
@ -278,6 +295,15 @@ class State {
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.
*
@ -313,10 +339,16 @@ class State {
* and will send a Team ID with every request, if it can find one.
*/
class Server {
/**
* @param {String | URL} baseUrl Base URL to server, for constructing API URLs
*/
constructor(baseUrl) {
if (!baseUrl) {
throw("Must provide baseURL")
}
this.baseUrl = new URL(baseUrl, location)
this.teameIdKey = this.baseUrl.toString() + " teamID"
this.teamId = localStorage[this.teameIdKey]
this.teamIdKey = this.baseUrl.toString() + " teamID"
this.TeamId = localStorage[this.teamIdKey]
}
/**
@ -326,21 +358,29 @@ class Server {
* this function throws an error.
*
* 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 {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
*/
fetch(path, body) {
fetch(path, args) {
let url = new URL(path, this.baseUrl)
if (this.teamId & (!(body && body.id))) {
url.searchParams.set("id", this.teamId)
if (this.TeamId & (!(args && args.id))) {
url.searchParams.set("id", this.TeamId)
}
return fetch(url, {
method: body?"POST":"GET",
body,
})
if (args) {
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) {
case "success":
return obj.data
case "failure":
case "fail":
throw new Error(obj.data.description || obj.data.short || obj.data)
case "error":
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.
*
* This is equivalent to logging out.
*/
Reset() {
localStorage.removeItem(this.teameIdKey)
this.teamId = null
localStorage.removeItem(this.teamIdKey)
this.TeamId = null
}
/**
* Fetch current contest state.
*
* @returns {State}
* @returns {Promise.<State>}
*/
async GetState() {
let resp = await this.fetch("/state")
@ -387,37 +445,41 @@ class Server {
}
/**
* 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.
*
* Log in to a team.
*
* This calls the server's registration endpoint; if the call succeds, or
* fails with "team already exists", the login is returned as successful.
*
* @param {String} teamId
* @param {String} teamName
* @returns {Promise.<String>} Success message from server
*/
async Register(teamId, teamName) {
let data = await this.call("/login", {id: teamId, name: teamName})
this.teamId = teamId
this.teamName = teamName
localStorage[this.teameIdKey] = teamId
async Login(teamId, teamName) {
let data = await this.call("/register", {id: teamId, name: teamName})
this.TeamId = teamId
this.TeamName = teamName
localStorage[this.teamIdKey] = teamId
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
* answer being rejected.
* proposed answer being rejected.
*
* @param {String} category Category of puzzle
* @param {Number} points Point value of puzzle
* @param {String} answer Answer to submit
* @returns {Promise.<Boolean>} Was the answer accepted?
* @param {String} proposed Answer to submit
* @returns {Promise.<String>} Success message
*/
async SubmitAnswer(category, points, answer) {
await this.call("/answer", {category, points, answer})
return true
async SubmitAnswer(category, points, proposed) {
let data = await this.call("/answer", {
cat: category,
points,
answer: proposed,
})
return data.description || data.short
}
/**

View File

@ -10,29 +10,25 @@
<script src="puzzle.mjs" type="module" async></script>
</head>
<body>
<h1 id="title">[loading]</h1>
<main>
<h1 id="title">[loading]</h1>
<section>
<div id="puzzle">
<p class="notification">
Starting script...
</p>
</div>
<section id="puzzle">
<p class="notification">
Starting script...
</p>
</section>
<section class="meta"></section>
<ul id="files"></ul>
<p>Puzzle by <span id="authors">[loading]</span></p>
</section>
<form class="answer">
Team ID: <input type="text" name="id"> <br>
Answer: <input type="text" name="answer" id="answer"> <span class="answer_ok"></span><br>
<label for="answer">Answer:</label>
<input type="text" name="answer" id="answer"> <span class="answer_ok"></span>
<br>
<input type="submit" value="Submit">
</form>
</main>
<div id="devel" class="notification"></div>
<nav>
<ul>
<li><a href="index.html">Puzzles</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li>
</ul>
</nav>
<div class="debug" class="notification"></div>
<div class="toasts"></div>
</body>
</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 common from "./common.mjs"
const server = new moth.Server(".")
/**
* Handle a submit event on a form.
@ -10,9 +16,22 @@ import * as moth from "./moth.mjs"
*
* @param {Event} event
*/
function formSubmitHandler(event) {
async function formSubmitHandler(event) {
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
*/
function setanswer(s) {
function SetAnswer(s) {
let e = document.querySelector("#answer")
e.value = s
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.
*
@ -93,16 +140,19 @@ async function loadPuzzle(category, points) {
}
}
let server = new moth.Server()
let puzzle = server.GetPuzzle(category, points)
console.time("Populate")
await puzzle.Populate()
console.timeEnd("Populate")
console.info("Tweaking HTML...")
let title = `${category} ${points}`
document.querySelector("title").textContent = title
document.querySelector("#title").textContent = title
document.querySelector("#authors").textContent = puzzle.Authors.join(", ")
if (puzzle.AnswerPattern) {
document.querySelector("#answer").pattern = puzzle.AnswerPattern
}
puzzleElement().innerHTML = puzzle.Body
console.info("Adding attached scripts...")
@ -110,7 +160,7 @@ async function loadPuzzle(category, points) {
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 || [])) {
@ -122,6 +172,16 @@ async function loadPuzzle(category, points) {
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"))
baseElement.href = contentBase
@ -129,11 +189,13 @@ async function loadPuzzle(category, points) {
console.info("window.app.puzzle =", window.app.puzzle)
console.groupEnd()
return puzzle
}
function init() {
async function init() {
window.app = {}
window.setanswer = setanswer
window.setanswer = (str => SetAnswer(str))
for (let form of document.querySelectorAll("form.answer")) {
form.addEventListener("submit", formSubmitHandler)
@ -158,12 +220,7 @@ function init() {
return
}
loadPuzzle(category, points)
.catch(err => error(err))
window.app.puzzle = await loadPuzzle(category, points)
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}
common.WhenDOMLoaded(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>
<title>Scoreboard</title>
<link rel="stylesheet" href="basic.css">
<link rel="stylesheet" href="scoreboard.css">
<meta name="viewport" content="width=device-width">
<script src="moment.min.js" async></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" async></script>
<script src="scoreboard.js" async></script>
<script src="https://cdn.jsdelivr.net/npm/luxon@1.26.0"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.0.2"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@0.2.1"></script>
<script type="module" src="scoreboard.mjs"></script>
</head>
<body class="wide">
<h4 id="location"></h4>
<section class="rotate">
<div id="chart"></div>
<div id="chart"><canvas></canvas></div>
<div id="rankings"></div>
</section>
<nav>
<ul>
<li><a href="index.html">Puzzles</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li>
</ul>
</nav>
</body>
</html>

View File

@ -1,8 +1,19 @@
// jshint asi:true
// 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)
const MILLISECOND = 1
const SECOND = 1000 * MILLISECOND
const MINUTE = 60 * SECOND
// If all else fails...
setInterval(() => location.reload(), 30 * SECOND)
function scoreboardInit() {
chartColors = [
let chartColors = [
"rgb(255, 99, 132)",
"rgb(255, 159, 64)",
"rgb(255, 205, 86)",
@ -11,13 +22,71 @@ function scoreboardInit() {
"rgb(153, 102, 255)",
"rgb(201, 203, 207)"
]
function update(state) {
window.state = state
for (let q of document.querySelectorAll("[data-url]")) {
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")) {
rotate.appendChild(rotate.firstElementChild)
}
window.scrollTo(0,0)
let element = document.getElementById("rankings")
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 needed it 0 times.
let stateHistory = JSON.parse(localStorage.getItem("stateHistory")) || []
if (stateHistory.length >= 20) {
stateHistory.shift()
let pointsHistory = JSON.parse(localStorage.getItem("pointsHistory")) || []
if (pointsHistory.length >= 20) {
pointsHistory.shift()
}
stateHistory.push(state)
localStorage.setItem("stateHistory", JSON.stringify(stateHistory))
pointsHistory.push(pointsLog)
localStorage.setItem("pointsHistory", JSON.stringify(pointsHistory))
let teams = {}
let highestCategoryScore = {} // map[string]int
@ -89,7 +158,7 @@ function scoreboardInit() {
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
@ -150,14 +219,21 @@ function scoreboardInit() {
element.appendChild(row)
}
let datasets = []
if (!chart) {
return
}
/*
* Update chart
*/
chart.data.datasets = []
for (let i in winners) {
if (i > 5) {
break
}
let team = winners[i]
let color = chartColors[i % chartColors.length]
datasets.push({
chart.data.datasets.push({
label: team.name,
backgroundColor: color,
borderColor: color,
@ -166,70 +242,10 @@ function scoreboardInit() {
fill: false
})
}
let config = {
type: "line",
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()
}
chart.update()
window.chart = chart
}
function refresh() {
fetch("state")
.then(resp => {
return resp.json()
})
.then(obj => {
update(obj)
})
.catch(err => {
console.log(err)
})
}
function init() {
let base = window.location.href.replace("scoreboard.html", "")
let location = document.querySelector("#location")
@ -237,7 +253,7 @@ function scoreboardInit() {
location.textContent = base
}
setInterval(refresh, 60000)
setInterval(refresh, 20 * SECOND)
refresh()
}

View File

@ -1,45 +1,29 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>Redeem Token</title>
<link rel="stylesheet" href="basic.css">
<meta name="viewport" content="width=device-width">
<script src="puzzle.js"></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>
<script src="token.mjs" type="module" async></script>
</head>
<body>
<h1>Redeem Token</h1>
<div id="messages"></div>
<form id="tokenForm">
<input type="hidden" name="cat">
<input type="hidden" name="points">
<input type="hidden" name="answer">
Team ID: <input type="text" name="id"> <br>
Token: <input type="text" name="token"> <br>
<main>
<p>
Have you found a token?
</p>
<p></p>
Tokens look like
<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">
</form>
<nav>
<ul>
<li><a href="puzzle-list.html">Puzzles</a></li>
<li><a href="scoreboard.html">Scoreboard</a></li>
</ul>
</nav>
<div class="toasts"></div>
</body>
</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)