Merge branch 'libmoth' into 'main'

New theme

Closes #190

See merge request devs/moth!181
This commit is contained in:
Neale Pickett 2023-09-29 00:17:52 +00:00
commit 077dc261e4
33 changed files with 50501 additions and 910 deletions

View File

@ -4,7 +4,7 @@ stages:
Run unit tests: Run unit tests:
stage: test stage: test
image: &goimage golang:1.18 image: &goimage golang:1.21
only: only:
refs: refs:
- main - main

View File

@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v4.6.0] - unreleased
### Changed
- Answer hashes are now the first 4 characters of the hex-encoded SHA1 digest
- Reworked the built-in theme
- [moth.mjs](theme/moth.mjs) is now the standard MOTH library for ECMAScript
- Devel mode no longer accepts an empty team ID
## [v4.4.9] - 2022-05-12 ## [v4.4.9] - 2022-05-12
### Changed ### Changed
- Added a performance optimization for events with a large number of teams - Added a performance optimization for events with a large number of teams

View File

@ -1,8 +1,7 @@
Dirtbags Monarch Of The Hill Server Dirtbags Monarch Of The Hill Server
===================== =====================
![Build badge](https://github.com/dirtbags/moth/workflows/Build/Test/Push/badge.svg) [![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth)](https://goreportcard.com/report/github.com/dirtbags/moth)
![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth)
Monarch Of The Hill (MOTH) is a puzzle server. Monarch Of The Hill (MOTH) is a puzzle server.
We (the authors) have used it for instructional and contest events called We (the authors) have used it for instructional and contest events called

View File

@ -128,7 +128,7 @@ func TestHttpd(t *testing.T) {
if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "answer123"}); r.Result().StatusCode != 200 { if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "answer123"}); r.Result().StatusCode != 200 {
t.Error(r.Result()) t.Error(r.Result())
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"error awarding points: points already awarded to this team in this category"}}` { } else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"points already awarded to this team in this category"}}` {
t.Error("Unexpected body", r.Body.String()) t.Error("Unexpected body", r.Body.String())
} }
} }

View File

@ -156,7 +156,7 @@ func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string)
return fmt.Errorf("invalid team ID") return fmt.Errorf("invalid team ID")
} }
if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil { if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
return fmt.Errorf("error awarding points: %s", err) return err
} }
return nil return nil
@ -169,7 +169,6 @@ func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time,
// Register associates a team name with a team ID. // Register associates a team name with a team ID.
func (mh *MothRequestHandler) Register(teamName string) error { func (mh *MothRequestHandler) Register(teamName string) error {
// BUG(neale): Register returns an error if a team is already registered; it may make more sense to return success
if teamName == "" { if teamName == "" {
return fmt.Errorf("empty team name") return fmt.Errorf("empty team name")
} }

View File

@ -522,6 +522,9 @@ func (ds *DevelState) TeamName(teamID string) (string, error) {
if name, err := ds.StateProvider.TeamName(teamID); err == nil { if name, err := ds.StateProvider.TeamName(teamID); err == nil {
return name, nil return name, nil
} }
if teamID == "" {
return "", fmt.Errorf("Empty team ID")
}
return fmt.Sprintf("«devel:%s»", teamID), nil return fmt.Sprintf("«devel:%s»", teamID), nil
} }

73
docs/scoring.md Normal file
View File

@ -0,0 +1,73 @@
Scoring
=======
MOTH does not carry any notion of who is winning: we consider this a user
interface issue. The server merely provides a timestamped log of point awards.
The bundled scoreboard provides one way to interpret the scores: this is the
main algorithm we use at Cyber Fire events. We use other views of the scoreboard
in other contexts, though! Here are some ideas:
Percentage of Each Category
---------------------
This is implemented in the scoreboard distributed with MOTH, and is how our
primary score calculation at Cyber Fire.
For each category:
* Divide the team's score in this category by the highest score in this category
* Add that to the team's overall score
This means the highest theoretical score in any event is the number of open
categories.
This algorithm means that point values only matter relative to other point
values within that category. A category with 5 total points is worth the same as
a category with 5000 total points, and a 2 point puzzle in the first category is
worth as much as a 2000 point puzzle in the second.
One interesting effect here is that a team solving a previously-unsolved puzzle
will reduce everybody else's ranking in that category, because it increases the
divisor for calculating that category's score.
Cyber Fire used to not display overall score: we would only show each team's
relative ranking per category. We may go back to this at some point!
Category Completion
----------------
Cyber Fire also has a scoreboard called the "class" scoreboard, which lists each
team, and which puzzles they have completed. This provides instructors with a
graphical overview of how people are progressing through content. We can provide
assistance to the general group when we see that a large number of teams are
stuck on a particular puzzle, and we can provide individual assistance if we see
that someone isn't keeping up with the class.
Monarch Of The Hill
----------------
You could also implement a "winner takes all" approach: any team with the
maximum number of points in a category gets 1 point, and all other teams get 0.
Time Bonuses
-----------
If you wanted to provide extra points to whichever team solves a puzzle first,
this is possible with the log. You could either boost a puzzle's point value or
decay it; either by timestamp, or by how many teams had solved it prior.
Bonkers Scoring
-------------
Other zany options exist:
* The first team to solve a puzzle with point value divisible by 7 gets double
points.
* [Tokens](tokens.md) with negative point values could be introduced, allowing
teams to manipulate other teams' scores, if they know the team ID.

View File

@ -4,7 +4,7 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
"crypto/sha256" "crypto/sha1"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -37,23 +37,45 @@ type PuzzleDebug struct {
Summary string Summary string
} }
// Puzzle contains everything about a puzzle that a client would see. // Puzzle contains everything about a puzzle that a client will see.
type Puzzle struct { type Puzzle struct {
Debug PuzzleDebug // Debug contains debugging information, omitted in mothballs
Authors []string Debug PuzzleDebug
Attachments []string
Scripts []string // Authors names all authors of this puzzle
Body string Authors []string
// Attachments is a list of filenames used by this puzzle
Attachments []string
// Scripts is a list of EMCAScript files needed by the client for this puzzle
Scripts []string
// Body is the HTML rendering of this puzzle
Body string
// AnswerPattern contains the pattern (regular expression?) used to match valid answers
AnswerPattern string AnswerPattern string
AnswerHashes []string
Objective string // AnswerHashes contains hashes of all answers for this puzzle
KSAs []string AnswerHashes []string
Success struct {
// Objective is the learning objective for this puzzle
Objective string
// KSAs lists all KSAs achieved upon successfull completion of this puzzle
KSAs []string
// Success lists the criteria for successfully understanding this puzzle
Success struct {
// Acceptable describes the minimum work required to be considered successfully understanding this puzzle's concepts
Acceptable string Acceptable string
Mastery string
// Mastery describes the work required to be considered mastering this puzzle's conceptss
Mastery string
} }
// Answers will be empty in a mothball // Answers lists all acceptable answers, omitted in mothballs
Answers []string Answers []string
} }
@ -63,9 +85,9 @@ func (puzzle *Puzzle) computeAnswerHashes() {
} }
puzzle.AnswerHashes = make([]string, len(puzzle.Answers)) puzzle.AnswerHashes = make([]string, len(puzzle.Answers))
for i, answer := range puzzle.Answers { for i, answer := range puzzle.Answers {
sum := sha256.Sum256([]byte(answer)) sum := sha1.Sum([]byte(answer))
hexsum := fmt.Sprintf("%x", sum) hexsum := fmt.Sprintf("%x", sum)
puzzle.AnswerHashes[i] = hexsum puzzle.AnswerHashes[i] = hexsum[:4]
} }
} }

