mirror of https://github.com/dirtbags/moth.git
Compare commits
3 Commits
0831c4e3d5
...
d87be0bfcb
Author | SHA1 | Date |
---|---|---|
Neale Pickett | d87be0bfcb | |
Neale Pickett | 13c17873d8 | |
Neale Pickett | 9ea39363b8 |
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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") {
|
||||||
|
|
176
theme/basic.css
176
theme/basic.css
|
@ -1,53 +1,12 @@
|
||||||
/*
|
/* Color palette: http://paletton.com/#uid=33x0u0klrl-4ON9dhtKtAdqMQ4T */
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
background-image: url("bg.png");
|
background: #010e19 url("bg.png") center fixed;
|
||||||
background-size: contain;
|
background-size: cover;
|
||||||
background-blend-mode: soft-light;
|
background-blend-mode: soft-light;
|
||||||
background-attachment: fixed;
|
background-color: #010e19;
|
||||||
|
color: #edd488;
|
||||||
}
|
}
|
||||||
canvas.wallpaper {
|
canvas.wallpaper {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
@ -62,16 +21,24 @@ 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;
|
||||||
|
background: #000d;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
color: #cb2408cc;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
|
background: #cb240844;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
margin: 1em 0em;
|
margin: 1em 0em;
|
||||||
}
|
}
|
||||||
|
a:any-link {
|
||||||
|
color: #b9cbd8;
|
||||||
|
}
|
||||||
form, pre {
|
form, pre {
|
||||||
margin: 1em;
|
margin: 1em;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
@ -81,34 +48,69 @@ input, select {
|
||||||
margin: 0.2em;
|
margin: 0.2em;
|
||||||
max-width: 30em;
|
max-width: 30em;
|
||||||
}
|
}
|
||||||
|
input {
|
||||||
|
background-color: #ccc4;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
.notification, .error {
|
.notification, .error {
|
||||||
padding: 0 1em;
|
padding: 0 1em;
|
||||||
border-radius: 8px;
|
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 {
|
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 {
|
||||||
border-color: red;
|
background-color: #800;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
.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 +140,19 @@ 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;
|
||||||
|
background: #cccc;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.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 +170,50 @@ 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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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,
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
|
File diff suppressed because one or more lines are too long
196
theme/moth.js
196
theme/moth.js
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
122
theme/moth.mjs
122
theme/moth.mjs
|
@ -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,22 +358,30 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args) {
|
||||||
|
let formData = new FormData()
|
||||||
|
for (let k in args) {
|
||||||
|
formData.set(k, args[k])
|
||||||
}
|
}
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
method: body?"POST":"GET",
|
method: "POST",
|
||||||
body,
|
body: formData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
return fetch(url)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a request to a JSend API endpoint.
|
* Send a request to a JSend API endpoint.
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -10,29 +10,25 @@
|
||||||
<script src="puzzle.mjs" type="module" async></script>
|
<script src="puzzle.mjs" type="module" async></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
|
||||||
<h1 id="title">[loading]</h1>
|
<h1 id="title">[loading]</h1>
|
||||||
<section>
|
<main>
|
||||||
<div id="puzzle">
|
<section id="puzzle">
|
||||||
<p class="notification">
|
<p class="notification">
|
||||||
Starting script...
|
Starting script...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</section>
|
||||||
|
<section class="meta"></section>
|
||||||
<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>
|
||||||
|
|
225
theme/puzzle.js
225
theme/puzzle.js
|
@ -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()
|
|
||||||
}
|
|
|
@ -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,19 @@ 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(", ")
|
||||||
|
if (puzzle.AnswerPattern) {
|
||||||
|
document.querySelector("#answer").pattern = puzzle.AnswerPattern
|
||||||
|
}
|
||||||
puzzleElement().innerHTML = puzzle.Body
|
puzzleElement().innerHTML = puzzle.Body
|
||||||
|
|
||||||
console.info("Adding attached scripts...")
|
console.info("Adding attached scripts...")
|
||||||
|
@ -122,6 +172,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 +189,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 +220,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()
|
|
||||||
}
|
|
||||||
|
|
|
@ -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;}
|
|
@ -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>
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
Loading…
Reference in New Issue