mirror of https://github.com/dirtbags/moth.git
Compare commits
No commits in common. "077dc261e478ce269e76f7a6cfcbd9479509afd7" and "a3d0f5516031674bc6f4ad1bae0a5d8159330d3f" have entirely different histories.
077dc261e4
...
a3d0f55160
|
@ -4,7 +4,7 @@ stages:
|
||||||
|
|
||||||
Run unit tests:
|
Run unit tests:
|
||||||
stage: test
|
stage: test
|
||||||
image: &goimage golang:1.21
|
image: &goimage golang:1.18
|
||||||
only:
|
only:
|
||||||
refs:
|
refs:
|
||||||
- main
|
- main
|
||||||
|
|
|
@ -4,13 +4,6 @@ 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
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
Dirtbags Monarch Of The Hill Server
|
Dirtbags Monarch Of The Hill Server
|
||||||
=====================
|
=====================
|
||||||
|
|
||||||
[![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth)](https://goreportcard.com/report/github.com/dirtbags/moth)
|
![Build badge](https://github.com/dirtbags/moth/workflows/Build/Test/Push/badge.svg)
|
||||||
|
![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
|
||||||
|
|
|
@ -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":"points already awarded to this team in this category"}}` {
|
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"error awarding points: points already awarded to this team in this category"}}` {
|
||||||
t.Error("Unexpected body", r.Body.String())
|
t.Error("Unexpected body", r.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 err
|
return fmt.Errorf("error awarding points: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -169,6 +169,7 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -522,9 +522,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,73 +0,0 @@
|
||||||
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.
|
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha1"
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -37,45 +37,23 @@ type PuzzleDebug struct {
|
||||||
Summary string
|
Summary string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Puzzle contains everything about a puzzle that a client will see.
|
// Puzzle contains everything about a puzzle that a client would see.
|
||||||
type Puzzle struct {
|
type Puzzle struct {
|
||||||
// Debug contains debugging information, omitted in mothballs
|
|
||||||
Debug PuzzleDebug
|
Debug PuzzleDebug
|
||||||
|
|
||||||
// Authors names all authors of this puzzle
|
|
||||||
Authors []string
|
Authors []string
|
||||||
|
|
||||||
// Attachments is a list of filenames used by this puzzle
|
|
||||||
Attachments []string
|
Attachments []string
|
||||||
|
|
||||||
// Scripts is a list of EMCAScript files needed by the client for this puzzle
|
|
||||||
Scripts []string
|
Scripts []string
|
||||||
|
|
||||||
// Body is the HTML rendering of this puzzle
|
|
||||||
Body string
|
Body string
|
||||||
|
|
||||||
// AnswerPattern contains the pattern (regular expression?) used to match valid answers
|
|
||||||
AnswerPattern string
|
AnswerPattern string
|
||||||
|
|
||||||
// AnswerHashes contains hashes of all answers for this puzzle
|
|
||||||
AnswerHashes []string
|
AnswerHashes []string
|
||||||
|
|
||||||
// Objective is the learning objective for this puzzle
|
|
||||||
Objective string
|
Objective string
|
||||||
|
|
||||||
// KSAs lists all KSAs achieved upon successfull completion of this puzzle
|
|
||||||
KSAs []string
|
KSAs []string
|
||||||
|
|
||||||
// Success lists the criteria for successfully understanding this puzzle
|
|
||||||
Success struct {
|
Success struct {
|
||||||
// Acceptable describes the minimum work required to be considered successfully understanding this puzzle's concepts
|
|
||||||
Acceptable string
|
Acceptable string
|
||||||
|
|
||||||
// Mastery describes the work required to be considered mastering this puzzle's conceptss
|
|
||||||
Mastery string
|
Mastery string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Answers lists all acceptable answers, omitted in mothballs
|
// Answers will be empty in a mothball
|
||||||
Answers []string
|
Answers []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,9 +63,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 := sha1.Sum([]byte(answer))
|
sum := sha256.Sum256([]byte(answer))
|
||||||
hexsum := fmt.Sprintf("%x", sum)
|
hexsum := fmt.Sprintf("%x", sum)
|
||||||
puzzle.AnswerHashes[i] = hexsum[:4]
|
puzzle.AnswerHashes[i] = hexsum
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,12 +23,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,175 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
240
theme/basic.css
240
theme/basic.css
|
@ -1,138 +1,132 @@
|
||||||
/* Color palette: http://paletton.com/#uid=33x0u0klrl-4ON9dhtKtAdqMQ4T */
|
/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */
|
||||||
|
|
||||||
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;
|
||||||
margin: 1em auto;
|
background: #282a33;
|
||||||
padding: 1px 3px;
|
color: #f6efdc;
|
||||||
border-radius: 5px;
|
|
||||||
background: #000d;
|
|
||||||
}
|
}
|
||||||
h1, h2, h3, h4, h5, h6 {
|
body.wide {
|
||||||
color: #cb2408cc;
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
a:any-link {
|
||||||
|
color: #8b969a;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
background: #cb240844;
|
background: #5e576b;
|
||||||
padding: 3px;
|
color: #9e98a8;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
input {
|
nav {
|
||||||
background-color: #ccc4;
|
border: solid black 2px;
|
||||||
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 {
|
||||||
margin: 0;
|
padding: 1em;
|
||||||
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;
|
||||||
}
|
}
|
||||||
.mothball {
|
iframe#body {
|
||||||
float: right;
|
border: inherit;
|
||||||
text-decoration: none;
|
width: 100%;
|
||||||
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 {
|
||||||
background-color: #800;
|
border-color: red;
|
||||||
|
}
|
||||||
|
#messages {
|
||||||
|
min-height: 3em;
|
||||||
|
border: solid black 2px;
|
||||||
|
}
|
||||||
|
#rankings {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#rankings span {
|
||||||
|
font-size: 75%;
|
||||||
|
display: inline-block;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 1.7em;
|
||||||
|
}
|
||||||
|
#rankings span.teamname {
|
||||||
|
font-size: inherit;
|
||||||
color: white;
|
color: white;
|
||||||
|
text-shadow: 0 0 3px black;
|
||||||
|
opacity: 0.8;
|
||||||
|
position: absolute;
|
||||||
|
right: 0.2em;
|
||||||
}
|
}
|
||||||
.answer_ok {
|
#rankings div * {white-space: nowrap;}
|
||||||
cursor: help;
|
.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;}
|
||||||
|
|
||||||
/** Development mode information */
|
|
||||||
.debug {
|
#devel {
|
||||||
overflow: auto;
|
background-color: #eee;
|
||||||
padding: 1em;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin: 2em auto;
|
|
||||||
background: #cccc;
|
|
||||||
color: black;
|
color: black;
|
||||||
|
overflow: scroll;
|
||||||
}
|
}
|
||||||
.debug dt {
|
#devel .string {
|
||||||
font-weight: bold;
|
color: #9c27b0;
|
||||||
|
}
|
||||||
|
#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;
|
||||||
|
@ -150,48 +144,6 @@ 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
BIN
theme/bg.png
Binary file not shown.
Before Width: | Height: | Size: 180 KiB |
|
@ -1,84 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
|
@ -1,44 +1,33 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html>
|
||||||
<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="index.mjs" type="module" async></script>
|
<script src="moth.js"></script>
|
||||||
<script src="background.mjs" type="module" async></script>
|
<link rel="manifest" href="manifest.json">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1 class="title" title="Monarch Of The Hill">MOTH</h1>
|
<h1 id="title">MOTH</h1>
|
||||||
<main>
|
<section>
|
||||||
<div class="messages notification">
|
<div id="messages">
|
||||||
|
<div id="notices"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="login">
|
<form id="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 class="puzzles"></div>
|
<div id="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" target="_blank">Scoreboard</a></li>
|
<li><a href="scoreboard.html">Scoreboard</a></li>
|
||||||
<li><button class="logout">Sign Out</button></li>
|
<li><a href="logout.html">Sign Out</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</body>
|
</body>
|
||||||
|
|
175
theme/index.mjs
175
theme/index.mjs
|
@ -1,175 +0,0 @@
|
||||||
/**
|
|
||||||
* 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)
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
<!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>
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "Monarch of the Hill",
|
||||||
|
"short_name": "MOTH",
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#282a33",
|
||||||
|
"theme_color": "#ECB",
|
||||||
|
"description": "The MOTH CTF engine"
|
||||||
|
}
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,196 @@
|
||||||
|
// 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
681
theme/moth.mjs
|
@ -1,681 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
}
|
|
|
@ -1,34 +1,37 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html>
|
||||||
<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">
|
||||||
<link rel="icon" href="luna-moth.svg">
|
<script src="puzzle.js"></script>
|
||||||
<link rel="stylesheet" href="basic.css">
|
<script>
|
||||||
<script src="background.mjs" type="module" async></script>
|
|
||||||
<script src="puzzle.mjs" type="module" async></script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1 id="title">[loading]</h1>
|
<h1>Puzzle</h1>
|
||||||
<main>
|
<section>
|
||||||
<section id="puzzle">
|
<div id="puzzle"><span class="spinner"></span></div>
|
||||||
<p class="notification">
|
|
||||||
Starting script...
|
|
||||||
</p>
|
|
||||||
</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"></span></p>
|
||||||
</section>
|
</section>
|
||||||
<form class="answer">
|
<div id="messages"></div>
|
||||||
<label for="answer">Answer:</label>
|
<form>
|
||||||
<input type="text" name="answer" id="answer"> <span class="answer_ok"></span>
|
<input type="hidden" name="cat">
|
||||||
<br>
|
<input type="hidden" name="points">
|
||||||
|
<input type="hidden" name="xAnswer">
|
||||||
|
Team ID: <input type="text" name="id"> <br>
|
||||||
|
Answer: <input type="text" name="answer" id="answer"> <span id="answer_ok"></span><br>
|
||||||
<input type="submit" value="Submit">
|
<input type="submit" value="Submit">
|
||||||
</form>
|
</form>
|
||||||
</main>
|
<div id="devel"></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>
|
||||||
|
|
|
@ -0,0 +1,225 @@
|
||||||
|
// 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
239
theme/puzzle.mjs
|
@ -1,239 +0,0 @@
|
||||||
/**
|
|
||||||
* 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)
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,55 +0,0 @@
|
||||||
<!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>
|
|
|
@ -1,140 +0,0 @@
|
||||||
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)
|
|
|
@ -1,84 +0,0 @@
|
||||||
/* 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;}
|
|
|
@ -3,15 +3,22 @@
|
||||||
<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="https://cdn.jsdelivr.net/npm/luxon@1.26.0"></script>
|
<script src="moment.min.js" async></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.0.2"></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/chartjs-adapter-luxon@0.2.1"></script>
|
<script src="scoreboard.js" async></script>
|
||||||
<script type="module" src="scoreboard.mjs"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="wide">
|
||||||
|
<h4 id="location"></h4>
|
||||||
|
<section class="rotate">
|
||||||
|
<div id="chart"></div>
|
||||||
<div id="rankings"></div>
|
<div id="rankings"></div>
|
||||||
<div class="location"></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>
|
||||||
|
|
|
@ -0,0 +1,251 @@
|
||||||
|
// 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()
|
||||||
|
}
|
|
@ -1,120 +0,0 @@
|
||||||
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)
|
|
|
@ -1,29 +1,45 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html>
|
||||||
<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="token.mjs" type="module" async></script>
|
<script src="puzzle.js"></script>
|
||||||
|
<script>
|
||||||
|
function tokenInput(e) {
|
||||||
|
let vals = e.target.value.split(":")
|
||||||
|
document.querySelector("input[name=cat]").value = vals[0]
|
||||||
|
document.querySelector("input[name=points]").value = vals[1]
|
||||||
|
document.querySelector("input[name=answer]").value = vals[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenInit() {
|
||||||
|
document.querySelector("input[name=token]").addEventListener("input", tokenInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", tokenInit)
|
||||||
|
} else {
|
||||||
|
tokenInit()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Redeem Token</h1>
|
<h1>Redeem Token</h1>
|
||||||
<main>
|
<div id="messages"></div>
|
||||||
<p>
|
<form id="tokenForm">
|
||||||
Have you found a token?
|
<input type="hidden" name="cat">
|
||||||
</p>
|
<input type="hidden" name="points">
|
||||||
<p></p>
|
<input type="hidden" name="answer">
|
||||||
Tokens look like
|
Team ID: <input type="text" name="id"> <br>
|
||||||
<code>category:5:xylep-radar-nanox</code>
|
Token: <input type="text" name="token"> <br>
|
||||||
<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>
|
||||||
<div class="toasts"></div>
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><a href="puzzle-list.html">Puzzles</a></li>
|
||||||
|
<li><a href="scoreboard.html">Scoreboard</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -1,48 +0,0 @@
|
||||||
/**
|
|
||||||
* 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