Compare commits

...

37 Commits

Author SHA1 Message Date
Neale Pickett 077dc261e4 Merge branch 'libmoth' into 'main'
New theme

Closes #190

See merge request devs/moth!181
2023-09-29 00:17:52 +00:00
Neale Pickett 0abb44c48c Actually implement login, LOL 2023-09-28 18:16:18 -06:00
Neale Pickett 6ff379e0f4 try to prevent future bad decisions 2023-09-28 12:59:51 -06:00
Neale Pickett eb786ba184 More scoreboard configurables 2023-09-28 12:42:25 -06:00
Neale Pickett 3d8c47d316 Integrate Ken's "monarch of the category" 2023-09-27 18:17:11 -06:00
Neale Pickett 5dfcb6324f Merge branch 'github/fork/knewbetter/scoreboard-js-dependency-loading' into 'libmoth'
Added responsive design elements and separated the scores from the te…

See merge request devs/moth!170
2023-09-28 00:01:06 +00:00
Neale Pickett 9071631353 more cleanup 2023-09-27 17:58:29 -06:00
Neale Pickett 43aec24d63 more cleanup 2023-09-27 17:57:30 -06:00
Neale Pickett b863955fdc Fully integrated 2023-09-27 17:56:40 -06:00
Neale Pickett b293a9f0e9 Add merged scoreboard.css 2023-09-27 17:15:51 -06:00
Neale Pickett 34e51848be Merge branch 'libmoth' into github/fork/knewbetter/scoreboard-js-dependency-loading 2023-09-27 17:15:37 -06:00
Neale Pickett 1ca2ec284f make the report card link to the report card 2023-09-27 16:16:04 -06:00
Neale Pickett 12979a55a3 We're not doing github builds any more 2023-09-27 16:14:23 -06:00
Neale Pickett 3282ad22b0 Scores, not Score 2023-09-27 16:10:31 -06:00
Neale Pickett 5350cf73a0 leadership sprint bugfixes
* Messages now in config.json
* puzzle.html: display errors
2023-09-19 16:48:24 -06:00
Neale Pickett 768600e48e Logout in devel mode generates a new TeamID 2023-09-15 16:13:09 -06:00
Neale Pickett bb4859e7a9 URL in scoreboard (configurable) 2023-09-15 16:09:08 -06:00
Neale Pickett d18de0fe8b working scoreboard 2023-09-15 15:17:07 -06:00
Neale Pickett f49eb3ed46 Change answer hash algorithm to SHA1₄ 2023-09-15 12:34:31 -06:00
Neale Pickett c72d13af32 Some twiddling to prepare for a scoreboard update 2023-09-14 19:08:44 -06:00
Neale Pickett c0761933a9 KSA report finished, config.json 2023-09-14 17:42:02 -06:00
Neale Pickett 4ce0dcf11a Stop accepting empty team ID in devel mode 2023-09-14 14:47:20 -06:00
Neale Pickett d87be0bfcb Color twiddling 2023-09-13 19:24:05 -06:00
Neale Pickett 13c17873d8 CSS twiddling 2023-09-13 19:10:25 -06:00
Neale Pickett 9ea39363b8 Mostly using new library, except scoreboard 2023-09-13 18:52:52 -06:00
Neale Pickett 0831c4e3d5 Just some twiddling 2023-09-12 19:30:53 -06:00
Neale Pickett 175b7aaa1b CoS hover cursor fix 2023-09-12 17:32:34 -06:00
Neale Pickett a82851fee3 Lots more (circle of success!) 2023-09-12 17:30:36 -06:00
Neale Pickett b135069851 Clean up animation code, begin work on login 2023-09-11 17:29:14 -06:00
Neale Pickett 18c5f044cc stub submit event 2023-09-08 18:11:36 -06:00
Neale Pickett 551afe04a5 Puzzle start using new lib +bg animation 2023-09-08 18:05:51 -06:00
Neale Pickett a896788cc5 Also list KSAs by Category 2023-09-08 11:31:41 -06:00
Neale Pickett 8ff91e79ec Refer to server docs for Puzzle fields 2023-09-07 17:29:21 -06:00
Neale Pickett 47671b9a12 jsdoc fixes (maybe?) 2023-09-07 16:32:06 -06:00
Neale Pickett 99d7245c49 Full moth.mjs, and an example to use it 2023-09-07 16:16:46 -06:00
Neale Pickett fcfa11b012 Initial work on #190 2023-09-01 17:59:09 -06:00
Ken Knudsen f7945fcf3b Added responsive design elements and separated the scores from the team names to reduce overlap. Use side-by-side view on large screens. 2021-08-13 00:22:15 +00:00
33 changed files with 50501 additions and 910 deletions