View File

@ -23,6 +23,12 @@ func TestPuzzle(t *testing.T) {
if (len(p.Answers) == 0) || (p.Answers[0] != "YAML answer") { if (len(p.Answers) == 0) || (p.Answers[0] != "YAML answer") {
t.Error("Answers are wrong", p.Answers) t.Error("Answers are wrong", p.Answers)
} }
if len(p.Answers) != len(p.AnswerHashes) {
t.Error("Answer hashes length does not match answers length")
}
if len(p.AnswerHashes[0]) != 4 {
t.Error("Answer hash is wrong length")
}
if (len(p.Authors) != 3) || (p.Authors[1] != "Buster") { if (len(p.Authors) != 3) || (p.Authors[1] != "Buster") {
t.Error("Authors are wrong", p.Authors) t.Error("Authors are wrong", p.Authors)
} }

175
theme/background.mjs Normal file
View File

@ -0,0 +1,175 @@
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
class Point {
constructor(x, y) {
this.x = x
this.y = y
}
/**
* Add n to this.
*
* @param {Point} n What to add to this
* @returns {Point}
*/
Add(n) {
return new Point(this.x + n.x, this.y + n.y)
}
/**
* Subtract n from this.
*
* @param {Point} n
* @returns {Point}
*/
Subtract(n) {
return new Point(this.x - n.x, this.y - n.y)
}
/**
* Add velocity, then bounce point off box defined by points at min and max
* @param {Point} velocity
* @param {Point} min
* @param {Point} max
* @returns {Point}
*/
Bounce(velocity, min, max) {
let p = this.Add(velocity)
if (p.x < min.x) {
p.x += (min.x - p.x) * 2
velocity.x *= -1
}
if (p.x > max.x) {
p.x += (max.x - p.x) * 2
velocity.x *= -1
}
if (p.y < min.y) {
p.y += (min.y - p.y) * 2
velocity.y *= -1
}
if (p.y > max.y) {
p.y += (max.y - p.y) * 2
velocity.y *= -1
}
return p
}
/**
*
* @param {Point} p
* @returns {Boolean}
*/
Equal(p) {
return (this.x == p.x) && (this.y == p.y)
}
}
class QixLine {
/**
* @param {Number} hue
* @param {Point} a
* @param {Point} b
*/
constructor(hue, a, b) {
this.hue = hue
this.a = a
this.b = b
}
}
/**
* Draw a line dancing around the screen,
* like the video game "qix"
*/
class QixBackground {
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)
this.box = this.max.Subtract(this.min)
this.lines = [
new QixLine(
Math.random(),
new Point(randint(this.box.x), randint(this.box.y)),
new Point(randint(this.box.x), randint(this.box.y)),
)
]
while (this.lines.length < 18) {
this.lines.push(this.lines[0])
}
this.velocity = new QixLine(
0.001,
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.nextFrame = 0
}
/**
* Maybe draw a frame
*/
Animate() {
let now = performance.now()
if (now < this.nextFrame) {
// Not today, satan
return
}
this.nextFrame = now + this.frameInterval
this.lines.shift()
let lastLine = this.lines[this.lines.length - 1]
let nextLine = new QixLine(
(lastLine.hue + this.velocity.hue) % 1.0,
lastLine.a.Bounce(this.velocity.a, this.min, this.max),
lastLine.b.Bounce(this.velocity.b, this.min, this.max),
)
this.lines.push(nextLine)
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
for (let line of this.lines) {
this.ctx.save()
this.ctx.strokeStyle = `hwb(${line.hue}turn 0% 0%)`
this.ctx.beginPath()
this.ctx.moveTo(line.a.x, line.a.y)
this.ctx.lineTo(line.b.x, line.b.y)
this.ctx.stroke()
this.ctx.restore()
}
}
}
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
canvas.classList.add("wallpaper")
document.body.insertBefore(canvas, document.body.firstChild)
let ctx = canvas.getContext("2d")
let qix = new QixBackground(ctx)
// window.requestAnimationFrame is overkill for something this silly
setInterval(() => qix.Animate(), Millisecond/FrameRate)
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}

View File

