random: will use a different seed every time you load a page (could be useful for debugging)
+
You can also hack your own seed into the URL, if you want to.
+
+
- If you are chaotic,
- you could even take your chances with a
- random seed for every HTTP request.
- This means generated files will get a different seed than the puzzle itself!
+ Puzzles can be generated from Python code: these puzzles can use a random number generator if they want.
+ The seed is used to create these random numbers.
+
+
+
+ We like to make a new seed for every contest,
+ and re-use that seed whenever we regenerate a category during an event
+ (say to fix a bug).
+ By using the same seed,
+ we make sure that all the dynamically-generated puzzles have the same values
+ in any new packages we build.
@@ -140,12 +187,8 @@ async def handle_static(request):
themes = request.app["theme_dir"]
fn = request.match_info.get("filename")
if not fn:
- for fn in ("puzzle-list.html", "index.html"):
- path = themes.joinpath(fn)
- if path.exists():
- break
- else:
- path = themes.joinpath(fn)
+ fn = "index.html"
+ path = themes.joinpath(fn)
return web.FileResponse(path)
@@ -182,9 +225,10 @@ if __name__ == '__main__':
app["puzzles_dir"] = pathlib.Path(args.puzzles)
app["theme_dir"] = pathlib.Path(args.theme)
app.router.add_route("GET", "/", handle_index)
- app.router.add_route("GET", "/{seed}/puzzles.json", handle_puzzlelist)
- app.router.add_route("GET", "/{seed}/content/{category}/{points}/puzzle.json", handle_puzzle)
- app.router.add_route("GET", "/{seed}/content/{category}/{points}/{filename}", handle_puzzlefile)
- app.router.add_route("GET", "/{seed}/mothballer/{category}", handle_mothballer)
+ app.router.add_route("*", "/{seed}/answer", handle_answer)
+ app.router.add_route("*", "/{seed}/puzzles.json", handle_puzzlelist)
+ app.router.add_route("GET", "/{seed}/content/{cat}/{points}/puzzle.json", handle_puzzle)
+ app.router.add_route("GET", "/{seed}/content/{cat}/{points}/{filename}", handle_puzzlefile)
+ app.router.add_route("GET", "/{seed}/mothballer/{cat}", handle_mothballer)
app.router.add_route("GET", "/{seed}/{filename:.*}", handle_static)
web.run_app(app, host=addr, port=port)
diff --git a/devel/moth.py b/devel/moth.py
index 9820783..6a62116 100644
--- a/devel/moth.py
+++ b/devel/moth.py
@@ -15,9 +15,9 @@ import tempfile
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
-def djb2hash(buf):
+def djb2hash(str):
h = 5381
- for c in buf:
+ for c in str.encode("utf-8"):
h = ((h * 33) + c) & 0xffffffff
return h
@@ -75,6 +75,7 @@ class Puzzle:
self.authors = []
self.answers = []
self.scripts = []
+ self.pattern = None
self.hint = None
self.files = {}
self.body = io.StringIO()
@@ -104,6 +105,8 @@ class Puzzle:
self.summary = val
elif key == 'answer':
self.answers.append(val)
+ elif key == 'pattern':
+ self.pattern = val
elif key == 'hint':
self.hint = val
elif key == 'name':
@@ -271,13 +274,14 @@ class Puzzle:
'hashes': self.hashes(),
'files': files,
'scripts': self.scripts,
+ 'pattern': self.pattern,
'body': self.html_body(),
}
def hashes(self):
"Return a list of answer hashes"
- return [djb2hash(a.encode('utf-8')) for a in self.answers]
+ return [djb2hash(a) for a in self.answers]
class Category:
diff --git a/example-puzzles/example/5/helpers.js b/example-puzzles/example/5/helpers.js
new file mode 100644
index 0000000..29ce813
--- /dev/null
+++ b/example-puzzles/example/5/helpers.js
@@ -0,0 +1,46 @@
+function helperUpdateAnswer(event) {
+ let e = event.currentTarget
+ let value = e.value
+ let inputs = e.querySelectorAll("input")
+
+ if (inputs.length > 0) {
+ // If there are child input nodes, join their values with commas
+ let values = []
+ for (let c of inputs) {
+ if (c.type == "checkbox") {
+ if (c.checked) {
+ values.push(c.value)
+ }
+ } else {
+ values.push(c.value)
+ }
+ }
+ value = values.join(",")
+ }
+
+ // First make any adjustments to the value
+ if (e.classList.contains("lower")) {
+ value = value.toLowerCase()
+ }
+ if (e.classList.contains("upper")) {
+ value = value.toUpperCase()
+ }
+
+ document.querySelector("#answer").value = value
+}
+
+function helperActivate(e) {
+ e.addEventListener("input", helperUpdateAnswer)
+}
+
+function helperInit(event) {
+ for (let e of document.querySelectorAll(".answer")) {
+ helperActivate(e)
+ }
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", helperInit);
+} else {
+ helperInit();
+}
diff --git a/example-puzzles/example/5/puzzle.moth b/example-puzzles/example/5/puzzle.moth
new file mode 100644
index 0000000..9c3be6b
--- /dev/null
+++ b/example-puzzles/example/5/puzzle.moth
@@ -0,0 +1,39 @@
+Summary: Using JavaScript Input Helpers
+Author: neale
+Script: helpers.js
+Answer: helper
+
+MOTH only takes static answers:
+you can't, for instance, write code to check answer correctness.
+But you can provide as many correct answers as you like in a single puzzle.
+
+This page has an associated `helpers.js` script
+you can include to assist with input formatting,
+so people aren't confused about how to enter an answer.
+
+You could also write your own JavaScript to validate things.
+
+This is just a demonstration page.
+You will probably only want one of these in a page,
+to avoid confusing people.
+
+Timestamp
+
+
+All lower-case letters
+
+
+Multiple concatenated values
+
+
+
+
+
+
+
+Select from an ordered list of options
+
+
Horns
+
Hooves
+
Antlers
+
diff --git a/src/handlers.go b/src/handlers.go
index 3d3984b..d4674d7 100644
--- a/src/handlers.go
+++ b/src/handlers.go
@@ -12,33 +12,26 @@ import (
"strings"
)
+// https://github.com/omniti-labs/jsend
type JSend struct {
- Status string `json:"status"`
- Data JSendData `json:"data"`
-}
-type JSendData struct {
- Short string `json:"short"`
- Description string `json:"description"`
+ Status string `json:"status"`
+ Data struct {
+ Short string `json:"short"`
+ Description string `json:"description"`
+ } `json:"data"`
}
-// ShowJSend renders a JSend response to w
-func ShowJSend(w http.ResponseWriter, status Status, short string, description string) {
+const (
+ JSendSuccess = "success"
+ JSendFail = "fail"
+ JSendError = "error"
+)
- resp := JSend{
- Status: "success",
- Data: JSendData{
- Short: short,
- Description: description,
- },
- }
- switch status {
- case Success:
- resp.Status = "success"
- case Fail:
- resp.Status = "fail"
- default:
- resp.Status = "error"
- }
+func respond(w http.ResponseWriter, req *http.Request, status string, short string, format string, a ...interface{}) {
+ resp := JSend{}
+ resp.Status = status
+ resp.Data.Short = short
+ resp.Data.Description = fmt.Sprintf(format, a...)
respBytes, err := json.Marshal(resp)
if err != nil {
@@ -51,59 +44,6 @@ func ShowJSend(w http.ResponseWriter, status Status, short string, description s
w.Write(respBytes)
}
-type Status int
-
-const (
- Success = iota
- Fail
- Error
-)
-
-// ShowHtml delevers an HTML response to w
-func ShowHtml(w http.ResponseWriter, status Status, title string, body string) {
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.WriteHeader(http.StatusOK)
-
- statusStr := ""
- switch status {
- case Success:
- statusStr = "Success"
- case Fail:
- statusStr = "Fail"
- default:
- statusStr = "Error"
- }
-
- fmt.Fprintf(w, "")
- fmt.Fprintf(w, "\n")
- fmt.Fprintf(w, "")
- fmt.Fprintf(w, "%s", title)
- fmt.Fprintf(w, "")
- fmt.Fprintf(w, "")
- fmt.Fprintf(w, "")
- fmt.Fprintf(w, "")
- fmt.Fprintf(w, "
%s
", statusStr, title)
- fmt.Fprintf(w, "%s", body)
- fmt.Fprintf(w, "")
- fmt.Fprintf(w, "")
-}
-
-func respond(w http.ResponseWriter, req *http.Request, status Status, short string, format string, a ...interface{}) {
- long := fmt.Sprintf(format, a...)
- // This is a kludge. Do proper parsing when this causes problems.
- accept := req.Header.Get("Accept")
- if strings.Contains(accept, "application/json") {
- ShowJSend(w, status, short, long)
- } else {
- ShowHtml(w, status, short, long)
- }
-}
-
// hasLine returns true if line appears in r.
// The entire line must match.
func hasLine(r io.Reader, line string) bool {
@@ -129,7 +69,7 @@ func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) {
if (teamid == "") || (teamname == "") {
respond(
- w, req, Fail,
+ w, req, JSendFail,
"Invalid Entry",
"Either `id` or `name` was missing from this request.",
)
@@ -139,7 +79,7 @@ func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) {
teamids, err := os.Open(ctx.StatePath("teamids.txt"))
if err != nil {
respond(
- w, req, Fail,
+ w, req, JSendFail,
"Cannot read valid team IDs",
"An error was encountered trying to read valid teams IDs: %v", err,
)
@@ -148,7 +88,7 @@ func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) {
defer teamids.Close()
if !hasLine(teamids, teamid) {
respond(
- w, req, Fail,
+ w, req, JSendFail,
"Invalid Team ID",
"I don't have a record of that team ID. Maybe you used capital letters accidentally?",
)
@@ -156,10 +96,17 @@ func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) {
}
f, err := os.OpenFile(ctx.StatePath("teams", teamid), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
- if err != nil {
+ if os.IsExist(err) {
+ respond(
+ w, req, JSendFail,
+ "Already registered",
+ "This team ID has already been registered.",
+ )
+ return
+ } else if err != nil {
log.Print(err)
respond(
- w, req, Fail,
+ w, req, JSendFail,
"Registration failed",
"Unable to register. Perhaps a teammate has already registered?",
)
@@ -168,7 +115,7 @@ func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) {
defer f.Close()
fmt.Fprintln(f, teamname)
respond(
- w, req, Success,
+ w, req, JSendSuccess,
"Team registered",
"Okay, your team has been named and you may begin using your team ID!",
)
@@ -183,7 +130,7 @@ func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
points, err := strconv.Atoi(pointstr)
if err != nil {
respond(
- w, req, Fail,
+ w, req, JSendFail,
"Cannot parse point value",
"This doesn't look like an integer: %s", pointstr,
)
@@ -193,7 +140,7 @@ func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
haystack, err := ctx.OpenCategoryFile(category, "answers.txt")
if err != nil {
respond(
- w, req, Fail,
+ w, req, JSendFail,
"Cannot list answers",
"Unable to read the list of answers for this category.",
)
@@ -205,7 +152,7 @@ func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
needle := fmt.Sprintf("%d %s", points, answer)
if !hasLine(haystack, needle) {
respond(
- w, req, Fail,
+ w, req, JSendFail,
"Wrong answer",
"That is not the correct answer for %s %d.", category, points,
)
@@ -214,20 +161,26 @@ func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) {
if err := ctx.AwardPoints(teamid, category, points); err != nil {
respond(
- w, req, Error,
+ w, req, JSendError,
"Cannot award points",
"The answer is correct, but there was an error awarding points: %v", err.Error(),
)
return
}
respond(
- w, req, Success,
+ w, req, JSendSuccess,
"Points awarded",
fmt.Sprintf("%d points for %s!", points, teamid),
)
}
func (ctx *Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) {
+ teamid := req.FormValue("id")
+ if _, err := ctx.TeamName(teamid); err != nil {
+ http.Error(w, "Unauthorized: must provide team ID", http.StatusUnauthorized)
+ return
+ }
+
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(ctx.jPuzzleList)
@@ -301,11 +254,46 @@ func (ctx *Instance) staticHandler(w http.ResponseWriter, req *http.Request) {
http.ServeContent(w, req, path, d.ModTime(), f)
}
-func (ctx *Instance) BindHandlers(mux *http.ServeMux) {
- mux.HandleFunc(ctx.Base+"/", ctx.staticHandler)
- mux.HandleFunc(ctx.Base+"/register", ctx.registerHandler)
- mux.HandleFunc(ctx.Base+"/answer", ctx.answerHandler)
- mux.HandleFunc(ctx.Base+"/content/", ctx.contentHandler)
- mux.HandleFunc(ctx.Base+"/puzzles.json", ctx.puzzlesHandler)
- mux.HandleFunc(ctx.Base+"/points.json", ctx.pointsHandler)
+type FurtiveResponseWriter struct {
+ w http.ResponseWriter
+ statusCode *int
+}
+
+func (w FurtiveResponseWriter) WriteHeader(statusCode int) {
+ *w.statusCode = statusCode
+ w.w.WriteHeader(statusCode)
+}
+
+func (w FurtiveResponseWriter) Write(buf []byte) (n int, err error) {
+ n, err = w.w.Write(buf)
+ return
+}
+
+func (w FurtiveResponseWriter) Header() http.Header {
+ return w.w.Header()
+}
+
+// This gives Instances the signature of http.Handler
+func (ctx *Instance) ServeHTTP(wOrig http.ResponseWriter, r *http.Request) {
+ w := FurtiveResponseWriter{
+ w: wOrig,
+ statusCode: new(int),
+ }
+ ctx.mux.ServeHTTP(w, r)
+ log.Printf(
+ "%s %s %s %d\n",
+ r.RemoteAddr,
+ r.Method,
+ r.URL,
+ *w.statusCode,
+ )
+}
+
+func (ctx *Instance) BindHandlers() {
+ ctx.mux.HandleFunc(ctx.Base+"/", ctx.staticHandler)
+ ctx.mux.HandleFunc(ctx.Base+"/register", ctx.registerHandler)
+ ctx.mux.HandleFunc(ctx.Base+"/answer", ctx.answerHandler)
+ ctx.mux.HandleFunc(ctx.Base+"/content/", ctx.contentHandler)
+ ctx.mux.HandleFunc(ctx.Base+"/puzzles.json", ctx.puzzlesHandler)
+ ctx.mux.HandleFunc(ctx.Base+"/points.json", ctx.pointsHandler)
}
diff --git a/src/instance.go b/src/instance.go
index 2794768..6f5dd95 100644
--- a/src/instance.go
+++ b/src/instance.go
@@ -6,6 +6,8 @@ import (
"io"
"io/ioutil"
"log"
+ "math/rand"
+ "net/http"
"os"
"path"
"strings"
@@ -21,6 +23,7 @@ type Instance struct {
update chan bool
jPuzzleList []byte
jPointsLog []byte
+ mux *http.ServeMux
}
func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, error) {
@@ -31,6 +34,7 @@ func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, e
ResourcesDir: resourcesDir,
Categories: map[string]*Mothball{},
update: make(chan bool, 10),
+ mux: http.NewServeMux(),
}
// Roll over and die if directories aren't even set up
@@ -41,11 +45,24 @@ func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, e
return nil, err
}
+ ctx.BindHandlers()
ctx.MaybeInitialize()
return ctx, nil
}
+// Stuff people with mediocre handwriting could write down unambiguously, and can be entered without holding down shift
+const distinguishableChars = "234678abcdefhijkmnpqrtwxyz="
+
+func mktoken() string {
+ a := make([]byte, 8)
+ for i := range a {
+ char := rand.Intn(len(distinguishableChars))
+ a[i] = distinguishableChars[char]
+ }
+ return string(a)
+}
+
func (ctx *Instance) MaybeInitialize() {
// Only do this if it hasn't already been done
if _, err := os.Stat(ctx.StatePath("initialized")); err == nil {
@@ -69,8 +86,8 @@ func (ctx *Instance) MaybeInitialize() {
// Preseed available team ids if file doesn't exist
if f, err := os.OpenFile(ctx.StatePath("teamids.txt"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil {
defer f.Close()
- for i := 0; i <= 9999; i += 1 {
- fmt.Fprintf(f, "%04d\n", i)
+ for i := 0; i <= 100; i += 1 {
+ fmt.Fprintln(f, mktoken())
}
}
@@ -83,18 +100,31 @@ func (ctx *Instance) MaybeInitialize() {
fmt.Fprintln(f, "Remove this file to reinitialize the contest")
}
+func pathCleanse(parts []string) string {
+ clean := make([]string, len(parts))
+ for i := range parts {
+ part := parts[i]
+ part = strings.TrimLeft(part, ".")
+ if p := strings.LastIndex(part, "/"); p >= 0 {
+ part = part[p+1:]
+ }
+ clean[i] = part
+ }
+ return path.Join(clean...)
+}
+
func (ctx Instance) MothballPath(parts ...string) string {
- tail := path.Join(parts...)
+ tail := pathCleanse(parts)
return path.Join(ctx.MothballDir, tail)
}
func (ctx *Instance) StatePath(parts ...string) string {
- tail := path.Join(parts...)
+ tail := pathCleanse(parts)
return path.Join(ctx.StateDir, tail)
}
func (ctx *Instance) ResourcePath(parts ...string) string {
- tail := path.Join(parts...)
+ tail := pathCleanse(parts)
return path.Join(ctx.ResourcesDir, tail)
}
diff --git a/src/mothd.go b/src/mothd.go
index 5834f24..ef08832 100644
--- a/src/mothd.go
+++ b/src/mothd.go
@@ -1,21 +1,16 @@
package main
import (
- "flag"
+ "github.com/namsral/flag"
"log"
+ "math/rand"
"mime"
"net/http"
"time"
)
-func logRequest(handler http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- log.Printf("HTTP %s %s %s\n", r.RemoteAddr, r.Method, r.URL)
- handler.ServeHTTP(w, r)
- })
-}
-
func setup() error {
+ rand.Seed(time.Now().UnixNano())
return nil
}
@@ -60,7 +55,6 @@ func main() {
if err != nil {
log.Fatal(err)
}
- ctx.BindHandlers(http.DefaultServeMux)
// Add some MIME extensions
// Doing this avoids decompressing a mothball entry twice per request
@@ -70,5 +64,5 @@ func main() {
go ctx.Maintenance(*maintenanceInterval)
log.Printf("Listening on %s", *listen)
- log.Fatal(http.ListenAndServe(*listen, logRequest(http.DefaultServeMux)))
+ log.Fatal(http.ListenAndServe(*listen, ctx))
}
diff --git a/theme/basic.css b/theme/basic.css
index 2ee7b34..9614028 100644
--- a/theme/basic.css
+++ b/theme/basic.css
@@ -12,7 +12,7 @@ h1 {
background: #5e576b;
color: #9e98a8;
}
-.Fail, .Error {
+.Fail, .Error, #messages {
background: #3a3119;
color: #ffcc98;
}
@@ -50,6 +50,13 @@ iframe#body {
img {
max-width: 100%;
}
+input:invalid {
+ border-color: red;
+}
+#messages {
+ min-height: 3em;
+ border: solid black 2px;
+}
#scoreboard {
width: 100%;
position: relative;
@@ -86,4 +93,26 @@ img {
}
.kvpair {
border: solid black 2px;
-}
\ No newline at end of file
+}
+
+.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);
+ }
+}
diff --git a/theme/devel.js b/theme/devel.js
deleted file mode 100644
index 6abe921..0000000
--- a/theme/devel.js
+++ /dev/null
@@ -1,47 +0,0 @@
-// Devel server addons
-
-// devel_addin drops a bunch of development extensions into element e.
-// It will only modify stuff inside e.
-function devel_addin(obj, e) {
- let h = document.createElement("h2");
- e.appendChild(h);
- h.textContent = "Development Options";
-
- let g = document.createElement("p");
- e.appendChild(g);
- g.innerText = "This section will not appear for participants."
-
- let keys = Object.keys(obj);
- keys.sort();
- for (let key of keys) {
- switch (key) {
- case "body":
- continue;
- }
-
- let d = document.createElement("div");
- e.appendChild(d);
- d.classList.add("kvpair");
-
- let ktxt = document.createElement("span");
- d.appendChild(ktxt);
- ktxt.textContent = key;
-
- let val = obj[key];
- if (Array.isArray(val)) {
- let vi = document.createElement("select");
- d.appendChild(vi);
- vi.multiple = true;
- for (let a of val) {
- let opt = document.createElement("option");
- vi.appendChild(opt);
- opt.innerText = a;
- }
- } else {
- let vi = document.createElement("input");
- d.appendChild(vi);
- vi.value = val;
- vi.disabled = true;
- }
- }
-}
\ No newline at end of file
diff --git a/theme/index.html b/theme/index.html
index 51ad704..89bdf48 100644
--- a/theme/index.html
+++ b/theme/index.html
@@ -1,31 +1,30 @@
- Welcome
-
+ MOTH
+
+
+
-
Welcome
+
MOTH
-
Register your team
-
-
-
-
- If someone on your team has already registered,
- proceed to the
- puzzles overview.
-
-
diff --git a/theme/puzzle.js b/theme/puzzle.js
new file mode 100644
index 0000000..eafda60
--- /dev/null
+++ b/theme/puzzle.js
@@ -0,0 +1,219 @@
+// devel_addin drops a bunch of development extensions into element e.
+// It will only modify stuff inside e.
+function devel_addin(obj, e) {
+ let h = document.createElement("h2")
+ e.appendChild(h)
+ h.textContent = "Development Options"
+
+ let g = document.createElement("p")
+ e.appendChild(g)
+ g.innerText = "This section will not appear for participants."
+
+ let keys = Object.keys(obj)
+ keys.sort()
+ for (let key of keys) {
+ switch (key) {
+ case "body":
+ continue
+ }
+
+ let d = document.createElement("div")
+ e.appendChild(d)
+ d.classList.add("kvpair")
+
+ let ktxt = document.createElement("span")
+ d.appendChild(ktxt)
+ ktxt.textContent = key
+
+ let val = obj[key]
+ if (Array.isArray(val)) {
+ let vi = document.createElement("select")
+ d.appendChild(vi)
+ vi.multiple = true
+ for (let a of val) {
+ let opt = document.createElement("option")
+ vi.appendChild(opt)
+ opt.innerText = a
+ }
+ } else {
+ let vi = document.createElement("input")
+ d.appendChild(vi)
+ vi.value = val
+ vi.disabled = true
+ }
+ }
+}
+
+
+
+// The routine used to hash answers in compiled puzzle packages
+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
+}
+
+
+// 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()
+ fetch("answer", {
+ method: "POST",
+ body: new FormData(e.target),
+ })
+ .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)
+ })
+}
+
+function loadPuzzle(categoryName, points, puzzleId) {
+ let puzzle = document.getElementById("puzzle")
+ let base = "content/" + categoryName + "/" + puzzleId + "/"
+
+ fetch(base + "puzzle.json")
+ .then(resp => {
+ return resp.json()
+ })
+ .then(obj => {
+ // Populate authors
+ document.getElementById("authors").textContent = obj.authors.join(", ")
+
+ // Make the whole puzzle available
+ window.puzzle = obj
+
+ // If answers are provided, this is the devel server
+ if (obj.answers) {
+ devel_addin(obj, document.getElementById("devel"))
+ }
+
+ // Load scripts
+ for (let script of obj.scripts) {
+ let st = document.createElement("script")
+ document.head.appendChild(st)
+ st.src = base + script
+ }
+
+ // List associated files
+ for (let fn of obj.files) {
+ 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(obj.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 (obj.pattern) {
+ document.querySelector("#answer").pattern = obj.pattern
+ }
+
+ // 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))
+ })
+ .catch(err => {
+ // Show error to the user
+ Array.from(puzzle.childNodes).map(e => e.remove())
+ let p = document.createElement("p")
+ puzzle.appendChild(p)
+ p.classList.add("Error")
+ p.textContent = err
+ })
+
+ 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
+ }
+
+ let possiblyCorrect = false
+ let answerHash = djb2hash(answer)
+ for (let correctHash of window.puzzle.hashes) {
+ if (correctHash == answerHash) {
+ possiblyCorrect = true
+ }
+ }
+
+ if (possiblyCorrect) {
+ 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 && puzzleId) {
+ loadPuzzle(categoryName, points, puzzleId)
+ }
+
+ 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()
+}
+
diff --git a/theme/scoreboard.html b/theme/scoreboard.html
index db6531a..fcbbc5b 100644
--- a/theme/scoreboard.html
+++ b/theme/scoreboard.html
@@ -128,7 +128,7 @@ if (document.readyState === "loading") {
diff --git a/theme/token.html b/theme/token.html
index 44d845a..80d6542 100644
--- a/theme/token.html
+++ b/theme/token.html
@@ -4,28 +4,30 @@
Redeem Token
+