View File

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

View File

@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
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
### Changed
- Added a performance optimization for events with a large number of teams

View File

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

View File

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

View File

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

View File

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

73
docs/scoring.md Normal file
View File

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

View File

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

View File

@ -23,6 +23,12 @@ func TestPuzzle(t *testing.T) {
if (len(p.Answers) == 0) || (p.Answers[0] != "YAML answer") {
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") {
t.Error("Authors are wrong", p.Authors)
}

175
theme/background.mjs Normal file
View File

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

View File

@ -1,132 +1,138 @@
/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */
/* Color palette: http://paletton.com/#uid=33x0u0klrl-4ON9dhtKtAdqMQ4T */
body {
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;
background: #282a33;
color: #f6efdc;
margin: 1em auto;
padding: 1px 3px;
border-radius: 5px;
background: #000d;
}
body.wide {
max-width: 100%;
}
a:any-link {
color: #8b969a;
h1, h2, h3, h4, h5, h6 {
color: #cb2408cc;
}
h1 {
background: #5e576b;
color: #9e98a8;
}
.Fail, .Error, #messages {
background: #3a3119;
color: #ffcc98;
}
.Fail:before {
content: "Fail: ";
}
.Error:before {
content: "Error: ";
background: #cb240844;
padding: 3px;
}
p {
margin: 1em 0em;
}
a:any-link {
color: #b9cbd8;
}
form, pre {
margin: 1em;
overflow-x: auto;
}
input, select {
padding: 0.6em;
margin: 0.2em;
max-width: 30em;
}
nav {
border: solid black 2px;
input {
background-color: #ccc4;
color: inherit;
}
input:hover {
background-color: #8884;
}
input:active {
background-color: inherit;
}
.notification, .error {
padding: 0 1em;
border-radius: 8px;
}
.notification {
background: #ac8f3944;
}
.error {
background: red;
color: white;
}
.hidden {
display: none;
}
/** Puzzles list */
.category {
margin: 5px 0;
background: #ccc4;
}
.category h2 {
margin: 0 0.2em;
}
.category .solved {
text-decoration: line-through;
}
nav ul, .category ul {
padding: 1em;
margin: 0;
padding: 0.2em 1em;
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
}
nav li, .category li {
display: inline;
margin: 1em;
}
iframe#body {
border: inherit;
width: 100%;
.mothball {
float: right;
text-decoration: none;
border-radius: 5px;
background: #ccc;
padding: 4px 8px;
margin: 5px;
}
img {
/** Puzzle content */
#puzzle {
border-bottom: solid;
padding: 0 0.5em;
}
#puzzle img {
max-width: 100%;
}
input:invalid {
border-color: red;
background-color: #800;
color: white;
}
#messages {
min-height: 3em;
border: solid black 2px;
}
#rankings {
width: 100%;
position: relative;
.answer_ok {
cursor: help;
}
#rankings span {
font-size: 75%;
display: inline-block;
overflow: hidden;
height: 1.7em;
}
#rankings span.teamname {
font-size: inherit;
color: white;
text-shadow: 0 0 3px black;
opacity: 0.8;
position: absolute;
right: 0.2em;
}
#rankings div * {white-space: nowrap;}
.cat0, .cat8, .cat16 {background-color: #a6cee3; color: black;}
.cat1, .cat9, .cat17 {background-color: #1f78b4; color: white;}
.cat2, .cat10, .cat18 {background-color: #b2df8a; color: black;}
.cat3, .cat11, .cat19 {background-color: #33a02c; color: white;}
.cat4, .cat12, .cat20 {background-color: #fb9a99; color: black;}
.cat5, .cat13, .cat21 {background-color: #e31a1c; color: white;}
.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;}
.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;}
#devel {
background-color: #eee;
/** Development mode information */
.debug {
overflow: auto;
padding: 1em;
border-radius: 10px;
margin: 2em auto;
background: #cccc;
color: black;
overflow: scroll;
}
#devel .string {
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);
}
.debug dt {
font-weight: bold;
}
/** Draggable items, from the draggable plugin */
li[draggable]::before {
content: "↕";
padding: 0.5em;
@ -144,6 +150,48 @@ li[draggable] {
border: 1px white dashed;
}
#cacheButton.disabled {
display: none;
/** Toasts are little pop-up informational messages. */
.toasts {
position: fixed;
z-index: 100;
bottom: 10px;
left: 10px;
text-align: center;
width: calc(100% - 20px);
display: flex;
flex-direction: column;
}
.toast {
border-radius: 0.5em;
padding: 0.2em 2em;
animation: fadeIn ease 1s;
margin: 2px auto;
background: #333;
color: #eee;
box-shadow: 0px 0px 8px 0px #0b0;
}
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}
@media (prefers-color-scheme: light) {
/* We uses the alpha channel to apply hue tinting to elements, to get a
* similar effect in light or dark mode. That means there aren't a whole lot of
* things to change between light and dark mode.
*/
body {
background-color: #b9cbd8;
color: black;
}
main {
background-color: #fffd;
}
a:any-link {
color: #092b45;
}
}

BIN
theme/bg.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

84
theme/common.mjs Normal file
View File

@ -0,0 +1,84 @@
/**
* Common functionality
*/
const Millisecond = 1
const Second = Millisecond * 1000
const Minute = Second * 60
/** URL to the top of this MOTH server */
const BaseURL = new URL(".", location)
/**
* Display a transient message to the user.
*
* @param {String} message Message to display
* @param {Number} timeout How long before removing this message
*/
function Toast(message, timeout=5*Second) {
console.info(message)
for (let toasts of document.querySelectorAll(".toasts")) {
let p = toasts.appendChild(document.createElement("p"))
p.classList.add("toast")
p.textContent = message
setTimeout(() => p.remove(), timeout)
}
}
/**
* Run a function when the DOM has been loaded.
*
* @param {function():void} cb Callback function
*/
function WhenDOMLoaded(cb) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", cb)
} else {
cb()
}
}
/**
* Interprets a String as a Boolean.
*
* Values like "no" or "disabled" to mean false here.
*
* @param {String} s
* @returns {Boolean}
*/
function Truthy(s) {
switch (s.toLowerCase()) {
case "disabled":
case "no":
case "off":
case "false":
return false
}
return true
}
/**
* Fetch the configuration object for this theme.
*
* @returns {Promise.<Object>}
*/
async function Config() {
let resp = await fetch(
new URL("config.json", BaseURL),
{
cache: "no-cache"
},
)
return resp.json()
}
export {
Millisecond,
Second,
Minute,
BaseURL,
Toast,
WhenDOMLoaded,
Truthy,
Config,
}