@ -1,132 +1,138 @@
/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */ /* Color palette: http://paletton.com/#uid=33x0u0klrl-4ON9dhtKtAdqMQ4T */
body { body {
font-family: sans-serif; font-family: sans-serif;
background: #010e19 url("bg.png") center fixed;
background-size: cover;
background-blend-mode: soft-light;
background-color: #010e19;
color: #edd488;
}
canvas.wallpaper {
position: fixed;
display: block;
z-index: -1000;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
opacity: 0.2;
image-rendering: pixelated;
}
main {
max-width: 40em; max-width: 40em;
background: #282a33; margin: 1em auto;
color: #f6efdc; padding: 1px 3px;
border-radius: 5px;
background: #000d;
} }
body.wide { h1, h2, h3, h4, h5, h6 {
max-width: 100%; color: #cb2408cc;
}
a:any-link {
color: #8b969a;
} }
h1 { h1 {
background: #5e576b; background: #cb240844;
color: #9e98a8; padding: 3px;
}
.Fail, .Error, #messages {
background: #3a3119;
color: #ffcc98;
}
.Fail:before {
content: "Fail: ";
}
.Error:before {
content: "Error: ";
} }
p { p {
margin: 1em 0em; margin: 1em 0em;
} }
a:any-link {
color: #b9cbd8;
}
form, pre { form, pre {
margin: 1em; margin: 1em;
overflow-x: auto;
} }
input, select { input, select {
padding: 0.6em; padding: 0.6em;
margin: 0.2em; margin: 0.2em;
max-width: 30em; max-width: 30em;
} }
nav { input {
border: solid black 2px; background-color: #ccc4;
color: inherit;
}
input:hover {
background-color: #8884;
}
input:active {
background-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;
}
.category .solved {
text-decoration: line-through;
} }
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;
} }
#messages { .answer_ok {
min-height: 3em; cursor: help;
border: solid black 2px;
}
#rankings {
width: 100%;
position: relative;
} }
#rankings span { /** Development mode information */
font-size: 75%; .debug {
display: inline-block; overflow: auto;
overflow: hidden; padding: 1em;
height: 1.7em; border-radius: 10px;
} margin: 2em auto;
#rankings span.teamname { background: #cccc;
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;}
#devel {
background-color: #eee;
color: black; color: black;
overflow: scroll;
} }
#devel .string { .debug dt {
color: #9c27b0; font-weight: bold;
}
#devel .body {
background-color: #ffc107;
}
.kvpair {
border: solid black 2px;
}
.spinner {
display: inline-block;
width: 64px;
height: 64px;
display: block;
width: 46px;
height: 46px;
margin: 1px;
border-radius: 50%;
border: 5px solid #fff;
border-color: #fff transparent #fff transparent;
animation: rotate 1.2s linear infinite;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
/** Draggable items, from the draggable plugin */
li[draggable]::before { li[draggable]::before {
content: "↕"; content: "↕";
padding: 0.5em; padding: 0.5em;
@ -144,6 +150,48 @@ 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) {
/* We 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;
}
} }

BIN
theme/bg.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

84
theme/common.mjs Normal file
View File

@ -0,0 +1,84 @@
/**
* Common functionality
*/
const Millisecond = 1
const Second = Millisecond * 1000
const Minute = Second * 60
/** URL to the top of this MOTH server */
const BaseURL = new URL(".", location)
/**
* 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()
}
}
/**
* Interprets a String as a Boolean.
*
* Values like "no" or "disabled" to mean false here.
*
* @param {String} s
* @returns {Boolean}
*/
function Truthy(s) {
switch (s.toLowerCase()) {
case "disabled":
case "no":
case "off":
case "false":
return false
}
return true
}
/**
* Fetch the configuration object for this theme.
*
* @returns {Promise.<Object>}
*/
async function Config() {
let resp = await fetch(
new URL("config.json", BaseURL),
{
cache: "no-cache"
},
)
return resp.json()
}
export {
Millisecond,
Second,
Minute,
BaseURL,
Toast,
WhenDOMLoaded,
Truthy,
Config,
}

13
theme/config.json Normal file
View File

@ -0,0 +1,13 @@
{
"TrackSolved": true,
"Scoreboard": {
"DisplayServerURL": true,
"ShowCategoryLeaders": true,
"ReplayHistory": true,
"ReplayFPS": 30,
"ReplayDurationMS": 2000,
"": ""
},
"Messages": "<!-- Messages can go here (HTML) -->",
"": "this is here so you don't have to remember to take the comma off the last item"
}

View File

@ -1,33 +1,44 @@
<!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="stylesheet" href="basic.css"> <link rel="stylesheet" href="basic.css">
<script src="moth.js"></script> <script src="index.mjs" type="module" async></script>
<link rel="manifest" href="manifest.json"> <script src="background.mjs" type="module" async></script>
</head> </head>
<body> <body>
<h1 id="title">MOTH</h1> <h1 class="title" title="Monarch Of The Hill">MOTH</h1>
<section> <main>
<div id="messages"> <div class="messages notification">
<div id="notices"></div>
</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>
</main>
<div class="notification" data-track-solved="no">
<p>
Solved puzzle tracking: <b>disabled</b>.
</p>
<p>
Your team's Incident Coordinator can help coordinate team activity.
</p>
</div>
<div class="toasts"></div>
</section>
<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>

175
theme/index.mjs Normal file
View File

@ -0,0 +1,175 @@
/**
* Functionality for index.html (Login / Puzzles list)
*/
import * as moth from "./moth.mjs"
import * as common from "./common.mjs"
class App {
constructor(basePath=".") {
this.config = {}
this.server = new moth.Server(basePath)
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.UpdateState(), common.Minute/3)
setInterval(() => this.UpdateConfig(), common.Minute* 5)
this.UpdateConfig()
.finally(() => this.UpdateState())
}
handleLoginSubmit(event) {
event.preventDefault()
let f = new FormData(event.target)
this.Login(f.get("id"), f.get("name"))
}
/**
* 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.UpdateState()
}
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.UpdateState()
}
catch (error) {
common.Toast(error)
}
}
/**
* Update app configuration.
*
* Configuration can be updated less frequently than state, to reduce server
* load, since configuration should (hopefully) change less frequently.
*/
async UpdateConfig() {
this.config = await common.Config()
for (let e of document.querySelectorAll(".messages")) {
e.innerHTML = this.config.Messages || ""
}
}
/**
* 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 UpdateState() {
this.state = await this.server.GetState()
// Update elements with data-track-solved
for (let e of document.querySelectorAll("[data-track-solved]")) {
// Only display if data-track-solved is the same as config.trackSolved
e.classList.toggle("hidden", common.Truthy(e.dataset.trackSolved) != this.config.TrackSolved)
}
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()) {
let teamID = Math.floor(Math.random() * 1000000).toString(16)
common.Toast("Automatically logging in to devel server")
console.info(`Logging in with generated Team ID: ${teamID}`)
return this.Login(teamID, `Team ${teamID}`)
}
}
/**
* Render a login box.
*
* Just toggles visibility, there's nothing dynamic in a login box.
*/
renderLogin(element, visible) {
element.classList.toggle("hidden", !visible)
}
/**
* Render a puzzles box.
*
* Displays 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", common.BaseURL)
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.config.TrackSolved) {
a.classList.toggle("solved", this.state.IsSolved(puzzle))
}
}
if (!this.state.ContainsUnsolved(cat)) {
l.appendChild(document.createElement("li")).textContent = "✿"
}
element.appendChild(pdiv)
}
}
}
function init() {
window.app = {
server: new App()
}
}
common.WhenDOMLoaded(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>

View File

@ -1,9 +0,0 @@
{
"name": "Monarch of the Hill",
"short_name": "MOTH",
"start_url": ".",
"display": "standalone",
"background_color": "#282a33",
"theme_color": "#ECB",
"description": "The MOTH CTF engine"
}

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()
}

681
theme/moth.mjs Normal file
View File

@ -0,0 +1,681 @@
/**
* Hash/digest functions
*/
class Hash {
/**
* Dan Bernstein hash
*
* Used until MOTH v3.5
*
* @param {string} buf Input
* @returns {number}
*/
static djb2(buf) {
let h = 5381
for (let c of (new TextEncoder()).encode(buf)) { // Encode as UTF-8 and read in each byte
// JavaScript converts everything to a signed 32-bit integer when you do bitwise operations.
// So we have to do "unsigned right shift" by zero to get it back to unsigned.
h = ((h * 33) + c) >>> 0
}
return h
}
/**
* Dan Bernstein hash with xor
*
* @param {string} buf Input
* @returns {number}
*/
static djb2xor(buf) {
let h = 5381
for (let c of (new TextEncoder()).encode(buf)) {
h = ((h * 33) ^ c) >>> 0
}
return h
}
/**
* SHA 256
*
* Used until MOTH v4.5
*
* @param {string} buf Input
* @returns {Promise.<string>} hex-encoded digest
*/
static async sha256(buf) {
const msgUint8 = new TextEncoder().encode(buf)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return this.hexlify(hashArray);
}
/**
* SHA 1, but only the first 4 hexits (2 octets).
*
* Git uses this technique with 7 hexits (default) as a "short identifier".
*
* @param {string} buf Input
*/
static async sha1_slice(buf, end=4) {
const msgUint8 = new TextEncoder().encode(buf)
const hashBuffer = await crypto.subtle.digest("SHA-1", msgUint8)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hexits = this.hexlify(hashArray)
return hexits.slice(0, end)
}
/**
* Hex-encode a byte array
*
* @param {number[]} buf Byte array
* @returns {string}
*/
static hexlify(buf) {
return buf.map(b => b.toString(16).padStart(2, "0")).join("")
}
/**
* Apply every hash to the input buffer.
*
* @param {string} buf Input
* @returns {Promise.<string[]>}
*/
static async All(buf) {
return [
String(this.djb2(buf)),
await this.sha256(buf),
await this.sha1_slice(buf),
]
}
}
/**
* A point award.
*/
class Award {
constructor(when, teamid, category, points) {
/** Unix epoch timestamp for this award
* @type {number}
*/
this.When = when
/** Team ID this award belongs to
* @type {string}
*/
this.TeamID = teamid
/** Puzzle category for this award
* @type {string}
*/
this.Category = category
/** Points value of this award
* @type {number}
*/
this.Points = points
}
}
/**
* A puzzle.
*
* A new Puzzle only knows its category and point value.
* If you want to populate it with meta-information, you must call Populate().
*
* Parameters created by Populate are described in the server source code:
* {@link https://pkg.go.dev/github.com/dirtbags/moth/v4/pkg/transpile#Puzzle}
*
*/
class Puzzle {
/**
* @param {Server} server
* @param {string} category
* @param {number} points
*/
constructor (server, category, points) {
if (points < 1) {
throw(`Invalid points value: ${points}`)
}
/** Server where this puzzle lives
* @type {Server}
*/
this.server = server
/** Category this puzzle belongs to */
this.Category = String(category)
/** Point value of this puzzle */
this.Points = Number(points)
/** Error returned trying to retrieve this puzzle */
this.Error = {
/** Status code provided by server */
Status: 0,
/** Status text provided by server */
StatusText: "",
/** Full text of server error */
Body: "",
}
}
/**
* Populate this Puzzle object with meta-information from the server.
*/
async Populate() {
let resp = await this.Get("puzzle.json")
if (!resp.ok) {
let body = await resp.text()
this.Error = {
Status: resp.status,
StatusText: resp.statusText,
Body: body,
}
throw(this.Error)
}
let obj = await resp.json()
Object.assign(this, obj)
// Make sure lists are lists
this.AnswerHashes ||= []
this.Answers ||= []
this.Attachments ||= []
this.Authors ||= []
this.Debug.Errors ||= []
this.Debug.Hints ||= []
this.Debug.Log ||= []
this.KSAs ||= []
this.Scripts ||= []
}
/**
* Get a resource associated with this puzzle.
*
* @param {string} filename Attachment/Script to retrieve
* @returns {Promise.<Response>}
*/
Get(filename) {
return this.server.GetContent(this.Category, this.Points, filename)
}
/**
* Check if a string is possibly correct.
*
* The server sends a list of answer hashes with each puzzle: this method
* checks to see if any of those hashes match a hash of the string.
*
* The MOTH development team likes obscure hash functions with a lot of
* collisions, which means that a given input may match another possible
* string's hash. We do this so that if you run a brute force attack against
* the list of hashes, you have to write your own brute force program, and
* you still have to pick through a lot of potentially correct answers when
* it's done.
*
* @param {string} str User-submitted possible answer
* @returns {Promise.<boolean>}
*/
async IsPossiblyCorrect(str) {
let userAnswerHashes = await Hash.All(str)
for (let pah of this.AnswerHashes) {
for (let uah of userAnswerHashes) {
if (pah == uah) {
return true
}
}
}
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)
}
}
/**
* A snapshot of scores.
*/
class Scores {
constructor() {
/**
* Timestamp of this score snapshot
* @type number
*/
this.Timestamp = 0
/**
* All categories present in this snapshot.
*
* ECMAScript sets preserve order, so iterating over this will yield
* categories as they were added to the points log.
*
* @type {Set.<string>}
*/
this.Categories = new Set()
/**
* All team IDs present in this snapshot
* @type {Set.<string>}
*/
this.TeamIDs = new Set()
/**
* Highest score in each category
* @type {Object.<string,number>}
*/
this.MaxPoints = {}
this.categoryTeamPoints = {}
}
/**
* Return a sorted list of category names
*
* @returns {string[]}
*/
SortedCategories() {
let categories = [...this.Categories]
categories.sort((a,b) => a.localeCompare(b, "en", {sensitivity: "base"}))
return categories
}
/**
* Add an award to a team's score.
*
* Updates this.Timestamp to the award's timestamp.
*
* @param {Award} award
*/
Add(award) {
this.Timestamp = award.Timestamp
this.Categories.add(award.Category)
this.TeamIDs.add(award.TeamID)
let teamPoints = (this.categoryTeamPoints[award.Category] ??= {})
let points = (teamPoints[award.TeamID] || 0) + award.Points
teamPoints[award.TeamID] = points
let max = this.MaxPoints[award.Category] || 0
this.MaxPoints[award.Category] = Math.max(max, points)
}
/**
* Get a team's score within a category.
*
* @param {string} category
* @param {string} teamID
* @returns {number}
*/
GetPoints(category, teamID) {
let teamPoints = this.categoryTeamPoints[category] || {}
return teamPoints[teamID] || 0
}
/**
* Calculate a team's score in a category, using the Cyber Fire algorithm.
*
*@param {string} category
* @param {string} teamID
*/
CyFiCategoryScore(category, teamID) {
return this.GetPoints(category, teamID) / this.MaxPoints[category]
}
/**
* Calculate a team's overall score, using the Cyber Fire algorithm.
*
*@param {string} category
* @param {string} teamID
* @returns {number}
*/
CyFiScore(teamID) {
let score = 0
for (let category of this.Categories) {
score += this.CyFiCategoryScore(category, teamID)
}
return score
}
}
/**
* MOTH instance state.
*/
class State {
/**
* @param {Server} server Server where we got this
* @param {Object} obj Raw state data
*/
constructor(server, obj) {
for (let key of ["Config", "TeamNames", "PointsLog"]) {
if (!obj[key]) {
throw(`Missing state property: ${key}`)
}
}
this.server = server
/** Configuration */
this.Config = {
/** Is the server in development mode?
* @type {boolean}
*/
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[]}
*/
this.PointsLog = obj.PointsLog.map(entry => new Award(entry[0], entry[1], entry[2], entry[3]))
}
/**
* Returns a sorted list of open category names
*
* @returns {string[]} List of categories
*/
Categories() {
let ret = []
for (let category in this.PointsByCategory) {
ret.push(category)
}
ret.sort()
return ret
}
/**
* Check whether a category contains unsolved puzzles.
*
* The server adds a puzzle with 0 points in every "solved" category,
* so this just checks whether there is a 0-point puzzle in the category's point list.
*
* @param {string} category
* @returns {boolean}
*/
ContainsUnsolved(category) {
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.
*
* The returned list will be sorted by (category, points).
* If not categories are given, all puzzles will be returned.
*
* @param {string} categories Limit results to these categories
* @returns {Puzzle[]}
*/
Puzzles(...categories) {
if (categories.length == 0) {
categories = this.Categories()
}
let ret = []
for (let category of categories) {
for (let points of this.PointsByCategory[category]) {
if (0 == points) {
// This means all potential puzzles in the category are open
continue
}
let p = new Puzzle(this.server, category, points)
ret.push(p)
}
}
return ret
}
/**
* Has this puzzle been solved by this team?
*
* @param {Puzzle} puzzle
* @param {string} teamID Team to check, default the logged-in team
* @returns {boolean}
*/
IsSolved(puzzle, teamID="self") {
for (let award of this.PointsLog) {
if (
(award.Category == puzzle.Category)
&& (award.Points == puzzle.Points)
&& (award.TeamID == teamID)
) {
return true
}
}
return false
}
/**
* Replay scores.
*
* MOTH has no notion of who is "winning", we consider this a user interface
* decision. There are lots of interesting options: see
* [scoring]{@link ../docs/scoring.md} for more.
*
* @yields {Scores} Snapshot at a point in time
*/
* ScoresHistory() {
let scores = new Scores()
for (let award of this.PointsLog) {
scores.Add(award)
yield scores
}
}
/**
* Calculate the current scores.
*
* @returns {Scores}
*/
CurrentScores() {
let scores
for (scores of this.ScoreHistory());
return scores
}
}
/**
* A MOTH Server interface.
*
* This uses localStorage to remember Team ID,
* 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.teamIDKey = this.baseUrl.toString() + " teamID"
this.TeamID = localStorage[this.teamIDKey]
}
/**
* Fetch a MOTH resource.
*
* If anything other than a 2xx code is returned,
* this function throws an error.
*
* This always sends teamID.
* If args is set, POST will be used instead of GET
*
* @param {string} path Path to API endpoint
* @param {Object.<string,string>} args Key/Values to send in POST data
* @returns {Promise.<Response>} Response
*/
fetch(path, args={}) {
let body = new URLSearchParams(args)
if (this.TeamID && !body.has("id")) {
body.set("id", this.TeamID)
}
let url = new URL(path, this.baseUrl)
return fetch(url, {
method: "POST",
body,
cache: "no-cache",
})
}
/**
* Send a request to a JSend API endpoint.
*
* @param {string} path Path to API endpoint
* @param {Object.<string,string>} args Key/Values to send in POST
* @returns {Promise.<Object>} JSend Data
*/
async call(path, args={}) {
let resp = await this.fetch(path, args)
let obj = await resp.json()
switch (obj.status) {
case "success":
return obj.data
case "fail":
throw new Error(obj.data.description || obj.data.short || obj.data)
case "error":
throw new Error(obj.message)
default:
throw new Error(`Unknown JSend status: ${obj.status}`)
}
}
/**
* Make a new URL for the given resource.
*
* The returned URL instance will be absolute, and immune to changes to the
* page that would affect relative URLs.
*
* @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.teamIDKey)
this.TeamID = null
}
/**
* Fetch current contest state.
*
* @returns {Promise.<State>}
*/
async GetState() {
let resp = await this.fetch("/state")
let obj = await resp.json()
return new State(this, obj)
}
/**
* 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 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 proposed answer for points.
*
* The returned promise will fail if anything goes wrong, including the
* proposed answer being rejected.
*
* @param {string} category Category of puzzle
* @param {number} points Point value of puzzle
* @param {string} proposed Answer to submit
* @returns {Promise.<string>} Success message
*/
async SubmitAnswer(category, points, proposed) {
let data = await this.call("/answer", {
cat: category,
points,
answer: proposed,
})
return data.description || data.short
}
/**
* Fetch a file associated with a puzzle.
*
* @param {string} category Category of puzzle
* @param {number} points Point value of puzzle
* @param {string} filename
* @returns {Promise.<Response>}
*/
GetContent(category, points, filename) {
return this.fetch(`/content/${category}/${points}/${filename}`)
}
/**
* Return a Puzzle object.
*
* New Puzzle objects only know their category and point value.
* See docstrings on the Puzzle object for more information.
*
* @param {string} category
* @param {number} points
* @returns {Puzzle}
*/
GetPuzzle(category, points) {
return new Puzzle(this, category, points)
}
}
export {
Hash,
Server,
}

View File

@ -1,37 +1,34 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<title>Puzzle</title> <title>Puzzle</title>
<link rel="stylesheet" href="basic.css">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<meta charset="utf-8"> <meta charset="utf-8">
<script src="puzzle.js"></script> <link rel="icon" href="luna-moth.svg">
<script> <link rel="stylesheet" href="basic.css">
<script src="background.mjs" type="module" async></script>
</script> <script src="puzzle.mjs" type="module" async></script>
</head> </head>
<body> <body>
<h1>Puzzle</h1> <h1 id="title">[loading]</h1>
<section> <main>
<div id="puzzle"><span class="spinner"></span></div> <section id="puzzle">
<ul id="files"></ul> <p class="notification">
<p>Puzzle by <span id="authors"></span></p> Starting script...
</section> </p>
<div id="messages"></div> </section>
<form> <section class="meta"></section>
<input type="hidden" name="cat"> <ul id="files"></ul>
<input type="hidden" name="points"> <p>Puzzle by <span id="authors">[loading]</span></p>
<input type="hidden" name="xAnswer"> </section>
Team ID: <input type="text" name="id"> <br> <form class="answer">
Answer: <input type="text" name="answer" id="answer"> <span id="answer_ok"></span><br> <label for="answer">Answer:</label>
<input type="submit" value="Submit"> <input type="text" name="answer" id="answer"> <span class="answer_ok"></span>
</form> <br>
<div id="devel"></div> <input type="submit" value="Submit">
<nav> </form>
<ul> </main>
<li><a href="index.html">Puzzles</a></li> <div class="debug" class="notification"></div>
<li><a href="scoreboard.html">Scoreboard</a></li> <div class="toasts"></div>
</ul>
</nav>
</body> </body>
</html> </html>

View File

@ -1,225 +0,0 @@
// jshint asi:true
// prettify adds classes to various types, returning an HTML string.
function prettify(key, val) {
switch (key) {
case "Body":
return '[HTML]'
}
return val
}
// devel_addin drops a bunch of development extensions into element e.
// It will only modify stuff inside e.
function devel_addin(e) {
let h = e.appendChild(document.createElement("h2"))
h.textContent = "Developer Output"
let log = window.puzzle.Debug.Log || []
if (log.length > 0) {
e.appendChild(document.createElement("h3")).textContent = "Log"
let le = e.appendChild(document.createElement("ul"))
for (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()
}

239
theme/puzzle.mjs Normal file
View File

@ -0,0 +1,239 @@
/**
* 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.
*
* Called when the user submits the form,
* either by clicking a "submit" button,
* or by some other means provided by the browser,
* like hitting the Enter key.
*
* @param {Event} event
*/
async function formSubmitHandler(event) {
event.preventDefault()
let data = new FormData(event.target)
let proposed = data.get("answer")
let message
console.groupCollapsed("Submit answer")
console.info(`Proposed answer: ${proposed}`)
try {
message = await window.app.puzzle.SubmitAnswer(proposed)
common.Toast(message)
}
catch (err) {
common.Toast(err)
}
console.groupEnd("Submit answer")
}
/**
* Handle an input event on the answer field.
*
* @param {Event} event
*/
async function answerInputHandler(event) {
let answer = event.target.value
let correct = await window.app.puzzle.IsPossiblyCorrect(answer)
for (let ok of event.target.parentElement.querySelectorAll(".answer_ok")) {
if (correct) {
ok.textContent = "⭕"
ok.title = "Possibly correct"
} else {
ok.textContent = "❌"
ok.title = "Definitely not correct"
}
}
}
/**
* Return the puzzle content element, possibly with everything cleared out of it.
*
* @param {boolean} clear Should the element be cleared of children? Default true.
* @returns {Element}
*/
function puzzleElement(clear=true) {
let e = document.querySelector("#puzzle")
if (clear) {
while (e.firstChild) e.firstChild.remove()
}
return e
}
/**
* Display an error in the puzzle area, and also send it to the console.
*
* Errors are rendered in the puzzle area, so the user can see a bit more about
* what the problem is.
*
* @param {string} error
*/
function error(error) {
console.error(error)
let e = puzzleElement().appendChild(document.createElement("pre"))
e.classList.add("error")
e.textContent = error.Body || error
}
/**
* Set the answer and invoke input handlers.
*
* Makes sure the Circle Of Success gets updated.
*
* @param {string} s
*/
function SetAnswer(s) {
let e = document.querySelector("#answer")
e.value = s
e.dispatchEvent(new Event("input"))
}
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.
*
* @param {string} category
* @param {number} points
*/
async function loadPuzzle(category, points) {
console.groupCollapsed("Loading puzzle:", category, points)
let contentBase = new URL(`content/${category}/${points}/`, common.BaseURL)
// Tell user we're loading
puzzleElement().appendChild(document.createElement("progress"))
for (let qs of ["#authors", "#title", "title"]) {
for (let e of document.querySelectorAll(qs)) {
e.textContent = "[loading]"
}
}
let puzzle = server.GetPuzzle(category, points)
console.time("Populate")
try {
await puzzle.Populate()
}
catch {
let error = puzzleElement().appendChild(document.createElement("pre"))
error.classList.add("notification", "error")
error.textContent = puzzle.Error.Body
return
}
finally {
console.timeEnd("Populate")
}
console.info(`Setting base tag to ${contentBase}`)
let baseElement = document.head.appendChild(document.createElement("base"))
baseElement.href = contentBase
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...")
for (let script of (puzzle.Scripts || [])) {
let st = document.createElement("script")
document.head.appendChild(st)
st.src = new URL(script, contentBase)
}
console.info("Listing attached files...")
for (let fn of (puzzle.Attachments || [])) {
let li = document.createElement("li")
let a = document.createElement("a")
a.href = new URL(fn, contentBase)
a.innerText = fn
li.appendChild(a)
document.getElementById("files").appendChild(li)
}
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")
}
}
window.app.puzzle = puzzle
console.info("window.app.puzzle =", window.app.puzzle)
console.groupEnd()
return puzzle
}
async function init() {
window.app = {}
window.setanswer = (str => SetAnswer(str))
for (let form of document.querySelectorAll("form.answer")) {
form.addEventListener("submit", formSubmitHandler)
for (let e of form.querySelectorAll("[name=answer]")) {
e.addEventListener("input", answerInputHandler)
}
}
// There isn't a more graceful way to "unload" scripts attached to the current puzzle
window.addEventListener("hashchange", () => location.reload())
// Make all links absolute, because we're going to be changing the base URL
for (let e of document.querySelectorAll("[href]")) {
e.href = new URL(e.href, common.BaseURL)
}
let hashpart = location.hash.split("#")[1] || ""
let catpoints = hashpart.split(":")
let category = catpoints[0]
let points = Number(catpoints[1])
if (!category && !points) {
error(`Doesn't look like a puzzle reference: ${hashpart}`)
return
}
window.app.puzzle = await loadPuzzle(category, points)
}
common.WhenDOMLoaded(init)

48340
theme/reports/NICEFramework2017.json Executable file

File diff suppressed because it is too large Load Diff

55
theme/reports/ksa.html Normal file
View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>KSA Report</title>
<meta name="viewport" content="width=device-width">
<meta charset="utf-8">
<script src="ksa.mjs" type="module" async></script>
<script src="../background.mjs" type="module" async></script>
<link rel="stylesheet" href="../basic.css">
</head>
<body>
<h1>KSA Report</h1>
<main>
<p>
This report shows all KSAs covered by this server so far.
This is not a report on your progress, but rather
what you would have covered if you had worked every exercise available.
</p>
<div class="notification">
<p class="doing"></p>
<progress class="doing"></progress>
</div>
<h2>All KSAs across all content</h2>
<ul class="allKSAs"></ul>
<h2>All KSAs by Category</h2>
<div class="KSAsByCategory">
</div>
<h2>KSAs by Puzzle</h2>
<table class="puzzles">
<thead>
<tr>
<th>Category</th>
<th>Points</th>
<th>KSAs</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
<template id="puzzlerow">
<tr>
<td class="category"></td>
<td class="points"></td>
<td class="ksas"></td>
<td><pre class="error"></pre></td>
</tr>
</template>
</tbody>
</table>
</main>
</body>
</html>

140
theme/reports/ksa.mjs Normal file
View File

@ -0,0 +1,140 @@
import * as moth from "../moth.mjs"
import * as common from "../common.mjs"
const server = new moth.Server("../")
/**
* Update "doing" indicators
*
* @param {String | null} what Text to display, or null to not update text
* @param {Number | null} finished Percentage complete to display, or null to not update progress
*/
function doing(what, finished = null) {
for (let e of document.querySelectorAll(".doing")) {
e.classList.remove("hidden")
if (what) {
e.textContent = what
}
if (finished) {
e.value = finished
} else {
e.removeAttribute("value")
}
}
}
function done() {
for (let e of document.querySelectorAll(".doing")) {
e.classList.add("hidden")
}
}
async function GetNice() {
let NiceElementsByIdentifier = {}
let resp = await fetch("NICEFramework2017.json")
let obj = await resp.json()
for (let e of obj.elements) {
NiceElementsByIdentifier[e.element_identifier] = e
}
return NiceElementsByIdentifier
}
/**
* Fetch a puzzle, and fill its KSAs and rows.
*
* This is done once per puzzle, in an asynchronous function, allowing the
* application to perform multiple blocking operations simultaneously.
*/
async function FetchAndFill(puzzle, KSAs, rows) {
try {
await puzzle.Populate()
}
catch (error) {
// Keep on going with whatever Populate was able to fill
}
for (let KSA of (puzzle.KSAs || [])) {
KSAs.add(KSA)
}
for (let row of rows) {
row.querySelector(".category").textContent = puzzle.Category
row.querySelector(".points").textContent = puzzle.Points
row.querySelector(".ksas").textContent = (puzzle.KSAs || []).join(" ")
row.querySelector(".error").textContent = puzzle.Error.Body
}
}
async function init() {
doing("Fetching NICE framework data")
let nicePromise = GetNice()
doing("Retrieving server state")
let state = await server.GetState()
doing("Retrieving all puzzles")
let KSAsByCategory = {}
let puzzlerowTemplate = document.querySelector("template#puzzlerow")
let puzzles = state.Puzzles()
let promises = []
for (let category of state.Categories()) {
KSAsByCategory[category] = new Set()
}
let pending = puzzles.length
for (let puzzle of puzzles) {
// Make space in the table, so everything fills in sorted order
let rows = []
for (let tbody of document.querySelectorAll("tbody")) {
let row = puzzlerowTemplate.content.cloneNode(true).firstElementChild
tbody.appendChild(row)
rows.push(row)
}
// Queue up a fetch, and update progress bar
let promise = FetchAndFill(puzzle, KSAsByCategory[puzzle.Category], rows)
promises.push(promise)
promise.then(() => doing(null, 1 - (--pending / puzzles.length)))
if (promises.length > 50) {
// Chrome runs out of resources if you queue up too many of these at once
await Promise.all(promises)
promises = []
}
}
await Promise.all(promises)
doing("Retrieving NICE identifiers")
let NiceElementsByIdentifier = await nicePromise
doing("Filling KSAs By Category")
let allKSAs = new Set()
for (let div of document.querySelectorAll(".KSAsByCategory")) {
for (let category of state.Categories()) {
doing(`Filling KSAs for category: ${category}`)
let KSAs = [...KSAsByCategory[category]]
KSAs.sort()
div.appendChild(document.createElement("h3")).textContent = category
let ul = div.appendChild(document.createElement("ul"))
for (let k of KSAs) {
let ksa = k.split(/\s+/)[0]
let ne = NiceElementsByIdentifier[ksa] || { text: "???" }
let text = `${ksa}: ${ne.text}`
ul.appendChild(document.createElement("li")).textContent = text
allKSAs.add(text)
}
}
}
doing("Filling KSAs")
for (let e of document.querySelectorAll(".allKSAs")) {
let KSAs = [...allKSAs]
KSAs.sort()
for (let text of KSAs) {
e.appendChild(document.createElement("li")).textContent = text
}
}
done()
}
common.WhenDOMLoaded(init)

84
theme/scoreboard.css Normal file
View File

@ -0,0 +1,84 @@
/* GHC displays: 1024x1820 */
@media screen and (max-aspect-ratio: 4/5) and (min-height: 1600px) {
html {
font-size: 20pt;
}
}
.location {
color: #acf;
background-color: #0008;
position: fixed;
right: 30vw;
bottom: 0;
padding: 1em;
margin: 0;
font-size: 1.2rem;
font-weight:bold;
text-decoration: underline;
}
/** Scoreboard */
#rankings {
width: 100%;
position: relative;
background-color: #000c;
}
#rankings div {
height: 1.2rem;
display: flex;
align-items: center;
}
#rankings div:nth-child(6n){
background-color: #ccc3;
}
#rankings div:nth-child(6n+3) {
background-color: #0f03;
}
#rankings span {
display: inline-block;
overflow: hidden;
}
#rankings span.category {
font-size: 80%;
}
#rankings span.teamname {
height: auto;
font-size: inherit;
color: white;
background-color: #000e;
border-radius: 3px;
position: absolute;
right: 0.2em;
}
#rankings span.teamname:hover,
#rankings span.category:hover {
width: inherit;
max-width: 100%;
}
.topscore::before {
content: "✩";
font-size: 75%;
vertical-align: top;
}
@media only screen and (max-width: 450px) {
#rankings span.teamname {
max-width: 6em;
text-overflow: ellipsis;
}
span.teampoints {
max-width: 80%;
}
}
#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,15 @@
<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>
<h4 id="location"></h4> <div id="rankings"></div>
<section class="rotate"> <div class="location"></div>
<div id="chart"></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> </body>
</html> </html>

View File

@ -1,251 +0,0 @@
// jshint asi:true
function scoreboardInit() {
chartColors = [
"rgb(255, 99, 132)",
"rgb(255, 159, 64)",
"rgb(255, 205, 86)",
"rgb(75, 192, 192)",
"rgb(54, 162, 235)",
"rgb(153, 102, 255)",
"rgb(201, 203, 207)"
]
function update(state) {
window.state = state
for (let rotate of document.querySelectorAll(".rotate")) {
rotate.appendChild(rotate.firstElementChild)
}
let element = document.getElementById("rankings")
let teamNames = state.TeamNames
let pointsLog = state.PointsLog
// Every machine that's displaying the scoreboard helpfully stores the last 20 values of
// points.json for us, in case of catastrophe. Thanks, y'all!
//
// 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()
}
stateHistory.push(state)
localStorage.setItem("stateHistory", JSON.stringify(stateHistory))
let teams = {}
let highestCategoryScore = {} // map[string]int
// Initialize data structures
for (let teamId in teamNames) {
teams[teamId] = {
categoryScore: {}, // map[string]int
overallScore: 0, // int
historyLine: [], // []{x: int, y: int}
name: teamNames[teamId],
id: teamId
}
}
// Dole out points
for (let entry of pointsLog) {
let timestamp = entry[0]
let teamId = entry[1]
let category = entry[2]
let points = entry[3]
let team = teams[teamId]
let score = team.categoryScore[category] || 0
score += points
team.categoryScore[category] = score
let highest = highestCategoryScore[category] || 0
if (score > highest) {
highestCategoryScore[category] = score
}
}
for (let teamId in teamNames) {
teams[teamId].categoryScore = {}
}
for (let entry of pointsLog) {
let timestamp = entry[0]
let teamId = entry[1]
let category = entry[2]
let points = entry[3]
let team = teams[teamId]
let score = team.categoryScore[category] || 0
score += points
team.categoryScore[category] = score
let overall = 0
for (let cat in team.categoryScore) {
overall += team.categoryScore[cat] / highestCategoryScore[cat]
}
team.historyLine.push({t: new Date(timestamp * 1000), y: overall})
}
// Compute overall scores based on current highest
for (let teamId in teams) {
let team = teams[teamId]
team.overallScore = 0
for (let cat in team.categoryScore) {
team.overallScore += team.categoryScore[cat] / highestCategoryScore[cat]
}
}
// Sort by team score
function teamCompare(a, b) {
return a.overallScore - b.overallScore
}
// Figure out how to order each team on the scoreboard
let winners = []
for (let teamId in teams) {
winners.push(teams[teamId])
}
winners.sort(teamCompare)
winners.reverse()
// Let's make some better names for things we've computed
let winningScore = winners[0].overallScore
let numCategories = Object.keys(highestCategoryScore).length
// Clear out the element we're about to populate
Array.from(element.childNodes).map(e => e.remove())
let maxWidth = 100 / winningScore
for (let team of winners) {
let row = document.createElement("div")
let ncat = 0
for (let category in highestCategoryScore) {
let catHigh = highestCategoryScore[category]
let catTeam = team.categoryScore[category] || 0
let catPct = catTeam / catHigh
let width = maxWidth * catPct
let bar = document.createElement("span")
bar.classList.add("category")
bar.classList.add("cat" + ncat)
bar.style.width = width + "%"
bar.textContent = category + ": " + catTeam
bar.title = bar.textContent
row.appendChild(bar)
ncat += 1
}
let te = document.createElement("span")
te.classList.add("teamname")
te.textContent = team.name
row.appendChild(te)
element.appendChild(row)
}
let datasets = []
for (let i in winners) {
if (i > 5) {
break
}
let team = winners[i]
let color = chartColors[i % chartColors.length]
datasets.push({
label: team.name,
backgroundColor: color,
borderColor: color,
data: team.historyLine,
lineTension: 0,
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()
}
}
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")
if (location) {
location.textContent = base
}
setInterval(refresh, 60000)
refresh()
}
init()
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", scoreboardInit)
} else {
scoreboardInit()
}