13
theme/config.json Normal file
View File

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

View File

@ -1,33 +1,44 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>MOTH</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<link rel="icon" href="luna-moth.svg">
<link rel="stylesheet" href="basic.css">
<script src="moth.js"></script>
<link rel="manifest" href="manifest.json">
<script src="index.mjs" type="module" async></script>
<script src="background.mjs" type="module" async></script>
</head>
<body>
<h1 id="title">MOTH</h1>
<section>
<div id="messages">
<div id="notices"></div>
<h1 class="title" title="Monarch Of The Hill">MOTH</h1>
<main>
<div class="messages notification">
</div>
<form id="login">
<form class="login">
Team ID: <input name="id"> <br>
Team name: <input name="name"> <br>
<input type="submit" value="Sign In">
</form>
<div id="puzzles"></div>
<div class="puzzles"></div>
</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>
<ul>
<li><a href="scoreboard.html">Scoreboard</a></li>
<li><a href="logout.html">Sign Out</a></li>
<li><a href="scoreboard.html" target="_blank">Scoreboard</a></li>
<li><button class="logout">Sign Out</button></li>
</ul>
</nav>
</body>

175
theme/index.mjs Normal file
View File

@ -0,0 +1,175 @@
/**
* Functionality for index.html (Login / Puzzles list)
*/
import * as moth from "./moth.mjs"
import * as common from "./common.mjs"
class App {
constructor(basePath=".") {
this.config = {}
this.server = new moth.Server(basePath)
for (let form of document.querySelectorAll("form.login")) {
form.addEventListener("submit", event => this.handleLoginSubmit(event))
}
for (let e of document.querySelectorAll(".logout")) {
e.addEventListener("click", () => this.Logout())
}
setInterval(() => this.UpdateState(), common.Minute/3)
setInterval(() => this.UpdateConfig(), common.Minute* 5)
this.UpdateConfig()
.finally(() => this.UpdateState())
}
handleLoginSubmit(event) {
event.preventDefault()
let f = new FormData(event.target)
this.Login(f.get("id"), f.get("name"))
}
/**
* Attempt to log in to the server.
*
* @param {string} teamID
* @param {string} teamName
*/
async Login(teamID, teamName) {
try {
await this.server.Login(teamID, teamName)
common.Toast(`Logged in (team id = ${teamID})`)
this.UpdateState()
}
catch (error) {
common.Toast(error)
}
}
/**
* Log out of the server by clearing the saved Team ID.
*/
async Logout() {
try {
this.server.Reset()
common.Toast("Logged out")
this.UpdateState()
}
catch (error) {
common.Toast(error)
}
}
/**
* Update app configuration.
*
* Configuration can be updated less frequently than state, to reduce server
* load, since configuration should (hopefully) change less frequently.
*/
async UpdateConfig() {
this.config = await common.Config()
for (let e of document.querySelectorAll(".messages")) {
e.innerHTML = this.config.Messages || ""
}
}
/**
* Update the entire page.
*
* Fetch a new state, and rebuild all dynamic elements on this bage based on
* what's returned. If we're in development mode and not logged in, auto
* login too.
*/
async UpdateState() {
this.state = await this.server.GetState()
// Update elements with data-track-solved
for (let e of document.querySelectorAll("[data-track-solved]")) {
// Only display if data-track-solved is the same as config.trackSolved
e.classList.toggle("hidden", common.Truthy(e.dataset.trackSolved) != this.config.TrackSolved)
}
for (let e of document.querySelectorAll(".login")) {
this.renderLogin(e, !this.server.LoggedIn())
}
for (let e of document.querySelectorAll(".puzzles")) {
this.renderPuzzles(e, this.server.LoggedIn())
}
if (this.state.DevelopmentMode() && !this.server.LoggedIn()) {
let teamID = Math.floor(Math.random() * 1000000).toString(16)
common.Toast("Automatically logging in to devel server")
console.info(`Logging in with generated Team ID: ${teamID}`)
return this.Login(teamID, `Team ${teamID}`)
}
}
/**
* Render a login box.
*
* Just toggles visibility, there's nothing dynamic in a login box.
*/
renderLogin(element, visible) {
element.classList.toggle("hidden", !visible)
}
/**
* Render a puzzles box.
*
* Displays the list of open puzzles, and adds mothball download links
* if the server is in development mode.
*/
renderPuzzles(element, visible) {
element.classList.toggle("hidden", !visible)
while (element.firstChild) element.firstChild.remove()
for (let cat of this.state.Categories()) {
let pdiv = element.appendChild(document.createElement("div"))
pdiv.classList.add("category")
let h = pdiv.appendChild(document.createElement("h2"))
h.textContent = cat
// Extras if we're running a devel server
if (this.state.DevelopmentMode()) {
let a = h.appendChild(document.createElement('a'))
a.classList.add("mothball")
a.textContent = "⬇️"
a.href = this.server.URL(`mothballer/${cat}.mb`)
a.title = "Download a compiled puzzle for this category"
}
// List out puzzles in this category
let l = pdiv.appendChild(document.createElement("ul"))
for (let puzzle of this.state.Puzzles(cat)) {
let i = l.appendChild(document.createElement("li"))
let url = new URL("puzzle.html", common.BaseURL)
url.hash = `${puzzle.Category}:${puzzle.Points}`
let a = i.appendChild(document.createElement("a"))
a.textContent = puzzle.Points
a.href = url
a.target = "_blank"
if (this.config.TrackSolved) {
a.classList.toggle("solved", this.state.IsSolved(puzzle))
}
}
if (!this.state.ContainsUnsolved(cat)) {
l.appendChild(document.createElement("li")).textContent = "✿"
}
element.appendChild(pdiv)
}
}
}
function init() {
window.app = {
server: new App()
}
}
common.WhenDOMLoaded(init)

View File

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

View File

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

1
theme/moment.min.js vendored

File diff suppressed because one or more lines are too long

View File

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

681
theme/moth.mjs Normal file
View File

@ -0,0 +1,681 @@
/**
* Hash/digest functions
*/
class Hash {
/**
* Dan Bernstein hash
*
* Used until MOTH v3.5
*
* @param {string} buf Input
* @returns {number}
*/
static djb2(buf) {
let h = 5381
for (let c of (new TextEncoder()).encode(buf)) { // Encode as UTF-8 and read in each byte
// JavaScript converts everything to a signed 32-bit integer when you do bitwise operations.
// So we have to do "unsigned right shift" by zero to get it back to unsigned.
h = ((h * 33) + c) >>> 0
}
return h
}
/**
* Dan Bernstein hash with xor
*
* @param {string} buf Input
* @returns {number}
*/
static djb2xor(buf) {
let h = 5381
for (let c of (new TextEncoder()).encode(buf)) {
h = ((h * 33) ^ c) >>> 0
}
return h
}
/**
* SHA 256
*
* Used until MOTH v4.5
*