120
theme/scoreboard.mjs Normal file
View File

@ -0,0 +1,120 @@
import * as moth from "./moth.mjs"
import * as common from "./common.mjs"
const server = new moth.Server(".")
/** Don't let any team's score exceed this percentage width */
const MaxScoreWidth = 90
/**
* Returns a promise that resolves after timeout.
*
* This uses setTimeout instead of some other fancy thing like
* requestAnimationFrame, because who actually cares about scoreboard update
* framerate?
*
* @param {Number} timeout How long to sleep (milliseconds)
* @returns {Promise}
*/
function sleep(timeout) {
return new Promise(resolve => setTimeout(resolve, timeout));
}
/**
* Pull new points log, and update the scoreboard.
*
* The update is animated, because I think that looks cool.
*/
async function update() {
let config = {}
try {
config = await common.Config()
}
catch (err) {
console.warn("Parsing config.json:", err)
}
// Pull configuration settings
let ScoreboardConfig = config.Scoreboard ?? {}
let ReplayHistory = ScoreboardConfig.ReplayHistory ?? false
let ReplayDurationMS = ScoreboardConfig.ReplayDurationMS ?? 300
let ReplayFPS = ScoreboardConfig.ReplayFPS ?? 24
if (!config.Scoreboard) {
console.warn("config.json has empty Scoreboard section")
}
for (let e of document.querySelectorAll(".location")) {
e.textContent = common.BaseURL
e.classList.toggle("hidden", !ScoreboardConfig.DisplayServerURL)
}
let state = await server.GetState()
let rankingsElement = document.querySelector("#rankings")
let logSize = state.PointsLog.length
// Figure out the timing so that we can replay the scoreboard in about
// ReplayDurationMS, but no more than 24 frames per second.
let frameModulo = 1
let delay = 0
while (delay < (common.Second / ReplayFPS)) {
frameModulo += 1
delay = ReplayDurationMS / (logSize / frameModulo)
}
let frame = 0
for (let scores of state.ScoresHistory()) {
frame += 1
if (frame < state.PointsLog.length) { // Always render the last frame
if (!ReplayHistory || (frame % frameModulo)) { // Skip if we're not animating, or if we need to drop frames
continue
}
}
while (rankingsElement.firstChild) rankingsElement.firstChild.remove()
let sortedTeamIDs = [...scores.TeamIDs]
sortedTeamIDs.sort((a, b) => scores.CyFiScore(a) - scores.CyFiScore(b))
sortedTeamIDs.reverse()
let topScore = scores.CyFiScore(sortedTeamIDs[0])
for (let teamID of sortedTeamIDs) {
let teamName = state.TeamNames[teamID]
let row = rankingsElement.appendChild(document.createElement("div"))
let teamname = row.appendChild(document.createElement("span"))
teamname.textContent = teamName
teamname.classList.add("teamname")
let categoryNumber = 0
let teampoints = row.appendChild(document.createElement("span"))
teampoints.classList.add("teampoints")
for (let category of scores.Categories) {
let score = scores.CyFiCategoryScore(category, teamID)
if (!score) {
continue
}
// XXX: Figure out how to do this properly with flexbox
let block = row.appendChild(document.createElement("span"))
let points = scores.GetPoints(category, teamID)
let width = MaxScoreWidth * score / topScore
block.textContent = category
block.title = `${points} points`
block.style.width = `${width}%`
block.classList.add("category", `cat${categoryNumber}`)
block.classList.toggle("topscore", (score == 1) && ScoreboardConfig.ShowCategoryLeaders)
categoryNumber += 1
}
}
await sleep(delay)
}
}
function init() {
setInterval(update, common.Minute)
update()
}
common.WhenDOMLoaded(init)

View File

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

48
theme/token.mjs Normal file
View File

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