mirror of https://github.com/dirtbags/moth.git
Compare commits
114 Commits
fa5ea87f22
...
959a802c84
Author | SHA1 | Date |
---|---|---|
Neale Pickett | 959a802c84 | |
Neale Pickett | ceb0cb0edb | |
Neale Pickett | 7c5b5b5ccf | |
Neale Pickett | dfecd100b8 | |
Neale Pickett | 830eb2851a | |
Neale Pickett | 0696e7c61c | |
Neale Pickett | cc74318e15 | |
Neale Pickett | afae394618 | |
Neale Pickett | c9bd05c4ef | |
Neale Pickett | a7c2ee0022 | |
Neale Pickett | 2f7fba2dff | |
Neale Pickett | 285c101bc6 | |
Neale Pickett | 3e629c6859 | |
Neale Pickett | c4788acaa2 | |
Neale Pickett | be75ae0d5a | |
Neale Pickett | 9c8c757dc0 | |
Neale Pickett | 702118a437 | |
Neale Pickett | ce0862372c | |
Neale Pickett | 58f60e4598 | |
Neale Pickett | 6a6860b5da | |
Neale Pickett | 124b879f03 | |
Neale Pickett | 3924eb0249 | |
Neale Pickett | bbb5a5484a | |
Neale Pickett | 60fdb0ddd8 | |
Neale Pickett | cc0e5bba94 | |
Neale Pickett | 535276446c | |
Neale Pickett | 7418a3c224 | |
Neale Pickett | 05ed4f315c | |
Neale Pickett | fa049db1a2 | |
Neale Pickett | 4710b6927a | |
Neale Pickett | f75286d0cf | |
Neale Pickett | 63881f05fa | |
Neale Pickett | c4bf25f8fa | |
Neale Pickett | 610eb27430 | |
Neale Pickett | e4a8883f27 | |
Neale Pickett | 79cef80486 | |
Neale Pickett | 62043919f5 | |
Neale Pickett | 6045000564 | |
Neale Pickett | bae0fb25c6 | |
Neale Pickett | 40b9acf33f | |
Neale Pickett | eba861aed6 | |
Neale Pickett | c20cc1484f | |
Neale Pickett | 44dfbd43b5 | |
Neale Pickett | 59a6aef007 | |
Neale Pickett | 79799bf1c2 | |
Neale Pickett | 077dc261e4 | |
Neale Pickett | 0abb44c48c | |
Neale Pickett | 6ff379e0f4 | |
Neale Pickett | eb786ba184 | |
Neale Pickett | 3d8c47d316 | |
Neale Pickett | 5dfcb6324f | |
Neale Pickett | 9071631353 | |
Neale Pickett | 43aec24d63 | |
Neale Pickett | b863955fdc | |
Neale Pickett | b293a9f0e9 | |
Neale Pickett | 34e51848be | |
Neale Pickett | 1ca2ec284f | |
Neale Pickett | 12979a55a3 | |
Neale Pickett | 3282ad22b0 | |
Neale Pickett | 5350cf73a0 | |
Neale Pickett | 768600e48e | |
Neale Pickett | bb4859e7a9 | |
Neale Pickett | d18de0fe8b | |
Neale Pickett | f49eb3ed46 | |
Neale Pickett | c72d13af32 | |
Neale Pickett | c0761933a9 | |
Neale Pickett | 4ce0dcf11a | |
Neale Pickett | d87be0bfcb | |
Neale Pickett | 13c17873d8 | |
Neale Pickett | 9ea39363b8 | |
Neale Pickett | 0831c4e3d5 | |
Neale Pickett | 175b7aaa1b | |
Neale Pickett | a82851fee3 | |
Neale Pickett | b135069851 | |
Neale Pickett | 18c5f044cc | |
Neale Pickett | 551afe04a5 | |
Neale Pickett | a896788cc5 | |
Neale Pickett | 8ff91e79ec | |
Neale Pickett | 47671b9a12 | |
Neale Pickett | 99d7245c49 | |
Neale Pickett | fcfa11b012 | |
Neale Pickett | a3d0f55160 | |
Neale Pickett | d2971ee740 | |
Neale Pickett | 67e8dda39d | |
Neale Pickett | c43ed9620b | |
dependabot[bot] | ded29f92c1 | |
Neale Pickett | 887e4b3eaf | |
John Donaldson | c3a7ee0d4f | |
John Donaldson | 7925547daf | |
John Donaldson | 5ba58664b6 | |
John Donaldson | 190657f2fa | |
John Donaldson | 73933447a9 | |
John Donaldson | 3bd1cdcc56 | |
John Donaldson | 5720961e85 | |
John Donaldson | 466de2d9c6 | |
John Donaldson | 92d904150a | |
Neale Pickett | 8e0f4561a5 | |
Neale Pickett | eb08700dd1 | |
Neale Pickett | a85df22479 | |
Neale Pickett | a387a06ae5 | |
Neale Pickett | cbe231ef12 | |
Neale Pickett | 243fdfd006 | |
Neale Pickett | bde4b2c86d | |
Neale Pickett | 85f5b96a40 | |
Neale Pickett | dfc31eb9f3 | |
Neale Pickett | be74961e94 | |
Neale Pickett | d014384b05 | |
Neale Pickett | 6d7fb9ebf5 | |
Neale Pickett | 5b6555cd9a | |
Neale Pickett | e5a3b26c93 | |
Neale Pickett | eea674b1a4 | |
Neale Pickett | 459d774726 | |
Neale Pickett | e349a18861 | |
Ken Knudsen | f7945fcf3b |
|
@ -1,5 +1,10 @@
|
||||||
*~
|
*~
|
||||||
*#
|
*#
|
||||||
.idea
|
/.idea
|
||||||
/vendor/
|
/vendor/
|
||||||
__debug_bin
|
/__debug_bin
|
||||||
|
winmoth.*.zip
|
||||||
|
/*.tar.gz
|
||||||
|
/transpile
|
||||||
|
/mothd
|
||||||
|
/*.exe
|
||||||
|
|
|
@ -1,19 +1,59 @@
|
||||||
stages:
|
stages:
|
||||||
- test
|
- test
|
||||||
|
- build
|
||||||
- push
|
- push
|
||||||
|
|
||||||
test:
|
Run unit tests:
|
||||||
stage: test
|
stage: test
|
||||||
image: golang:1.17
|
image: &goimage golang:1.21
|
||||||
only:
|
only:
|
||||||
refs:
|
refs:
|
||||||
- main
|
- main
|
||||||
|
- tags
|
||||||
- merge_requests
|
- merge_requests
|
||||||
script:
|
script:
|
||||||
- go test ./...
|
- go test -coverprofile=coverage.txt -covermode=atomic -race ./...
|
||||||
|
- go tool cover -html=coverage.txt -o coverage.html
|
||||||
|
- go tool cover -func coverage.txt
|
||||||
|
coverage: /\(statements\)(?:\s+)?(\d+(?:\.\d+)?%)/
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- coverage.html
|
||||||
|
- coverage.txt
|
||||||
|
Generage coverage XML:
|
||||||
|
stage: test
|
||||||
|
image: *goimage
|
||||||
|
needs: ["Run unit tests"]
|
||||||
|
script:
|
||||||
|
- go get github.com/boumenot/gocover-cobertura
|
||||||
|
- go run github.com/boumenot/gocover-cobertura < coverage.txt > coverage.xml
|
||||||
|
only:
|
||||||
|
refs:
|
||||||
|
- main
|
||||||
|
- tags
|
||||||
|
- merge_requests
|
||||||
|
artifacts:
|
||||||
|
reports:
|
||||||
|
coverage_report:
|
||||||
|
coverage_format: cobertura
|
||||||
|
path: coverage.xml
|
||||||
|
|
||||||
|
winbuild:
|
||||||
|
stage: build
|
||||||
|
image: *goimage
|
||||||
|
needs: ["Run unit tests"]
|
||||||
|
script:
|
||||||
|
- GOOS=windows GOARCH=amd64 go build ./cmd/mothd
|
||||||
|
- cp build/package/moth-devel.bat .
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- mothd.exe
|
||||||
|
- moth-devel.bat
|
||||||
|
- theme/*
|
||||||
|
|
||||||
push:
|
push:
|
||||||
stage: push
|
stage: push
|
||||||
|
needs: ["Run unit tests"]
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_COMMIT_TAG
|
- if: $CI_COMMIT_TAG
|
||||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||||
|
|
38
CHANGELOG.md
38
CHANGELOG.md
|
@ -4,6 +4,44 @@ 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.5.0] - 2024-01-19
|
||||||
|
### Changed
|
||||||
|
- Answer hashes are now the first 4 characters of the hex-encoded SHA1 digest
|
||||||
|
- Reworked the built-in theme
|
||||||
|
- Devel mode no longer accepts an empty team ID
|
||||||
|
- messages.html moved into theme
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- [moth.mjs](theme/moth.mjs) is now the standard MOTH library for ECMAScript
|
||||||
|
- Exported state now includes "Enabled" boolean
|
||||||
|
- New `Extra` field on puzzles will allow arbitrary metadata on puzzles.
|
||||||
|
|
||||||
|
## [v4.4.11] - 2023-04-11
|
||||||
|
### Changed
|
||||||
|
- CI/CD now builds tags
|
||||||
|
|
||||||
|
## [v4.4.10] - 2022-10-21
|
||||||
|
### Changed
|
||||||
|
- `enabled` file is no longer used
|
||||||
|
- `hours.txt` parsing logs more verbosely
|
||||||
|
- Participant IDs are no longer used anywhere
|
||||||
|
- A few changes to CI/CD test reporting
|
||||||
|
|
||||||
|
## [v4.4.9] - 2022-05-12
|
||||||
|
### Changed
|
||||||
|
- Added a performance optimization for events with a large number of teams
|
||||||
|
backed by NFS
|
||||||
|
|
||||||
|
## [v4.4.8] - 2022-05-10
|
||||||
|
### Changed
|
||||||
|
- You can now join with a team ID not appearing in `teamids.txt`,
|
||||||
|
as long as it is registered (in the `teams/` directory)
|
||||||
|
|
||||||
|
## [v4.4.7] - 2022-05-10
|
||||||
|
### Changed
|
||||||
|
- Initializing an instance now truncates `events.csv`
|
||||||
|
|
||||||
## [v4.4.6] - 2021-10-26
|
## [v4.4.6] - 2021-10-26
|
||||||
### Added
|
### Added
|
||||||
- State is now cached in memory, in an attempt to reduce filesystem metadata operations,
|
- State is now cached in memory, in an attempt to reduce filesystem metadata operations,
|
||||||
|
|
36
LICENSE.md
36
LICENSE.md
|
@ -129,10 +129,36 @@ Both came with the following license:
|
||||||
> OTHER DEALINGS IN THE FONT SOFTWARE.
|
> OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
Javascript MD5 Library
|
Go Fonts
|
||||||
======================
|
=======
|
||||||
|
|
||||||
Obtained from <https://github.com/blueimp/JavaScript-MD5>, which says:
|
The Go fonts were obtained from
|
||||||
|
https://go.googlesource.com/image
|
||||||
|
|
||||||
> The JavaScript MD5 script is released under the
|
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||||
> [MIT license](http://www.opensource.org/licenses/MIT).
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are
|
||||||
|
met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
copyright notice, this list of conditions and the following disclaimer
|
||||||
|
in the documentation and/or other materials provided with the
|
||||||
|
distribution.
|
||||||
|
* Neither the name of Google Inc. nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||||
|
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
Dirtbags Monarch Of The Hill Server
|
Dirtbags Monarch Of The Hill Server
|
||||||
=====================
|
=====================
|
||||||
|
|
||||||
![Build badge](https://github.com/dirtbags/moth/workflows/Build/Test/Push/badge.svg)
|
[![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth)](https://goreportcard.com/report/github.com/dirtbags/moth)
|
||||||
![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth)
|
|
||||||
|
|
||||||
Monarch Of The Hill (MOTH) is a puzzle server.
|
Monarch Of The Hill (MOTH) is a puzzle server.
|
||||||
We (the authors) have used it for instructional and contest events called
|
We (the authors) have used it for instructional and contest events called
|
||||||
|
|
|
@ -43,6 +43,11 @@ case $ACTION in
|
||||||
run docker push $image
|
run docker push $image
|
||||||
done
|
done
|
||||||
;;
|
;;
|
||||||
|
release)
|
||||||
|
run go build -v ./cmd/mothd
|
||||||
|
run go build -v ./cmd/transpile
|
||||||
|
run tar czf moth-$(git tag --contains).$(uname -s)-$(uname -m).tar.gz mothd transpile theme
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown action: $1" 1>&2
|
echo "Unknown action: $1" 1>&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
FROM golang:1 AS builder
|
ARG GO_VERSION=1.21-alpine
|
||||||
|
FROM docker.io/library/golang:${GO_VERSION} AS builder
|
||||||
COPY go.* /src/
|
COPY go.* /src/
|
||||||
COPY pkg /src/pkg/
|
COPY pkg /src/pkg/
|
||||||
COPY cmd /src/cmd/
|
COPY cmd /src/cmd/
|
||||||
|
|
|
@ -2,19 +2,34 @@
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
cd $(dirname $0)/../..
|
cd $(dirname $0)
|
||||||
|
base=../..
|
||||||
|
|
||||||
PODMAN=$(command -v podman || echo docker)
|
VERSION=$(cat $base/CHANGELOG.md | awk -F '[][]' '/^## \[/ {print $2; exit}')
|
||||||
VERSION=$(cat CHANGELOG.md | awk -F '[][]' '/^## \[/ {print $2; exit}')
|
GO_VERSION=$(cat $base/go.mod | sed -n 's/^go //p')
|
||||||
|
|
||||||
for target in moth; do
|
(
|
||||||
tag=dirtbags/$target:$VERSION
|
zipfile=winmoth.$VERSION.zip
|
||||||
|
echo "=== Building $zipfile"
|
||||||
|
mkdir -p winmoth winmoth/state winmoth/puzzles winmoth/mothballs
|
||||||
|
echo devel > winmoth/state/teamids.txt
|
||||||
|
cp moth-devel.bat winmoth
|
||||||
|
cp -a $base/theme winmoth
|
||||||
|
(
|
||||||
|
cd winmoth
|
||||||
|
GOOS=windows GOARCH=amd64 go build ../$base/cmd/mothd/...
|
||||||
|
)
|
||||||
|
zip -r $zipfile winmoth
|
||||||
|
|
||||||
|
rm -rf winmoth
|
||||||
|
)
|
||||||
|
|
||||||
|
tag=dirtbags/moth:$VERSION
|
||||||
echo "==== Building $tag"
|
echo "==== Building $tag"
|
||||||
$PODMAN build \
|
docker build \
|
||||||
|
--build-arg GO_VERSION=$GO_VERSION \
|
||||||
--build-arg http_proxy --build-arg https_proxy --build-arg no_proxy \
|
--build-arg http_proxy --build-arg https_proxy --build-arg no_proxy \
|
||||||
--tag $tag \
|
--tag $tag \
|
||||||
--target $target \
|
-f Containerfile $base
|
||||||
-f build/package/Containerfile .
|
|
||||||
done
|
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
mkdir state
|
||||||
|
mkdir puzzles
|
||||||
|
echo devel > state/teamids.txt
|
||||||
|
.\mothd.exe -puzzles puzzles
|
|
@ -0,0 +1,24 @@
|
||||||
|
#! /bin/sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd $(dirname $0)
|
||||||
|
base=../..
|
||||||
|
|
||||||
|
VERSION=$(cat $base/CHANGELOG.md | awk -F '[][]' '/^## \[/ {print $2; exit}')
|
||||||
|
|
||||||
|
(
|
||||||
|
zipfile=winmoth.$VERSION.zip
|
||||||
|
echo "=== Building $zipfile"
|
||||||
|
mkdir -p winmoth winmoth/state winmoth/puzzles winmoth/mothballs
|
||||||
|
echo devel > winmoth/state/teamids.txt
|
||||||
|
cp moth-devel.bat winmoth
|
||||||
|
cp -a $base/theme winmoth
|
||||||
|
(
|
||||||
|
cd winmoth
|
||||||
|
GOOS=windows GOARCH=amd64 go build ../$base/cmd/mothd/...
|
||||||
|
)
|
||||||
|
zip -r $zipfile winmoth
|
||||||
|
|
||||||
|
rm -rf winmoth
|
||||||
|
)
|
|
@ -8,7 +8,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dirtbags/moth/pkg/jsend"
|
"github.com/dirtbags/moth/v4/pkg/jsend"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTPServer is a MOTH HTTP server
|
// HTTPServer is a MOTH HTTP server
|
||||||
|
@ -44,9 +44,8 @@ func (h *HTTPServer) HandleMothFunc(
|
||||||
mothHandler func(MothRequestHandler, http.ResponseWriter, *http.Request),
|
mothHandler func(MothRequestHandler, http.ResponseWriter, *http.Request),
|
||||||
) {
|
) {
|
||||||
handler := func(w http.ResponseWriter, req *http.Request) {
|
handler := func(w http.ResponseWriter, req *http.Request) {
|
||||||
participantID := req.FormValue("pid")
|
|
||||||
teamID := req.FormValue("id")
|
teamID := req.FormValue("id")
|
||||||
mh := h.server.NewHandler(participantID, teamID)
|
mh := h.server.NewHandler(teamID)
|
||||||
mothHandler(mh, w, req)
|
mothHandler(mh, w, req)
|
||||||
}
|
}
|
||||||
h.HandleFunc(h.base+pattern, handler)
|
h.HandleFunc(h.base+pattern, handler)
|
||||||
|
|
|
@ -7,16 +7,12 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
const TestParticipantID = "shipox"
|
|
||||||
|
|
||||||
func (hs *HTTPServer) TestRequest(path string, args map[string]string) *httptest.ResponseRecorder {
|
func (hs *HTTPServer) TestRequest(path string, args map[string]string) *httptest.ResponseRecorder {
|
||||||
vals := url.Values{}
|
vals := url.Values{}
|
||||||
vals.Set("pid", TestParticipantID)
|
|
||||||
vals.Set("id", TestTeamID)
|
vals.Set("id", TestTeamID)
|
||||||
for k, v := range args {
|
for k, v := range args {
|
||||||
vals.Set(k, v)
|
vals.Set(k, v)
|
||||||
|
@ -33,7 +29,8 @@ func (hs *HTTPServer) TestRequest(path string, args map[string]string) *httptest
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHttpd(t *testing.T) {
|
func TestHttpd(t *testing.T) {
|
||||||
hs := NewHTTPServer("/", NewTestServer())
|
server := NewTestServer()
|
||||||
|
hs := NewHTTPServer("/", server.MothServer)
|
||||||
|
|
||||||
if r := hs.TestRequest("/", nil); r.Result().StatusCode != 200 {
|
if r := hs.TestRequest("/", nil); r.Result().StatusCode != 200 {
|
||||||
t.Error(r.Result())
|
t.Error(r.Result())
|
||||||
|
@ -48,7 +45,7 @@ func TestHttpd(t *testing.T) {
|
||||||
|
|
||||||
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
|
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
|
||||||
t.Error(r.Result())
|
t.Error(r.Result())
|
||||||
} else if r.Body.String() != `{"Config":{"Devel":false},"Messages":"messages.html","TeamNames":{},"PointsLog":[],"Puzzles":{}}` {
|
} else if r.Body.String() != `{"Config":{"Devel":false},"Enabled":true,"TeamNames":{},"PointsLog":[],"Puzzles":{}}` {
|
||||||
t.Error("Unexpected state", r.Body.String())
|
t.Error("Unexpected state", r.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,11 +67,11 @@ func TestHttpd(t *testing.T) {
|
||||||
t.Error("Register failed", r.Body.String())
|
t.Error("Register failed", r.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(TestMaintenanceInterval)
|
server.refresh()
|
||||||
|
|
||||||
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
|
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
|
||||||
t.Error(r.Result())
|
t.Error(r.Result())
|
||||||
} else if r.Body.String() != `{"Config":{"Devel":false},"Messages":"messages.html","TeamNames":{"self":"GoTeam"},"PointsLog":[],"Puzzles":{"pategory":[1]}}` {
|
} else if r.Body.String() != `{"Config":{"Devel":false},"Enabled":true,"TeamNames":{"self":"GoTeam"},"PointsLog":[],"Puzzles":{"pategory":[1]}}` {
|
||||||
t.Error("Unexpected state", r.Body.String())
|
t.Error("Unexpected state", r.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,7 +109,7 @@ func TestHttpd(t *testing.T) {
|
||||||
t.Error("Unexpected body", r.Body.String())
|
t.Error("Unexpected body", r.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(TestMaintenanceInterval)
|
server.refresh()
|
||||||
|
|
||||||
if r := hs.TestRequest("/content/pategory/2/puzzle.json", nil); r.Result().StatusCode != 200 {
|
if r := hs.TestRequest("/content/pategory/2/puzzle.json", nil); r.Result().StatusCode != 200 {
|
||||||
t.Error(r.Result())
|
t.Error(r.Result())
|
||||||
|
@ -124,14 +121,14 @@ func TestHttpd(t *testing.T) {
|
||||||
} else if err := json.Unmarshal(r.Body.Bytes(), &state); err != nil {
|
} else if err := json.Unmarshal(r.Body.Bytes(), &state); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
} else if len(state.PointsLog) != 1 {
|
} else if len(state.PointsLog) != 1 {
|
||||||
t.Error("Points log wrong length")
|
t.Errorf("Points log wrong length. Wanted 1, got %v (length %d)", state.PointsLog, len(state.PointsLog))
|
||||||
} else if len(state.Puzzles["pategory"]) != 2 {
|
} else if len(state.Puzzles["pategory"]) != 2 {
|
||||||
t.Error("Didn't unlock next puzzle")
|
t.Error("Didn't unlock next puzzle")
|
||||||
}
|
}
|
||||||
|
|
||||||
if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "answer123"}); r.Result().StatusCode != 200 {
|
if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "answer123"}); r.Result().StatusCode != 200 {
|
||||||
t.Error(r.Result())
|
t.Error(r.Result())
|
||||||
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"error awarding points: points already awarded to this team in this category"}}` {
|
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"points already awarded to this team in this category"}}` {
|
||||||
t.Error("Unexpected body", r.Body.String())
|
t.Error("Unexpected body", r.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -140,7 +137,7 @@ func TestDevelMemHttpd(t *testing.T) {
|
||||||
srv := NewTestServer()
|
srv := NewTestServer()
|
||||||
|
|
||||||
{
|
{
|
||||||
hs := NewHTTPServer("/", srv)
|
hs := NewHTTPServer("/", srv.MothServer)
|
||||||
|
|
||||||
if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 404 {
|
if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 404 {
|
||||||
t.Error("Should have gotten a 404 for mothballer in prod mode")
|
t.Error("Should have gotten a 404 for mothballer in prod mode")
|
||||||
|
@ -149,7 +146,7 @@ func TestDevelMemHttpd(t *testing.T) {
|
||||||
|
|
||||||
{
|
{
|
||||||
srv.Config.Devel = true
|
srv.Config.Devel = true
|
||||||
hs := NewHTTPServer("/", srv)
|
hs := NewHTTPServer("/", srv.MothServer)
|
||||||
|
|
||||||
if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 500 {
|
if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 500 {
|
||||||
t.Log(r.Body.String())
|
t.Log(r.Body.String())
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIssue156(t *testing.T) {
|
||||||
|
puzzles := NewTestMothballs()
|
||||||
|
state := NewTestState()
|
||||||
|
theme := NewTestTheme()
|
||||||
|
server := NewMothServer(Configuration{}, theme, state, puzzles)
|
||||||
|
|
||||||
|
afero.WriteFile(state, "teams/bloop", []byte("bloop: the team"), 0644)
|
||||||
|
state.refresh()
|
||||||
|
|
||||||
|
handler := server.NewHandler("bloop")
|
||||||
|
es := handler.ExportState()
|
||||||
|
if _, ok := es.TeamNames["self"]; !ok {
|
||||||
|
t.Fail()
|
||||||
|
}
|
||||||
|
|
||||||
|
err := handler.Register("bloop: the other team")
|
||||||
|
if err != ErrAlreadyRegistered {
|
||||||
|
t.Fail()
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"mime"
|
"mime"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
|
@ -54,21 +55,38 @@ func main() {
|
||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
var theme *Theme
|
||||||
osfs := afero.NewOsFs()
|
osfs := afero.NewOsFs()
|
||||||
theme := NewTheme(afero.NewBasePathFs(osfs, *themePath))
|
if p, err := filepath.Abs(*themePath); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
} else {
|
||||||
|
theme = NewTheme(afero.NewBasePathFs(osfs, p))
|
||||||
|
}
|
||||||
|
|
||||||
config := Configuration{}
|
config := Configuration{}
|
||||||
|
|
||||||
var provider PuzzleProvider
|
var provider PuzzleProvider
|
||||||
provider = NewMothballs(afero.NewBasePathFs(osfs, *mothballPath))
|
if p, err := filepath.Abs(*mothballPath); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
} else {
|
||||||
|
provider = NewMothballs(afero.NewBasePathFs(osfs, p))
|
||||||
|
}
|
||||||
if *puzzlePath != "" {
|
if *puzzlePath != "" {
|
||||||
provider = NewTranspilerProvider(afero.NewBasePathFs(osfs, *puzzlePath))
|
if p, err := filepath.Abs(*puzzlePath); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
} else {
|
||||||
|
provider = NewTranspilerProvider(afero.NewBasePathFs(osfs, p))
|
||||||
|
}
|
||||||
config.Devel = true
|
config.Devel = true
|
||||||
log.Println("-=- You are in development mode, champ! -=-")
|
log.Println("-=- You are in development mode, champ! -=-")
|
||||||
}
|
}
|
||||||
|
|
||||||
var state StateProvider
|
var state StateProvider
|
||||||
state = NewState(afero.NewBasePathFs(osfs, *statePath))
|
if p, err := filepath.Abs(*statePath); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
} else {
|
||||||
|
state = NewState(afero.NewBasePathFs(osfs, p))
|
||||||
|
}
|
||||||
if config.Devel {
|
if config.Devel {
|
||||||
state = NewDevelState(state)
|
state = NewDevelState(state)
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dirtbags/moth/pkg/transpile"
|
"github.com/dirtbags/moth/v4/pkg/transpile"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProviderCommand specifies a command to run for the puzzle API
|
// ProviderCommand specifies a command to run for the puzzle API
|
||||||
|
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dirtbags/moth/pkg/award"
|
"github.com/dirtbags/moth/v4/pkg/award"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Category represents a puzzle category.
|
// Category represents a puzzle category.
|
||||||
|
@ -30,7 +30,7 @@ type Configuration struct {
|
||||||
// StateExport is given to clients requesting the current state.
|
// StateExport is given to clients requesting the current state.
|
||||||
type StateExport struct {
|
type StateExport struct {
|
||||||
Config Configuration
|
Config Configuration
|
||||||
Messages string
|
Enabled bool
|
||||||
TeamNames map[string]string
|
TeamNames map[string]string
|
||||||
PointsLog award.List
|
PointsLog award.List
|
||||||
Puzzles map[string][]int
|
Puzzles map[string][]int
|
||||||
|
@ -53,12 +53,12 @@ type ThemeProvider interface {
|
||||||
|
|
||||||
// StateProvider defines what's required to provide MOTH state.
|
// StateProvider defines what's required to provide MOTH state.
|
||||||
type StateProvider interface {
|
type StateProvider interface {
|
||||||
Messages() string
|
Enabled() bool
|
||||||
PointsLog() award.List
|
PointsLog() award.List
|
||||||
TeamName(teamID string) (string, error)
|
TeamName(teamID string) (string, error)
|
||||||
SetTeamName(teamID, teamName string) error
|
SetTeamName(teamID, teamName string) error
|
||||||
AwardPoints(teamID string, cat string, points int) error
|
AwardPoints(teamID string, cat string, points int) error
|
||||||
LogEvent(event, participantID, teamID, cat string, points int, extra ...string)
|
LogEvent(event, teamID, cat string, points int, extra ...string)
|
||||||
Maintainer
|
Maintainer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,6 +68,9 @@ type Maintainer interface {
|
||||||
// It will only be called once, when execution begins.
|
// It will only be called once, when execution begins.
|
||||||
// It's okay to just exit if there's no maintenance to be done.
|
// It's okay to just exit if there's no maintenance to be done.
|
||||||
Maintain(updateInterval time.Duration)
|
Maintain(updateInterval time.Duration)
|
||||||
|
|
||||||
|
// refresh is a shortcut used internally for testing
|
||||||
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MothServer gathers together the providers that make up a MOTH server.
|
// MothServer gathers together the providers that make up a MOTH server.
|
||||||
|
@ -89,10 +92,9 @@ func NewMothServer(config Configuration, theme ThemeProvider, state StateProvide
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler returns a new http.RequestHandler for the provided teamID.
|
// NewHandler returns a new http.RequestHandler for the provided teamID.
|
||||||
func (s *MothServer) NewHandler(participantID, teamID string) MothRequestHandler {
|
func (s *MothServer) NewHandler(teamID string) MothRequestHandler {
|
||||||
return MothRequestHandler{
|
return MothRequestHandler{
|
||||||
MothServer: s,
|
MothServer: s,
|
||||||
participantID: participantID,
|
|
||||||
teamID: teamID,
|
teamID: teamID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -100,7 +102,6 @@ func (s *MothServer) NewHandler(participantID, teamID string) MothRequestHandler
|
||||||
// MothRequestHandler provides http.RequestHandler for a MothServer.
|
// MothRequestHandler provides http.RequestHandler for a MothServer.
|
||||||
type MothRequestHandler struct {
|
type MothRequestHandler struct {
|
||||||
*MothServer
|
*MothServer
|
||||||
participantID string
|
|
||||||
teamID string
|
teamID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -128,7 +129,7 @@ func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (
|
||||||
|
|
||||||
// Log puzzle.json loads
|
// Log puzzle.json loads
|
||||||
if path == "puzzle.json" {
|
if path == "puzzle.json" {
|
||||||
mh.State.LogEvent("load", mh.participantID, mh.teamID, cat, points)
|
mh.State.LogEvent("load", mh.teamID, cat, points)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
@ -145,17 +146,17 @@ func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !correct {
|
if !correct {
|
||||||
mh.State.LogEvent("wrong", mh.participantID, mh.teamID, cat, points)
|
mh.State.LogEvent("wrong", mh.teamID, cat, points)
|
||||||
return fmt.Errorf("incorrect answer")
|
return fmt.Errorf("incorrect answer")
|
||||||
}
|
}
|
||||||
|
|
||||||
mh.State.LogEvent("correct", mh.participantID, mh.teamID, cat, points)
|
mh.State.LogEvent("correct", mh.teamID, cat, points)
|
||||||
|
|
||||||
if _, err := mh.State.TeamName(mh.teamID); err != nil {
|
if _, err := mh.State.TeamName(mh.teamID); err != nil {
|
||||||
return fmt.Errorf("invalid team ID")
|
return fmt.Errorf("invalid team ID")
|
||||||
}
|
}
|
||||||
if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
|
if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
|
||||||
return fmt.Errorf("error awarding points: %s", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -168,11 +169,10 @@ 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")
|
||||||
}
|
}
|
||||||
mh.State.LogEvent("register", mh.participantID, mh.teamID, "", 0)
|
mh.State.LogEvent("register", mh.teamID, "", 0)
|
||||||
return mh.State.SetTeamName(mh.teamID, teamName)
|
return mh.State.SetTeamName(mh.teamID, teamName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,14 +184,17 @@ func (mh *MothRequestHandler) ExportState() *StateExport {
|
||||||
return mh.exportStateIfRegistered(false)
|
return mh.exportStateIfRegistered(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mh *MothRequestHandler) exportStateIfRegistered(override bool) *StateExport {
|
// Export state, replacing the team ID with "self" if the team is registered.
|
||||||
|
//
|
||||||
|
// If forceRegistered is true, go ahead and export it anyway
|
||||||
|
func (mh *MothRequestHandler) exportStateIfRegistered(forceRegistered bool) *StateExport {
|
||||||
export := StateExport{}
|
export := StateExport{}
|
||||||
export.Config = mh.Config
|
export.Config = mh.Config
|
||||||
|
|
||||||
teamName, err := mh.State.TeamName(mh.teamID)
|
teamName, err := mh.State.TeamName(mh.teamID)
|
||||||
registered := override || mh.Config.Devel || (err == nil)
|
registered := forceRegistered || mh.Config.Devel || (err == nil)
|
||||||
|
|
||||||
export.Messages = mh.State.Messages()
|
export.Enabled = mh.State.Enabled()
|
||||||
export.TeamNames = make(map[string]string)
|
export.TeamNames = make(map[string]string)
|
||||||
|
|
||||||
// Anonymize team IDs in points log, and write out team names
|
// Anonymize team IDs in points log, and write out team names
|
||||||
|
|
|
@ -3,34 +3,45 @@ package main
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
const TestMaintenanceInterval = time.Millisecond * 1
|
|
||||||
const TestTeamID = "teamID"
|
const TestTeamID = "teamID"
|
||||||
|
|
||||||
func NewTestServer() *MothServer {
|
type TestServer struct {
|
||||||
|
*MothServer
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTestServer creates a new MothServer with NewTestMothballs and some initial state.
|
||||||
|
//
|
||||||
|
// See function definition for details.
|
||||||
|
func NewTestServer() TestServer {
|
||||||
puzzles := NewTestMothballs()
|
puzzles := NewTestMothballs()
|
||||||
go puzzles.Maintain(TestMaintenanceInterval)
|
puzzles.refresh()
|
||||||
|
|
||||||
state := NewTestState()
|
state := NewTestState()
|
||||||
afero.WriteFile(state, "teamids.txt", []byte("teamID\n"), 0644)
|
afero.WriteFile(state, "teamids.txt", []byte("teamID\n"), 0644)
|
||||||
afero.WriteFile(state, "messages.html", []byte("messages.html"), 0644)
|
state.refresh()
|
||||||
go state.Maintain(TestMaintenanceInterval)
|
|
||||||
|
|
||||||
theme := NewTestTheme()
|
theme := NewTestTheme()
|
||||||
afero.WriteFile(theme.Fs, "/index.html", []byte("index.html"), 0644)
|
afero.WriteFile(theme.Fs, "/index.html", []byte("index.html"), 0644)
|
||||||
go theme.Maintain(TestMaintenanceInterval)
|
|
||||||
|
|
||||||
return NewMothServer(Configuration{}, theme, state, puzzles)
|
return TestServer{NewMothServer(Configuration{}, theme, state, puzzles)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts TestServer) refresh() {
|
||||||
|
ts.State.(*State).refresh()
|
||||||
|
for _, pp := range ts.PuzzleProviders {
|
||||||
|
pp.(*Mothballs).refresh()
|
||||||
|
}
|
||||||
|
ts.Theme.(*Theme).refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevelServer(t *testing.T) {
|
func TestDevelServer(t *testing.T) {
|
||||||
server := NewTestServer()
|
server := NewTestServer()
|
||||||
server.Config.Devel = true
|
server.Config.Devel = true
|
||||||
anonHandler := server.NewHandler("badParticipantId", "badTeamId")
|
anonHandler := server.NewHandler("badTeamId")
|
||||||
|
|
||||||
{
|
{
|
||||||
es := anonHandler.ExportState()
|
es := anonHandler.ExportState()
|
||||||
|
@ -45,12 +56,11 @@ func TestDevelServer(t *testing.T) {
|
||||||
|
|
||||||
func TestProdServer(t *testing.T) {
|
func TestProdServer(t *testing.T) {
|
||||||
teamName := "OurTeam"
|
teamName := "OurTeam"
|
||||||
participantID := "participantID"
|
|
||||||
teamID := TestTeamID
|
teamID := TestTeamID
|
||||||
|
|
||||||
server := NewTestServer()
|
server := NewTestServer()
|
||||||
handler := server.NewHandler(participantID, teamID)
|
handler := server.NewHandler(teamID)
|
||||||
anonHandler := server.NewHandler("badParticipantId", "badTeamId")
|
anonHandler := server.NewHandler("badTeamId")
|
||||||
|
|
||||||
{
|
{
|
||||||
es := handler.ExportState()
|
es := handler.ExportState()
|
||||||
|
@ -80,8 +90,7 @@ func TestProdServer(t *testing.T) {
|
||||||
t.Error("index.html wrong contents", contents)
|
t.Error("index.html wrong contents", contents)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for refresh to pick everything up
|
server.refresh()
|
||||||
time.Sleep(TestMaintenanceInterval)
|
|
||||||
|
|
||||||
{
|
{
|
||||||
es := handler.ExportState()
|
es := handler.ExportState()
|
||||||
|
@ -91,9 +100,6 @@ func TestProdServer(t *testing.T) {
|
||||||
if len(es.Puzzles) != 1 {
|
if len(es.Puzzles) != 1 {
|
||||||
t.Error("Puzzle categories wrong length", len(es.Puzzles))
|
t.Error("Puzzle categories wrong length", len(es.Puzzles))
|
||||||
}
|
}
|
||||||
if es.Messages != "messages.html" {
|
|
||||||
t.Error("Messages has wrong contents")
|
|
||||||
}
|
|
||||||
if len(es.PointsLog) != 0 {
|
if len(es.PointsLog) != 0 {
|
||||||
t.Error("Points log not empty")
|
t.Error("Points log not empty")
|
||||||
}
|
}
|
||||||
|
@ -134,7 +140,7 @@ func TestProdServer(t *testing.T) {
|
||||||
t.Error("Right answer marked wrong", err)
|
t.Error("Right answer marked wrong", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(TestMaintenanceInterval)
|
server.refresh()
|
||||||
|
|
||||||
{
|
{
|
||||||
es := handler.ExportState()
|
es := handler.ExportState()
|
||||||
|
@ -163,7 +169,7 @@ func TestProdServer(t *testing.T) {
|
||||||
t.Error("Right answer marked wrong:", err)
|
t.Error("Right answer marked wrong:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(TestMaintenanceInterval)
|
server.refresh()
|
||||||
|
|
||||||
{
|
{
|
||||||
es := anonHandler.ExportState()
|
es := anonHandler.ExportState()
|
||||||
|
|
|
@ -14,7 +14,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dirtbags/moth/pkg/award"
|
"github.com/dirtbags/moth/v4/pkg/award"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -37,17 +37,18 @@ type State struct {
|
||||||
afero.Fs
|
afero.Fs
|
||||||
|
|
||||||
// Enabled tracks whether the current State system is processing updates
|
// Enabled tracks whether the current State system is processing updates
|
||||||
Enabled bool
|
enabled bool
|
||||||
|
|
||||||
|
enabledWhy string
|
||||||
refreshNow chan bool
|
refreshNow chan bool
|
||||||
eventStream chan []string
|
eventStream chan []string
|
||||||
eventWriter *csv.Writer
|
eventWriter *csv.Writer
|
||||||
eventWriterFile afero.File
|
eventWriterFile afero.File
|
||||||
|
|
||||||
// Caches, so we're not hammering NFS with metadata operations
|
// Caches, so we're not hammering NFS with metadata operations
|
||||||
|
teamNamesLastChange time.Time
|
||||||
teamNames map[string]string
|
teamNames map[string]string
|
||||||
pointsLog award.List
|
pointsLog award.List
|
||||||
messages string
|
|
||||||
lock sync.RWMutex
|
lock sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,7 +56,7 @@ type State struct {
|
||||||
func NewState(fs afero.Fs) *State {
|
func NewState(fs afero.Fs) *State {
|
||||||
s := &State{
|
s := &State{
|
||||||
Fs: fs,
|
Fs: fs,
|
||||||
Enabled: true,
|
enabled: true,
|
||||||
refreshNow: make(chan bool, 5),
|
refreshNow: make(chan bool, 5),
|
||||||
eventStream: make(chan []string, 80),
|
eventStream: make(chan []string, 80),
|
||||||
|
|
||||||
|
@ -70,11 +71,10 @@ func NewState(fs afero.Fs) *State {
|
||||||
// updateEnabled checks a few things to see if this state directory is "enabled".
|
// updateEnabled checks a few things to see if this state directory is "enabled".
|
||||||
func (s *State) updateEnabled() {
|
func (s *State) updateEnabled() {
|
||||||
nextEnabled := true
|
nextEnabled := true
|
||||||
why := "`state/enabled` present, `state/hours.txt` missing"
|
why := "state/hours.txt has no timestamps before now"
|
||||||
|
|
||||||
if untilFile, err := s.Open("hours.txt"); err == nil {
|
if untilFile, err := s.Open("hours.txt"); err == nil {
|
||||||
defer untilFile.Close()
|
defer untilFile.Close()
|
||||||
why = "`state/hours.txt` present"
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(untilFile)
|
scanner := bufio.NewScanner(untilFile)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
|
@ -94,35 +94,36 @@ func (s *State) updateEnabled() {
|
||||||
case '#':
|
case '#':
|
||||||
continue
|
continue
|
||||||
default:
|
default:
|
||||||
log.Println("Misformatted line in hours.txt file")
|
log.Println("state/hours.txt has bad line:", line)
|
||||||
}
|
}
|
||||||
|
line, _, _ = strings.Cut(line, "#") // Remove inline comments
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
until, err := time.Parse(time.RFC3339, line)
|
until := time.Time{}
|
||||||
if err != nil {
|
if len(line) == 0 {
|
||||||
until, err = time.Parse(RFC3339Space, line)
|
// Let it stay as zero time, so it's always before now
|
||||||
}
|
} else if until, err = time.Parse(time.RFC3339, line); err == nil {
|
||||||
if err != nil {
|
// Great, it was RFC 3339
|
||||||
log.Println("Suspended: Unparseable until date:", line)
|
} else if until, err = time.Parse(RFC3339Space, line); err == nil {
|
||||||
|
// Great, it was RFC 3339 with a space instead of a 'T'
|
||||||
|
} else {
|
||||||
|
log.Println("state/hours.txt has bad timestamp:", line)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if until.Before(time.Now()) {
|
if until.Before(time.Now()) {
|
||||||
nextEnabled = thisEnabled
|
nextEnabled = thisEnabled
|
||||||
|
why = fmt.Sprint("state/hours.txt most recent timestamp:", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.Stat("enabled"); os.IsNotExist(err) {
|
if (nextEnabled != s.enabled) || (why != s.enabledWhy) {
|
||||||
nextEnabled = false
|
s.enabled = nextEnabled
|
||||||
why = "`state/enabled` missing"
|
s.enabledWhy = why
|
||||||
}
|
log.Printf("Setting enabled=%v: %s", s.enabled, s.enabledWhy)
|
||||||
|
if s.enabled {
|
||||||
if nextEnabled != s.Enabled {
|
s.LogEvent("enabled", "", "", 0, s.enabledWhy)
|
||||||
s.Enabled = nextEnabled
|
|
||||||
log.Printf("Setting enabled=%v: %s", s.Enabled, why)
|
|
||||||
if s.Enabled {
|
|
||||||
s.LogEvent("enabled", "", "", "", 0, why)
|
|
||||||
} else {
|
} else {
|
||||||
s.LogEvent("disabled", "", "", "", 0, why)
|
s.LogEvent("disabled", "", "", 0, s.enabledWhy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -141,6 +142,13 @@ func (s *State) TeamName(teamID string) (string, error) {
|
||||||
// SetTeamName writes out team name.
|
// SetTeamName writes out team name.
|
||||||
// This can only be done once per team.
|
// This can only be done once per team.
|
||||||
func (s *State) SetTeamName(teamID, teamName string) error {
|
func (s *State) SetTeamName(teamID, teamName string) error {
|
||||||
|
s.lock.RLock()
|
||||||
|
_, ok := s.teamNames[teamID]
|
||||||
|
s.lock.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return ErrAlreadyRegistered
|
||||||
|
}
|
||||||
|
|
||||||
idsFile, err := s.Open("teamids.txt")
|
idsFile, err := s.Open("teamids.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("team IDs file does not exist")
|
return fmt.Errorf("team IDs file does not exist")
|
||||||
|
@ -184,11 +192,9 @@ func (s *State) PointsLog() award.List {
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
// Messages retrieves the current messages.
|
// Enabled returns true if the server is in "enabled" state
|
||||||
func (s *State) Messages() string {
|
func (s *State) Enabled() bool {
|
||||||
s.lock.RLock() // It's not clear to me that this actually needs to happen
|
return s.enabled
|
||||||
defer s.lock.RUnlock()
|
|
||||||
return s.messages
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AwardPoints gives points to teamID in category.
|
// AwardPoints gives points to teamID in category.
|
||||||
|
@ -303,7 +309,7 @@ func (s *State) maybeInitialize() {
|
||||||
s.Remove("enabled")
|
s.Remove("enabled")
|
||||||
s.Remove("hours.txt")
|
s.Remove("hours.txt")
|
||||||
s.Remove("points.log")
|
s.Remove("points.log")
|
||||||
s.Remove("messages.html")
|
s.Remove("events.csv")
|
||||||
s.Remove("mothd.log")
|
s.Remove("mothd.log")
|
||||||
s.RemoveAll("points.tmp")
|
s.RemoveAll("points.tmp")
|
||||||
s.RemoveAll("points.new")
|
s.RemoveAll("points.new")
|
||||||
|
@ -313,7 +319,7 @@ func (s *State) maybeInitialize() {
|
||||||
if err := s.reopenEventLog(); err != nil {
|
if err := s.reopenEventLog(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
s.LogEvent("init", "", "", "", 0)
|
s.LogEvent("init", "", "", 0)
|
||||||
|
|
||||||
// Make sure various subdirectories exist
|
// Make sure various subdirectories exist
|
||||||
s.Mkdir("points.tmp", 0755)
|
s.Mkdir("points.tmp", 0755)
|
||||||
|
@ -341,43 +347,35 @@ func (s *State) maybeInitialize() {
|
||||||
f.Close()
|
f.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
if f, err := s.Create("enabled"); err == nil {
|
|
||||||
fmt.Fprintln(f, "enabled: remove or rename to suspend the contest.")
|
|
||||||
f.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
if f, err := s.Create("hours.txt"); err == nil {
|
if f, err := s.Create("hours.txt"); err == nil {
|
||||||
fmt.Fprintln(f, "# hours.txt: when the contest is enabled")
|
fmt.Fprintln(f, "# hours.txt: when the contest is enabled")
|
||||||
fmt.Fprintln(f, "#")
|
fmt.Fprintln(f, "#")
|
||||||
fmt.Fprintln(f, "# Enable: + timestamp")
|
fmt.Fprintln(f, "# Enable: + [timestamp]")
|
||||||
fmt.Fprintln(f, "# Disable: - timestamp")
|
fmt.Fprintln(f, "# Disable: - [timestamp]")
|
||||||
fmt.Fprintln(f, "#")
|
fmt.Fprintln(f, "#")
|
||||||
fmt.Fprintln(f, "# You can have multiple start/stop times.")
|
fmt.Fprintln(f, "# This file, and all files in this directory, are re-read periodically.")
|
||||||
fmt.Fprintln(f, "# Whatever time is the most recent, wins.")
|
fmt.Fprintln(f, "# Default is enabled.")
|
||||||
fmt.Fprintln(f, "# Times in the future are ignored.")
|
fmt.Fprintln(f, "# Rules with only '-' or '+' are also allowed.")
|
||||||
|
fmt.Fprintln(f, "# Rules apply from the top down.")
|
||||||
|
fmt.Fprintln(f, "# If you put something in out of order, it's going to be bonkers.")
|
||||||
fmt.Fprintln(f)
|
fmt.Fprintln(f)
|
||||||
|
fmt.Fprintln(f, "- 1970-01-01T00:00:00Z")
|
||||||
fmt.Fprintln(f, "+", now)
|
fmt.Fprintln(f, "+", now)
|
||||||
fmt.Fprintln(f, "- 2519-10-31T00:00:00Z")
|
fmt.Fprintln(f, "- 2519-10-31T00:00:00Z")
|
||||||
f.Close()
|
f.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
if f, err := s.Create("messages.html"); err == nil {
|
|
||||||
fmt.Fprintln(f, "<!-- messages.html: put client broadcast messages here. -->")
|
|
||||||
f.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
if f, err := s.Create("points.log"); err == nil {
|
if f, err := s.Create("points.log"); err == nil {
|
||||||
f.Close()
|
f.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogEvent writes to the event log
|
// LogEvent writes to the event log
|
||||||
func (s *State) LogEvent(event, participantID, teamID, cat string, points int, extra ...string) {
|
func (s *State) LogEvent(event, teamID, cat string, points int, extra ...string) {
|
||||||
s.eventStream <- append(
|
s.eventStream <- append(
|
||||||
[]string{
|
[]string{
|
||||||
strconv.FormatInt(time.Now().Unix(), 10),
|
strconv.FormatInt(time.Now().Unix(), 10),
|
||||||
event,
|
event,
|
||||||
participantID,
|
|
||||||
teamID,
|
teamID,
|
||||||
cat,
|
cat,
|
||||||
strconv.Itoa(points),
|
strconv.Itoa(points),
|
||||||
|
@ -428,7 +426,15 @@ func (s *State) updateCaches() {
|
||||||
s.pointsLog = pointsLog
|
s.pointsLog = pointsLog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only do this if the teams directory has a newer mtime; directories with
|
||||||
|
// hundreds of team names can cause NFS I/O storms
|
||||||
{
|
{
|
||||||
|
_, ismmfs := s.Fs.(*afero.MemMapFs) // Tests run so quickly that the time check isn't precise enough
|
||||||
|
if fi, err := s.Fs.Stat("teams"); err != nil {
|
||||||
|
log.Printf("Getting modification time of teams directory: %v", err)
|
||||||
|
} else if ismmfs || s.teamNamesLastChange.Before(fi.ModTime()) {
|
||||||
|
s.teamNamesLastChange = fi.ModTime()
|
||||||
|
|
||||||
// The compiler recognizes this as an optimization case
|
// The compiler recognizes this as an optimization case
|
||||||
for k := range s.teamNames {
|
for k := range s.teamNames {
|
||||||
delete(s.teamNames, k)
|
delete(s.teamNames, k)
|
||||||
|
@ -448,18 +454,14 @@ func (s *State) updateCaches() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if bMessages, err := afero.ReadFile(s, "messages.html"); err == nil {
|
|
||||||
s.messages = string(bMessages)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *State) refresh() {
|
func (s *State) refresh() {
|
||||||
s.maybeInitialize()
|
s.maybeInitialize()
|
||||||
s.updateEnabled()
|
s.updateEnabled()
|
||||||
if s.Enabled {
|
if s.enabled {
|
||||||
s.collectPoints()
|
s.collectPoints()
|
||||||
}
|
}
|
||||||
s.updateCaches()
|
s.updateCaches()
|
||||||
|
@ -507,6 +509,9 @@ func (ds *DevelState) TeamName(teamID string) (string, error) {
|
||||||
if name, err := ds.StateProvider.TeamName(teamID); err == nil {
|
if name, err := ds.StateProvider.TeamName(teamID); err == nil {
|
||||||
return name, nil
|
return name, nil
|
||||||
}
|
}
|
||||||
|
if teamID == "" {
|
||||||
|
return "", fmt.Errorf("empty team ID")
|
||||||
|
}
|
||||||
return fmt.Sprintf("«devel:%s»", teamID), nil
|
return fmt.Sprintf("«devel:%s»", teamID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,8 +17,16 @@ func NewTestState() *State {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func slurp(c chan bool) {
|
||||||
|
for range c {
|
||||||
|
// Nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestState(t *testing.T) {
|
func TestState(t *testing.T) {
|
||||||
s := NewTestState()
|
s := NewTestState()
|
||||||
|
defer close(s.refreshNow)
|
||||||
|
go slurp(s.refreshNow)
|
||||||
|
|
||||||
mustExist := func(path string) {
|
mustExist := func(path string) {
|
||||||
_, err := s.Fs.Stat(path)
|
_, err := s.Fs.Stat(path)
|
||||||
|
@ -33,7 +41,6 @@ func TestState(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
mustExist("initialized")
|
mustExist("initialized")
|
||||||
mustExist("enabled")
|
|
||||||
mustExist("hours.txt")
|
mustExist("hours.txt")
|
||||||
|
|
||||||
teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt")
|
teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt")
|
||||||
|
@ -157,16 +164,19 @@ func TestStateOutOfOrderAward(t *testing.T) {
|
||||||
|
|
||||||
func TestStateEvents(t *testing.T) {
|
func TestStateEvents(t *testing.T) {
|
||||||
s := NewTestState()
|
s := NewTestState()
|
||||||
s.LogEvent("moo", "", "", "", 0)
|
s.LogEvent("moo", "", "", 0)
|
||||||
s.LogEvent("moo 2", "", "", "", 0)
|
s.LogEvent("moo 2", "", "", 0)
|
||||||
|
|
||||||
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "init::::0" {
|
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "init:::0" {
|
||||||
t.Error("Wrong message from event stream:", msg)
|
t.Error("Wrong message from event stream:", msg)
|
||||||
}
|
}
|
||||||
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo::::0" {
|
if msg := <-s.eventStream; !strings.HasPrefix(msg[5], "state/hours.txt") {
|
||||||
|
t.Error("Wrong message from event stream:", msg[5])
|
||||||
|
}
|
||||||
|
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo:::0" {
|
||||||
t.Error("Wrong message from event stream:", msg)
|
t.Error("Wrong message from event stream:", msg)
|
||||||
}
|
}
|
||||||
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo 2::::0" {
|
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo 2:::0" {
|
||||||
t.Error("Wrong message from event stream:", msg)
|
t.Error("Wrong message from event stream:", msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -175,7 +185,7 @@ func TestStateDisabled(t *testing.T) {
|
||||||
s := NewTestState()
|
s := NewTestState()
|
||||||
s.refresh()
|
s.refresh()
|
||||||
|
|
||||||
if !s.Enabled {
|
if !s.Enabled() {
|
||||||
t.Error("Brand new state is disabled")
|
t.Error("Brand new state is disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,62 +194,72 @@ func TestStateDisabled(t *testing.T) {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
defer hoursFile.Close()
|
defer hoursFile.Close()
|
||||||
|
s.refresh()
|
||||||
|
if !s.Enabled() {
|
||||||
|
t.Error("Empty hours.txt not enabled")
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Fprintln(hoursFile, "- 1970-01-01T01:01:01Z")
|
fmt.Fprintln(hoursFile, "- 1970-01-01T01:01:01Z")
|
||||||
hoursFile.Sync()
|
hoursFile.Sync()
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if s.Enabled {
|
if s.Enabled() {
|
||||||
t.Error("Disabling 1970-01-01")
|
t.Error("1970-01-01")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(hoursFile, "+ 1970-01-01 01:01:01+05:00")
|
fmt.Fprintln(hoursFile, "+ 1970-01-02 01:01:01+05:00")
|
||||||
hoursFile.Sync()
|
hoursFile.Sync()
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if !s.Enabled {
|
if !s.Enabled() {
|
||||||
t.Error("Enabling 1970-01-02")
|
t.Error("1970-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(hoursFile, "-")
|
||||||
|
hoursFile.Sync()
|
||||||
|
s.refresh()
|
||||||
|
if s.Enabled() {
|
||||||
|
t.Error("bare -")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(hoursFile, "+")
|
||||||
|
hoursFile.Sync()
|
||||||
|
s.refresh()
|
||||||
|
if !s.Enabled() {
|
||||||
|
t.Error("bare +")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(hoursFile, "")
|
fmt.Fprintln(hoursFile, "")
|
||||||
fmt.Fprintln(hoursFile, "# Comment")
|
fmt.Fprintln(hoursFile, "# Comment")
|
||||||
hoursFile.Sync()
|
hoursFile.Sync()
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if !s.Enabled {
|
if !s.Enabled() {
|
||||||
t.Error("Comments")
|
t.Error("Comment")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(hoursFile, "intentional parse error")
|
fmt.Fprintln(hoursFile, "intentional parse error")
|
||||||
hoursFile.Sync()
|
hoursFile.Sync()
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if !s.Enabled {
|
if !s.Enabled() {
|
||||||
t.Error("intentional parse error")
|
t.Error("intentional parse error")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(hoursFile, "- 1980-01-01T01:01:01Z")
|
fmt.Fprintln(hoursFile, "- 1980-01-01T01:01:01Z")
|
||||||
hoursFile.Sync()
|
hoursFile.Sync()
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if s.Enabled {
|
if s.Enabled() {
|
||||||
t.Error("Disabling 1980-01-01")
|
t.Error("1980-01-01")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Remove("hours.txt"); err != nil {
|
if err := s.Remove("hours.txt"); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if !s.Enabled {
|
if !s.Enabled() {
|
||||||
t.Error("Removing `hours.txt` disabled event")
|
t.Error("Removing `hours.txt` disabled event")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Remove("enabled"); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
s.refresh()
|
|
||||||
if s.Enabled {
|
|
||||||
t.Error("Removing `enabled` didn't disable")
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Remove("initialized")
|
s.Remove("initialized")
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if !s.Enabled {
|
if !s.Enabled() {
|
||||||
t.Error("Re-initializing didn't start event")
|
t.Error("Re-initializing didn't start event")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -266,7 +286,7 @@ func TestStateMaintainer(t *testing.T) {
|
||||||
t.Error("Team ID too short:", teamID)
|
t.Error("Team ID too short:", teamID)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.LogEvent("Hello!", "", "", "", 0)
|
s.LogEvent("Hello!", "", "", 0)
|
||||||
|
|
||||||
if len(s.PointsLog()) != 0 {
|
if len(s.PointsLog()) != 0 {
|
||||||
t.Error("Points log is not empty")
|
t.Error("Points log is not empty")
|
||||||
|
@ -291,11 +311,11 @@ func TestStateMaintainer(t *testing.T) {
|
||||||
eventLog, err := afero.ReadFile(s.Fs, "events.csv")
|
eventLog, err := afero.ReadFile(s.Fs, "events.csv")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
} else if events := strings.Split(string(eventLog), "\n"); len(events) != 3 {
|
} else if events := strings.Split(string(eventLog), "\n"); len(events) != 4 {
|
||||||
t.Log("Events:", events)
|
t.Log("Events:", events)
|
||||||
t.Error("Wrong event log length:", len(events))
|
t.Error("Wrong event log length:", len(events))
|
||||||
} else if events[2] != "" {
|
} else if events[3] != "" {
|
||||||
t.Error("Event log didn't end with newline")
|
t.Error("Event log didn't end with newline", events)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -38,3 +38,7 @@ func (t *Theme) Open(name string) (ReadSeekCloser, time.Time, error) {
|
||||||
func (t *Theme) Maintain(i time.Duration) {
|
func (t *Theme) Maintain(i time.Duration) {
|
||||||
// No periodic tasks for a theme
|
// No periodic tasks for a theme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *Theme) refresh() {
|
||||||
|
// Nothing to do for a theme
|
||||||
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dirtbags/moth/pkg/transpile"
|
"github.com/dirtbags/moth/v4/pkg/transpile"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -79,3 +79,7 @@ func (p TranspilerProvider) Mothball(cat string, w io.Writer) error {
|
||||||
func (p TranspilerProvider) Maintain(updateInterval time.Duration) {
|
func (p TranspilerProvider) Maintain(updateInterval time.Duration) {
|
||||||
// Nothing to do here.
|
// Nothing to do here.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p TranspilerProvider) refresh() {
|
||||||
|
// Nothing to do for a theme
|
||||||
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"github.com/dirtbags/moth/pkg/transpile"
|
"github.com/dirtbags/moth/v4/pkg/transpile"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
|
@ -10,7 +10,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dirtbags/moth/pkg/transpile"
|
"github.com/dirtbags/moth/v4/pkg/transpile"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
#! /bin/sh
|
||||||
|
|
||||||
|
url=${1%/}
|
||||||
|
teamid=$2
|
||||||
|
|
||||||
|
case "$url:$teamid" in
|
||||||
|
*:|-h*|--h*)
|
||||||
|
cat <<EOD; exit 1
|
||||||
|
Usage: $0 MOTHURL TEAMID
|
||||||
|
|
||||||
|
Downloads all content currently open,
|
||||||
|
and writes it out to a zip file.
|
||||||
|
|
||||||
|
MOTHURL URL to the instance
|
||||||
|
TEAMID Team ID you used to log in
|
||||||
|
EOD
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
tmpdir=$(mktemp -d moth-dl.XXXXXX)
|
||||||
|
bye () {
|
||||||
|
echo "bye now"
|
||||||
|
rm -rf $tmpdir
|
||||||
|
}
|
||||||
|
trap bye EXIT
|
||||||
|
|
||||||
|
fetch () {
|
||||||
|
curl -s -d id=$teamid "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== Fetching puzzles and attachments"
|
||||||
|
fetch $url/state > $tmpdir/state.json
|
||||||
|
cat $tmpdir/state.json \
|
||||||
|
| jq -r '.Puzzles | to_entries[] | .key as $k | .value[] | select (. > 0) | "\($k) \(.)"' \
|
||||||
|
| while read cat points; do
|
||||||
|
echo " + $cat $points"
|
||||||
|
dir=$tmpdir/$cat/$points
|
||||||
|
mkdir -p $dir
|
||||||
|
fetch $url/content/$cat/$points/puzzle.json > $dir/puzzle.json
|
||||||
|
cat $dir/puzzle.json | jq .Body > $dir/puzzle.html
|
||||||
|
cat $dir/puzzle.json | jq -r '.Attachments[]?' | while read attachment; do
|
||||||
|
echo " - $attachment"
|
||||||
|
fetch $url/content/$cat/$points/$attachment > $dir/$attachment
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
zipfile=$(echo $url | grep -o '[a-z]*\.[a-z.]*').zip
|
||||||
|
echo "=== Writing $zipfile"
|
||||||
|
(cd $tmpdir && zip -r - .) > $zipfile
|
||||||
|
|
||||||
|
echo "=== Wrote $zipfile"
|
|
@ -0,0 +1,79 @@
|
||||||
|
Frequently Asked Questions
|
||||||
|
=================
|
||||||
|
|
||||||
|
I should probably move this somewhere else,
|
||||||
|
since most of it is about
|
||||||
|
|
||||||
|
Main Application Questions
|
||||||
|
=====================
|
||||||
|
|
||||||
|
## Can we add some instructions to the user interface? It's confusing.
|
||||||
|
|
||||||
|
The lack of instruction was a deliberate design decision made about 9 years ago
|
||||||
|
when we found in A/B testing that college students are a lot more motivated by
|
||||||
|
vague instruction and mystery than precise instruction. We've since found that
|
||||||
|
people who are inclined to "play" our events are similarly motivated by
|
||||||
|
weirdness and mystery: they enjoy fiddling around with things until they've
|
||||||
|
worked it out experimentally.
|
||||||
|
|
||||||
|
Oddly, the group who seems to be the most perturbed by the vagueness is
|
||||||
|
professionals. This may be because many of these folks spend long amounts of
|
||||||
|
time trying to make things accessible and precise, and this looks like a train
|
||||||
|
wreck from that perspective.
|
||||||
|
|
||||||
|
Another way to think about it: this is supposed to be a game, like Super Mario
|
||||||
|
Brothers. We were very careful about designing the puzzles so that you could
|
||||||
|
learn by playing. The whimsical design is meant to make it feel like trying
|
||||||
|
things out will not result in a catastrophic failure anywhere, and we've found
|
||||||
|
that most people figure it out very quickly without any instruction at all,
|
||||||
|
despite feeling a little confused or disoriented at first.
|
||||||
|
|
||||||
|
## Why can't I choose my team from a list when I log in?
|
||||||
|
|
||||||
|
We actually started this way, but we quickly learned that there were exploitable
|
||||||
|
attack avenues available when any participant can join any team. One individual
|
||||||
|
in 2010, having a bad day, decided to enter every answer they had, for every
|
||||||
|
team in the contest, as a way of sabotaging the event. It worked: everyone's
|
||||||
|
motivation to try and solve puzzles tanked, and people were angry that they'd
|
||||||
|
been working on content only to find that they already had the points.
|
||||||
|
|
||||||
|
## Why won't you add this helpful text to the login page?
|
||||||
|
|
||||||
|
It has been our experience that the more words we have on that page, the less
|
||||||
|
likely any of them will be read. We strive now to have no instruction at all,
|
||||||
|
and to design the interface in a way that it's obvious what you have to do.
|
||||||
|
|
||||||
|
## Why aren't we providing a link to the scoreboard?
|
||||||
|
|
||||||
|
It's because the scoreboard looks horrible on a mobile phone:
|
||||||
|
it was designed for a projector.
|
||||||
|
Once we have a scoreboard that is readable on mobile,
|
||||||
|
I'll add that link.
|
||||||
|
|
||||||
|
## Why can't we show a list of teams to log in to?
|
||||||
|
|
||||||
|
At a previous event,
|
||||||
|
we had a participant log in as other teams and solve every puzzle,
|
||||||
|
because they were upset about something.
|
||||||
|
This ruined the event for everyone,
|
||||||
|
because it took away the challenge of scoring points.
|
||||||
|
|
||||||
|
|
||||||
|
Scoreboard Questions
|
||||||
|
=================
|
||||||
|
|
||||||
|
## Why are there no links or title on the scoreboard?
|
||||||
|
|
||||||
|
The scoreboard is supposed to be projected at events, to participants. The current scoreboard isn't something we intend participants to pull up on their mobile devices or laptops.
|
||||||
|
|
||||||
|
Think of the scoreboard as sort of like the menu screens at Burger King.
|
||||||
|
|
||||||
|
|
||||||
|
## Will you change the scoreboard color scheme?
|
||||||
|
|
||||||
|
The scoreboard colors and layout were carefully chosen to be distinguishable for
|
||||||
|
all forms of color blindness, and even accessible by users with total blindness
|
||||||
|
using screen readers. This is why we decided to put the category name inside the
|
||||||
|
bar and just deal with it being a little weird.
|
||||||
|
|
||||||
|
I'm open to suggestions, but they need to work for all users.
|
|
@ -45,8 +45,8 @@ Scores
|
||||||
Pausing/resuming scoring
|
Pausing/resuming scoring
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
rm /srv/moth/state/enabled # Pause scoring
|
echo '-###' >> /srv/moth/state/hours.txt # Suspend scoring
|
||||||
touch /srv/moth/state/enabled # Resume scoring
|
sed -i '/###/d' /srv/moth/state/hours.txt # Resume scoring
|
||||||
|
|
||||||
When scoring is paused,
|
When scoring is paused,
|
||||||
participants can still submit answers,
|
participants can still submit answers,
|
||||||
|
@ -54,12 +54,13 @@ and the system will tell them whether the answer is correct.
|
||||||
As soon as you unpause,
|
As soon as you unpause,
|
||||||
all correctly-submitted answers will be scored.
|
all correctly-submitted answers will be scored.
|
||||||
|
|
||||||
|
|
||||||
Adjusting scores
|
Adjusting scores
|
||||||
------------------
|
------------------
|
||||||
|
|
||||||
rm /srv/moth/state/enabled # Suspend scoring
|
echo '-###' >> /srv/moth/state/hours.txt # Suspend scoring
|
||||||
nano /srv/moth/state/points.log # Replace nano with your preferred editor
|
nano /srv/moth/state/points.log # Replace nano with your preferred editor
|
||||||
touch /srv/moth/state/enabled # Resume scoring
|
sed -i '/###/d' /srv/moth/state/hours.txt # Resume scoring
|
||||||
|
|
||||||
We don't warn participants before we do this:
|
We don't warn participants before we do this:
|
||||||
any points scored while scoring is suspended are queued up and processed as soon as scoreing is resumed.
|
any points scored while scoring is suspended are queued up and processed as soon as scoreing is resumed.
|
||||||
|
|
|
@ -0,0 +1,163 @@
|
||||||
|
MOTH Client API
|
||||||
|
===========
|
||||||
|
|
||||||
|
MOTH provides a WebDAV interface:
|
||||||
|
this is described in
|
||||||
|
[MOTH Client Directory Structure](client-structure.md).
|
||||||
|
|
||||||
|
This document explains the WebDAV directory structure
|
||||||
|
as though it were a REST API.
|
||||||
|
These endpoints are a subset of the functionality provided,
|
||||||
|
but should be sufficient for many use cases.
|
||||||
|
|
||||||
|
Theme
|
||||||
|
======
|
||||||
|
|
||||||
|
Theme files are served as static content,
|
||||||
|
just like any standard web server.
|
||||||
|
|
||||||
|
### `GET` `/theme/${path}` - Retrieve File
|
||||||
|
|
||||||
|
|
||||||
|
Puzzles
|
||||||
|
======
|
||||||
|
|
||||||
|
Static Files
|
||||||
|
----------
|
||||||
|
|
||||||
|
With the exception of the `answer` file,
|
||||||
|
puzzle files are served as static content.
|
||||||
|
|
||||||
|
The entry point to a puzzle is `index.html`:
|
||||||
|
see [Puzzle Format](puzzle-format.md)
|
||||||
|
for details on its structure.
|
||||||
|
|
||||||
|
### `GET` `/puzzles/${category}/${points}/${filename}` - Retrieve File
|
||||||
|
|
||||||
|
|
||||||
|
Answer Submission
|
||||||
|
------------------
|
||||||
|
|
||||||
|
### `GET` `/puzzles/${category}/${points}/answer` - not supported
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| http code | meaning |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 405 | `GET` method is not supported |
|
||||||
|
|
||||||
|
|
||||||
|
### `POST` `/puzzles/${category}/${points}/answer` - Submit Answer
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| http code | meaning |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 200 | Answer is correct, and points are awarded |
|
||||||
|
| 202 | Answer is correct, but points have already been awarded for this puzzle |
|
||||||
|
| 409 | Answer is incorrect |
|
||||||
|
| 401 | Authentication is invalid (bad team ID) |
|
||||||
|
|
||||||
|
|
||||||
|
State
|
||||||
|
====
|
||||||
|
|
||||||
|
Points Log
|
||||||
|
--------
|
||||||
|
|
||||||
|
The points log contains a history of correct answer submission.
|
||||||
|
Each submission is terminated by a newline (`\n`)
|
||||||
|
and consists of space-separated fields
|
||||||
|
of the format:
|
||||||
|
|
||||||
|
${timestamp} ${team_id} ${category} ${points}
|
||||||
|
|
||||||
|
### `GET` `/state/points.log` - Retrieve points log
|
||||||
|
|
||||||
|
| http code | meaning |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 200 | Points log in payload (text/plain) |
|
||||||
|
| 401 | Authentication is invalid (bad team ID) |
|
||||||
|
|
||||||
|
|
||||||
|
Team Name
|
||||||
|
--------
|
||||||
|
|
||||||
|
### `GET` `/state/self/name` - Retrieve my team name
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| http code | meaning |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 200 | Team ID in payload (text/plain) |
|
||||||
|
| 401 | Authentication is invalid (bad team ID) |
|
||||||
|
|
||||||
|
|
||||||
|
### `POST` `/state/self/name` - Set my team name
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| http code | meaning |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 200 | Team ID is valid, and team name was recorded |
|
||||||
|
| 202 | Team ID is valid, but team name was previously set and cannot be changed |
|
||||||
|
| 401 | Authentication is invalid (bad team ID) |
|
||||||
|
|
||||||
|
|
||||||
|
Public Data
|
||||||
|
--------
|
||||||
|
|
||||||
|
Up to 4096 bytes of arbitrary public data per team may be stored on the server.
|
||||||
|
This data can be viewed by any authenticated team.
|
||||||
|
|
||||||
|
There are no restrictions on the content of the data:
|
||||||
|
clients are free to store whatever they want.
|
||||||
|
|
||||||
|
### `GET` `/state/${id}/public.bin` - Retrieve public data
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| http code | meaning |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 200 | Data follows (application/octet-stream) |
|
||||||
|
| 401 | Authentication is invalid (bad team ID) |
|
||||||
|
|
||||||
|
|
||||||
|
### `PUT` `/state/${id}/public.bin` - Upload public data
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| http code | meaning |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 200 | Data follows (application/octet-stream) |
|
||||||
|
| 401 | Authentication is invalid (bad team ID) |
|
||||||
|
|
||||||
|
|
||||||
|
Private Data
|
||||||
|
--------
|
||||||
|
|
||||||
|
Up to 4096 bytes of arbitrary data per team may be stored on the server.
|
||||||
|
This data is only accessible by an authenticated request,
|
||||||
|
and is private to the authenticated team.
|
||||||
|
|
||||||
|
There are no restrictions on the content of the data:
|
||||||
|
clients are free to store whatever they want.
|
||||||
|
|
||||||
|
### `GET` `/state/self/private.bin` - Retrieve private data
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| http code | meaning |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 200 | Data follows (application/octet-stream) |
|
||||||
|
| 401 | Authentication is invalid (bad team ID) |
|
||||||
|
|
||||||
|
|
||||||
|
### `POST` `/state/self/private.bin` - Upload private data
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| http code | meaning |
|
||||||
|
| ---- | ---- |
|
||||||
|
| 200 | Data follows (application/octet-stream) |
|
||||||
|
| 401 | Authentication is invalid (bad team ID) |
|
432
docs/api.md
432
docs/api.md
|
@ -1,432 +0,0 @@
|
||||||
Moth APIs
|
|
||||||
=======
|
|
||||||
|
|
||||||
This document covers the following interfaces:
|
|
||||||
|
|
||||||
* HTTP Endpoints: what the Moth client sends the Moth server
|
|
||||||
* Puzzle executable: how the transpiler communicates with executables that provide puzzles
|
|
||||||
* Category executable: how the transpiler communicates with executables that provide categories
|
|
||||||
* Provider executable: how Moth communicates with things that provide puzzles (like the transpiler)
|
|
||||||
|
|
||||||
The Puzzle, Category, and Provider executalbes are all very closely related, since each is a subset of the next.
|
|
||||||
|
|
||||||
----
|
|
||||||
|
|
||||||
Here's a bad diagram of how this all fits together. I don't know if this is going to help at all. Please submit a merge request with something better.
|
|
||||||
|
|
||||||
HTTP provider API mothball API
|
|
||||||
🡗 🡗 🡗
|
|
||||||
client - mothd - mothball provider - category1.mb
|
|
||||||
|
|
||||||
- custom provider
|
|
||||||
category API
|
|
||||||
🡗
|
|
||||||
- internal transpiler - category2/mkcategory
|
|
||||||
|
|
||||||
- category3/1/puzzle.md
|
|
||||||
- category3/2/mkpuzzle
|
|
||||||
🡔
|
|
||||||
puzzle API
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# HTTP Endpoints
|
|
||||||
|
|
||||||
The Moth server accepts
|
|
||||||
standard HTTP `GET` and `POST`.
|
|
||||||
|
|
||||||
Parameters may be encoded with standard `GET` query parameters
|
|
||||||
(like `GET /endpoint?a=1&b=2`),
|
|
||||||
or with `POST` as `application/x-www-form-encoded` data.
|
|
||||||
|
|
||||||
## `/state`
|
|
||||||
|
|
||||||
Returns the current Moth event state as a JSON object.
|
|
||||||
|
|
||||||
### Parameters
|
|
||||||
* `id`: team ID (optional)
|
|
||||||
|
|
||||||
### Return
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
"Config": {
|
|
||||||
"Devel": false // true means this is a development server
|
|
||||||
},
|
|
||||||
"Messages: "HTML to be rendered as broadcast messages",
|
|
||||||
"TeamNames": {
|
|
||||||
"self": "Requesting team name", // Only if regestered team id is a provided
|
|
||||||
"0": "Team 1 Name",
|
|
||||||
"1": "Team 2 Name"
|
|
||||||
// ...
|
|
||||||
},
|
|
||||||
"PointsLog": [
|
|
||||||
[1602679698, "0", "category", 1] // epochTime, teamID, category, points
|
|
||||||
// ...
|
|
||||||
],
|
|
||||||
"Puzzles": {
|
|
||||||
"category": [1, 2, 3, 6] // list of unlocked puzzles for category
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example HTTP transaction
|
|
||||||
|
|
||||||
#### Request
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /state HTTP/1.0
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
This response has been reflowed for readability:
|
|
||||||
an actual on-wire response would not have newlines or indentation.
|
|
||||||
|
|
||||||
```
|
|
||||||
HTTP/1.0 200 OK
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{"Config":
|
|
||||||
{"Devel":false},
|
|
||||||
"Messages":"<p>Welcome to the event!</p><p>Event ends at 19:00!</p>",
|
|
||||||
"TeamNames":{
|
|
||||||
"0":"Mike and Jack",
|
|
||||||
"12":"Team 2",
|
|
||||||
"4":"Team 8"
|
|
||||||
},
|
|
||||||
"PointsLog":[
|
|
||||||
[1602702696,"0","nocode",1],
|
|
||||||
[1602702705,"0","sequence",1],
|
|
||||||
[1602702787,"0","nocode",2],
|
|
||||||
[1602702831,"0","sequence",2],
|
|
||||||
[1602702839,"4","nocode",3],
|
|
||||||
[1602702896,"0","sequence",8],
|
|
||||||
[1602702900,"4","nocode",4],
|
|
||||||
[1602702913,"0","sequence",16]
|
|
||||||
],
|
|
||||||
"Puzzles":{
|
|
||||||
"indy":[12],
|
|
||||||
"nocode":[1,2,3,4,10],
|
|
||||||
"sequence":[1,2,8,16,19],
|
|
||||||
"steg":[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## `/register`
|
|
||||||
|
|
||||||
Registers a name to a team ID.
|
|
||||||
|
|
||||||
This is only required once per team,
|
|
||||||
but user interfaces may find it less confusing to users
|
|
||||||
to present a "login" page.
|
|
||||||
For this reason "this team is already registered"
|
|
||||||
does not return an error.
|
|
||||||
|
|
||||||
### Parameters
|
|
||||||
* `id`: team ID
|
|
||||||
* `name`: team name
|
|
||||||
|
|
||||||
### Return
|
|
||||||
|
|
||||||
An object inspired by [JSend](https://github.com/omniti-labs/jsend):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "success/fail/error",
|
|
||||||
"data": {
|
|
||||||
"short": "short description",
|
|
||||||
"description": "long description"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example HTTP transaction
|
|
||||||
|
|
||||||
#### Request
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /register HTTP/1.0
|
|
||||||
Content-Type: application/x-www-form-urlencoded
|
|
||||||
Content-Length: 26
|
|
||||||
|
|
||||||
id=b387ca98&name=dirtbags
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Repsonse
|
|
||||||
|
|
||||||
```
|
|
||||||
HTTP/1.0 200 OK
|
|
||||||
Content-Type: application/json
|
|
||||||
Content-Length=86
|
|
||||||
|
|
||||||
{"status":"success","data":{"short":"registered","description":"Team ID registered"}}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## `/answer`
|
|
||||||
|
|
||||||
Submits an answer for points.
|
|
||||||
|
|
||||||
If the answer is wrong, no points are awarded 😉
|
|
||||||
|
|
||||||
### Parameters
|
|
||||||
* `id`: team ID
|
|
||||||
* `category`: along with `points`, uniquely identifies a puzzle
|
|
||||||
* `points`: along with `category`, uniquely identifies a puzzle
|
|
||||||
|
|
||||||
### Return
|
|
||||||
|
|
||||||
An object inspired by [JSend](https://github.com/omniti-labs/jsend):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "success/fail/error",
|
|
||||||
"data": {
|
|
||||||
"short": "short description",
|
|
||||||
"description": "long description"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example HTTP transaction
|
|
||||||
|
|
||||||
#### Request
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /answer HTTP/1.0
|
|
||||||
Content-Type: application/x-www-form-urlencoded
|
|
||||||
Content-Length: 62
|
|
||||||
|
|
||||||
id=b387ca98&category=sequence&points=2&answer=achilles+turnip
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Repsonse
|
|
||||||
|
|
||||||
```
|
|
||||||
HTTP/1.0 200 OK
|
|
||||||
Content-Type: application/json
|
|
||||||
Content-Length=83
|
|
||||||
|
|
||||||
{"status":"fail","data":{"short":"not accepted","description":"Incorrect answer"}}
|
|
||||||
```
|
|
||||||
|
|
||||||
## `/content/{category}/{points}/puzzle.json`
|
|
||||||
|
|
||||||
Retrieves the JSON object describing a puzzle.
|
|
||||||
|
|
||||||
Parameters are all in the URL for this endpoint,
|
|
||||||
so `curl` and `wget` can be used.
|
|
||||||
|
|
||||||
### Parameters
|
|
||||||
* `{category}` (in URL): along with `{points}`, uniquely identifies a puzzle
|
|
||||||
* `{points}` (in URL): along with `{category}`, uniquely identifies a puzzle
|
|
||||||
* `{filename}` (in URL): filename to retrieve
|
|
||||||
|
|
||||||
### Return
|
|
||||||
|
|
||||||
JSON object describing a puzzle.
|
|
||||||
|
|
||||||
#### JSON Puzzle Object
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
"Pre": { // Things which appear before the puzzle is solved
|
|
||||||
"Authors": ["Neale Pickett"], // List of puzzle authors, usually rendered as a footnote
|
|
||||||
"Attachments": ["tiger.jpg"], // List of files attached to the puzzle
|
|
||||||
"Scripts": [], // List of scripts which should be included in the HTML render of the puzzle
|
|
||||||
"Body": "<p>Can you find the hidden text?</p><p><img src=\"tiger.jpg\" alt=\"Grr\" /></p>\n", // HTML puzzle body
|
|
||||||
"AnswerPattern": "", // Regular expression to include in HTML input tag for validation
|
|
||||||
"AnswerHashes": [ // List of SHA265 hashes of correct answers, for client-side answer checking
|
|
||||||
"f91b1fe875cdf9e969e5bccd3e259adec5a987dcafcbc9ca8da62e341a7f29c6"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Post": { // Things reveal after the puzzle is solved
|
|
||||||
"Objective": "Learn to examine images for hidden text", // Learning objective
|
|
||||||
"Success": { // Measures of learning success
|
|
||||||
"Acceptable": "Visually examine image to find hidden text",
|
|
||||||
"Mastery": "Visually examine image to find hidden text"
|
|
||||||
},
|
|
||||||
"KSAs": null // Knowledge, Skills, and Abilities covered by this puzzle
|
|
||||||
},
|
|
||||||
"Debug": { // Debugging output used in development: all fields are emptied when making mothballs
|
|
||||||
"Log": [ // Debug message log
|
|
||||||
"Input image size: 600x400",
|
|
||||||
"Applying gaussian blur",
|
|
||||||
"Text width 58, left offset 513",
|
|
||||||
"Complete in 0.028s"
|
|
||||||
],
|
|
||||||
"Errors": [], // Errors encountered generating this puzzzle
|
|
||||||
"Hints": [ // Hints for instructional assistants to provide to participants
|
|
||||||
"Zoom in to the image and examine all sections carefully"
|
|
||||||
],
|
|
||||||
"Summary": "text in image" // Summary of this puzzle, to help identify it in an overview of puzzles
|
|
||||||
},
|
|
||||||
"Answers": ["sandwich"] // List of answers: empty in production
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### Example HTTP transaction
|
|
||||||
|
|
||||||
#### Request
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /content/sequence/1/puzzle.json HTTP/1.0
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Repsonse
|
|
||||||
|
|
||||||
```
|
|
||||||
HTTP/1.0 200 OK
|
|
||||||
Content-Type: application/json
|
|
||||||
Content-Length: 397
|
|
||||||
|
|
||||||
{"Pre":{"Authors":["neale"],"Attachments":[],"Scripts":[],"Body":"\u003cp\u003e1 2 3 4 5 ⬜\u003c/p\u003e\n","AnswerPattern":"","AnswerHashes":["e7f6c011776e8db7cd330b54174fd76f7d0216b612387a5ffcfb81e6f0919683"]},"Post":{"Objective":"","Success":{"Acceptable":"","Mastery":""},"KSAs":null},"Debug":{"Log":[],"Errors":[],"Hints":[],"Summary":"Simple introduction to how this works"},"Answers":[]}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## `/content/{category}/{points}/{filename}`
|
|
||||||
|
|
||||||
Retrieves static content associated with a puzzle.
|
|
||||||
|
|
||||||
Parameters are all in the URL for this endpoint,
|
|
||||||
so `curl` and `wget` can be used.
|
|
||||||
|
|
||||||
### Parameters
|
|
||||||
* `{category}` (in URL): along with `{points}`, uniquely identifies a puzzle
|
|
||||||
* `{points}` (in URL): along with `{category}`, uniquely identifies a puzzle
|
|
||||||
* `{filename}` (in URL): filename to retrieve
|
|
||||||
|
|
||||||
### Return
|
|
||||||
|
|
||||||
Raw file octets,
|
|
||||||
with a (hopefully) suitable
|
|
||||||
`Content-type` HTTP header field.
|
|
||||||
|
|
||||||
### Example HTTP transaction
|
|
||||||
|
|
||||||
#### Request
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /content/sequence/1/attachment.txt HTTP/1.0
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Repsonse
|
|
||||||
|
|
||||||
```
|
|
||||||
HTTP/1.0 200 OK
|
|
||||||
Content-Type: text/plain
|
|
||||||
Content-Length: 98
|
|
||||||
|
|
||||||
This is an attachment file! This is just plain text for the example. Many attachments are JPEGs.
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
# Puzzle
|
|
||||||
|
|
||||||
A puzzle contains one question and one or more associated answers.
|
|
||||||
Puzzles are not aware of their point value: this is set by the category they are in.
|
|
||||||
|
|
||||||
Puzzle executables must be named `mkpuzzle`.
|
|
||||||
|
|
||||||
|
|
||||||
## `mkpuzzle puzzle`
|
|
||||||
|
|
||||||
puzzles/category3/1 $ ./mkpuzzle puzzle
|
|
||||||
{JSON PUZZLE OBJECT}
|
|
||||||
|
|
||||||
Also see [JSON Puzzle Object](#json-puzzle-object)
|
|
||||||
|
|
||||||
|
|
||||||
## `mkpuzzle file {filename}`
|
|
||||||
|
|
||||||
puzzles/category3/1 $ ./mkpuzzle file attachment.txt
|
|
||||||
This is an attachment file! It's just plain text for this example. Many attachments are JPEGs.
|
|
||||||
|
|
||||||
|
|
||||||
## `mkpuzzle answer {answer}`
|
|
||||||
|
|
||||||
puzzles/category3/1 $ ./mkpuzzle answer "cow goes moo"
|
|
||||||
{"Correct":false}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Category
|
|
||||||
|
|
||||||
Categories are collections of puzzles.
|
|
||||||
Each puzzle has a unique point value, determined by the category.
|
|
||||||
|
|
||||||
Category executables must be called `mkcategory`.
|
|
||||||
|
|
||||||
## `mkcategory inventory`
|
|
||||||
|
|
||||||
puzzles/category2 $ ./mkcategory inventory
|
|
||||||
{"Puzzles": [1, 2, 3, 5, 10, 20, 30, 50, 100]}
|
|
||||||
|
|
||||||
|
|
||||||
## `mkcategory puzzle {points}`
|
|
||||||
|
|
||||||
puzzles/category2 $ ./mkcategory puzzle 1
|
|
||||||
{JSON PUZZLE OBJECT}
|
|
||||||
|
|
||||||
Also see [JSON Puzzle Object](#json-puzzle-object)
|
|
||||||
|
|
||||||
|
|
||||||
## `mkcategory file {points} {filename}`
|
|
||||||
|
|
||||||
puzzles/category2 $ ./mkcategory file 1 attachment.txt
|
|
||||||
This is an attachment file's contents!
|
|
||||||
|
|
||||||
|
|
||||||
## `mkcategory answer {points} {answer}`
|
|
||||||
|
|
||||||
puzzles/category2 $ ./mkcategory answer 1 "cow goes moo"
|
|
||||||
{"Correct":false}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Provider API
|
|
||||||
|
|
||||||
This is how Claire gets her dynamic graders.
|
|
||||||
|
|
||||||
*Notice: this is not complete in the code base!*
|
|
||||||
I'm writing here how it *should* work.
|
|
||||||
If anybody wants this,
|
|
||||||
please let me know,
|
|
||||||
and I'll finish the code.
|
|
||||||
|
|
||||||
This could ostensibly be expanded to call HTTP servers,
|
|
||||||
with the four endpoints described here.
|
|
||||||
If somebody were to want such a thing.
|
|
||||||
|
|
||||||
## `provider inventory`
|
|
||||||
|
|
||||||
$ provider inventory
|
|
||||||
{
|
|
||||||
"category1": [1, 2, 3, 4, 5, 10, 20, 30],
|
|
||||||
"category2": [20, 40, 70, 150]
|
|
||||||
}
|
|
||||||
|
|
||||||
## `provider puzzle {category} {points}`
|
|
||||||
|
|
||||||
$ provider puzzle category1 20
|
|
||||||
{JSON PUZZLE OBJECT}
|
|
||||||
|
|
||||||
Also see [JSON Puzzle Object](#json-puzzle-object)
|
|
||||||
|
|
||||||
|
|
||||||
## `provider file {category} {points} {filename}`
|
|
||||||
|
|
||||||
$ provider file category1 20 attachment.txt
|
|
||||||
This is an attachment! Yay!
|
|
||||||
|
|
||||||
## `provider answer {category} {points} {answer}`
|
|
||||||
|
|
||||||
$ provider answer category1 20 "cow goes moo"
|
|
||||||
{"Correct":true}
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
MOTH Client Directory Structure
|
||||||
|
=======
|
||||||
|
|
||||||
|
MOTHv5 implements WebDAV.
|
||||||
|
Depending on the authentication level of a user,
|
||||||
|
files may be read-only, or read-write.
|
||||||
|
|
||||||
|
WebDAV allows you to mount MOTH as a local filesystem.
|
||||||
|
You are encouraged to do this,
|
||||||
|
and use this document as a reference.
|
||||||
|
|
||||||
|
|
||||||
|
Directory Structure: Participant
|
||||||
|
-----------
|
||||||
|
|
||||||
|
Here is an example list of the files available to participants.
|
||||||
|
|
||||||
|
r- /state/points.log
|
||||||
|
rw /state/self/name
|
||||||
|
rw /state/self/private.dat
|
||||||
|
rw /state/self/public.dat
|
||||||
|
r- /state/1/name
|
||||||
|
r- /state/1/public.dat
|
||||||
|
r- /state/2/name
|
||||||
|
r- /state/2/public.dat
|
||||||
|
r- /puzzles/category-a/1/index.html
|
||||||
|
rw /puzzles/category-a/1/answer
|
||||||
|
r- /puzzles/category-a/2/index.html
|
||||||
|
rw /puzzles/category-a/2/answer
|
||||||
|
r- /puzzles/category-a/2/attachment.jpg
|
||||||
|
r- /puzzles/category-b/1/index.html
|
||||||
|
rw /puzzles/category-b/1/answer
|
||||||
|
r- /theme/*
|
||||||
|
|
||||||
|
Directory Structure: Anonymous
|
||||||
|
-----------
|
||||||
|
|
||||||
|
Anonymous (unauthenticated) users
|
||||||
|
have a restricted view:
|
||||||
|
|
||||||
|
r- /state/points.log
|
||||||
|
rw /state/self/name
|
||||||
|
rw /state/self/private.dat
|
||||||
|
rw /state/self/public.dat
|
||||||
|
r- /state/1/name
|
||||||
|
r- /state/1/public.dat
|
||||||
|
r- /state/2/name
|
||||||
|
r- /state/2/public.dat
|
||||||
|
|
||||||
|
|
||||||
|
Directory Structure: Administrator
|
||||||
|
------------
|
||||||
|
|
||||||
|
Here is an example list of the files available
|
||||||
|
to an administrator.
|
Binary file not shown.
After Width: | Height: | Size: 37 KiB |
|
@ -1,105 +1,211 @@
|
||||||
Developing Content
|
Developing Content: Structure
|
||||||
============================
|
================
|
||||||
|
|
||||||
The development server shows debugging for each puzzle,
|
![Content Layout](content-layout.png)
|
||||||
and will compile puzzles on the fly.
|
|
||||||
|
|
||||||
Use it along with a text editor and shell to create new puzzles and categories.
|
MOTH content heirarchy consists of three layers:
|
||||||
|
categories, puzzles, and attachments.
|
||||||
|
|
||||||
|
Category
|
||||||
|
-------
|
||||||
|
|
||||||
Set up some example puzzles
|
A category consists of one or more puzzles.
|
||||||
---------
|
Each puzzle is named by its point value,
|
||||||
|
and each point value must be unique.
|
||||||
|
For instance,
|
||||||
|
you cannot have two 5-point puzzles in a category.
|
||||||
|
|
||||||
If you don't have puzzles of your own to start with,
|
Scoring is usually calculated by summing the
|
||||||
you can copy the example puzzles that come with the source:
|
*percentage of available points per category*.
|
||||||
|
This allows content developers to focus only on point values within a single category:
|
||||||
|
how points are assigned in other categories doesn't matter.
|
||||||
|
|
||||||
cp -r /path/to/src/moth/example-puzzles /srv/moth/puzzles
|
Puzzle
|
||||||
|
|
||||||
|
|
||||||
Run the server in development mode
|
|
||||||
---------------
|
|
||||||
|
|
||||||
These recipes run the server in the foreground,
|
|
||||||
so you can watch the access log and any error messages.
|
|
||||||
|
|
||||||
|
|
||||||
### Podman
|
|
||||||
|
|
||||||
podman run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro ghcr.io/dirtbags/moth-devel
|
|
||||||
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
|
|
||||||
docker run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro ghcr.io/dirtbags/moth-devel
|
|
||||||
|
|
||||||
### Native
|
|
||||||
|
|
||||||
I assume you've built and installed the `moth` command from the source tree.
|
|
||||||
|
|
||||||
If you don't know how to build Go packages,
|
|
||||||
please consider using Podman or Docker.
|
|
||||||
Building Go software is not a skill related to running MOTH or puzzle events,
|
|
||||||
unless you plan on hacking on the source code.
|
|
||||||
|
|
||||||
mkdir -p /srv/moth/state
|
|
||||||
cp -r /path/to/src/moth/theme /srv/moth/theme
|
|
||||||
cd /srv/moth
|
|
||||||
moth -puzzles puzzles
|
|
||||||
|
|
||||||
|
|
||||||
Log In
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
Point a browser to http://localhost:8080/ (or whatever host is running the server).
|
A puzzle consists of a few static fields,
|
||||||
You will be logged in automatically.
|
a main puzzle body,
|
||||||
|
and optionally attached files.
|
||||||
|
|
||||||
|
At a minimum,
|
||||||
|
a puzzle must contain `puzzle.md`:
|
||||||
|
a
|
||||||
|
[CommonMark Markdown](https://spec.commonmark.org/dingus/)
|
||||||
|
file with metadata.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
authors:
|
||||||
|
- Neale Pickett
|
||||||
|
answers:
|
||||||
|
- one of its legs are both the same
|
||||||
|
- One of its legs are both the same
|
||||||
|
- one of its legs are both the same.
|
||||||
|
- One of its legs are both the same.
|
||||||
|
---
|
||||||
|
|
||||||
|
This puzzle consists of a joke.
|
||||||
|
|
||||||
|
What is the difference between a duck?
|
||||||
|
```
|
||||||
|
|
||||||
|
### Puzzle Metadata
|
||||||
|
|
||||||
|
Puzzle metadata is the section of `puzzle.md` between `---`.
|
||||||
|
This section consists of [YAML](https://yaml.org/),
|
||||||
|
describing various metadata aspects of the puzzle.
|
||||||
|
|
||||||
|
At a minimum,
|
||||||
|
each puzzle should contain:
|
||||||
|
|
||||||
|
* authors: who created the puzzle
|
||||||
|
* answers: a list of answers that are considered "correct"
|
||||||
|
|
||||||
|
Other metadata a puzzle can contain:
|
||||||
|
|
||||||
|
* debug: information used only in development mode
|
||||||
|
* summary: text summarizing what this puzzle is about
|
||||||
|
* notes: any additional notes you think it's important to record
|
||||||
|
* hints: a list of hints that could aid an instructor
|
||||||
|
* objective: what the goal of this puzzle is
|
||||||
|
* ksas: a list of NICE KSAs covered by this puzzle
|
||||||
|
* success: criteria for success
|
||||||
|
* acceptable: criterion for acceptably succeeding at the task
|
||||||
|
* mastery: criterion for mastery of the task
|
||||||
|
* attachments: a list of files to attach to this puzzle (see below)
|
||||||
|
|
||||||
|
### Body
|
||||||
|
|
||||||
|
The body of a puzzle is interpreted as
|
||||||
|
[CommonMark Markdown](https://spec.commonmark.org/dingus/)
|
||||||
|
and rendered as HTML.
|
||||||
|
|
||||||
|
Attachments
|
||||||
|
-------
|
||||||
|
|
||||||
|
Any filenames listed under `attachments` in the metadata will be included
|
||||||
|
with the puzzle.
|
||||||
|
|
||||||
|
Usually,
|
||||||
|
these are listed at the bottom of each puzzle,
|
||||||
|
one by one.
|
||||||
|
But you can also refer to attachments from the puzzle body
|
||||||
|
with either links or inline images.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
This is a [link](attachment-document.html).
|
||||||
|
And this is an inline image: ![alt text](attachment-image.png)
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
Browse the example puzzles
|
Developing Content: Source vs Mothballs
|
||||||
------------
|
=============================
|
||||||
|
|
||||||
|
As a developer,
|
||||||
|
you will begin by running MOTH in development mode.
|
||||||
|
In development mode,
|
||||||
|
MOTH will provide answers for each puzzle alongside the puzzle.
|
||||||
|
|
||||||
|
In order to run in production mode,
|
||||||
|
each category must be "transpiled" into a "mothball".
|
||||||
|
This is done to reduce the amount of dynamic code running on a production server,
|
||||||
|
as a way of decreasing the attack surface of the server.
|
||||||
|
|
||||||
|
To obtain a mothball,
|
||||||
|
simply click the "download" button on the puzzles list of a development server.
|
||||||
|
Mothballs have the file extension `.mb`.
|
||||||
|
|
||||||
|
|
||||||
The example puzzles are written to demonstrate various features of MOTH,
|
Setting Up Your Workstation
|
||||||
and serve as documentation of the puzzle format.
|
=====================
|
||||||
|
|
||||||
|
You will need two things to develop content:
|
||||||
|
a MOTH server,
|
||||||
|
and a text editor.
|
||||||
|
|
||||||
|
MOTH Server
|
||||||
|
-----------
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
Windows users can unzip the zip file provided by the MOTH development team,
|
||||||
|
and run `moth-devel.bat`.
|
||||||
|
You can close this window whenever you're done developing.
|
||||||
|
|
||||||
|
Once started,
|
||||||
|
open
|
||||||
|
http://localhost:8080/
|
||||||
|
to connect to your development server.
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
Linux users with Docker can run the following to get started
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p puzzles
|
||||||
|
docker run --rm -it -p 8080:8080 -v $(pwd)/puzzles:/puzzles:ro ghcr.io/dirtbags/moth-devel
|
||||||
|
```
|
||||||
|
|
||||||
|
Hit Control-C to terminate MOTH.
|
||||||
|
|
||||||
|
|
||||||
Make your own puzzle category
|
Text Editor
|
||||||
-------------------------
|
---------
|
||||||
|
|
||||||
cp -r /srv/moth/puzzles/example /srv/moth/puzzles/my-category
|
We like Visual Studio Code,
|
||||||
|
but any text editor will work:
|
||||||
|
you only need to edit the `puzzle.md` (Markdown) files.
|
||||||
|
|
||||||
|
Your First Category
|
||||||
|
================
|
||||||
|
|
||||||
Edit the one point puzzle
|
Open the `puzzles` directory/folder created where you launched the server.
|
||||||
--------
|
Create a new directory/folder named `test-category`.
|
||||||
|
Within that directory/folder,
|
||||||
|
create a directory/folder named `1`.
|
||||||
|
And within `1`,
|
||||||
|
create a new file named `puzzle.md`.
|
||||||
|
Paste the following text into that file:
|
||||||
|
|
||||||
nano /srv/moth/puzzles/my-category/1/puzzle.md
|
```markdown
|
||||||
|
---
|
||||||
|
authors:
|
||||||
|
- YOUR NAME HERE
|
||||||
|
answers:
|
||||||
|
- Elephant
|
||||||
|
- elephant
|
||||||
|
---
|
||||||
|
|
||||||
I don't use nano, personally,
|
# Animal Time
|
||||||
but if you're advanced enough to have an opinion about nano,
|
|
||||||
you're advanced enough to know how to use a different editor.
|
|
||||||
|
|
||||||
|
Do you like animals? I do!
|
||||||
|
Can you guess my favorite animal?
|
||||||
|
```
|
||||||
|
|
||||||
Read our advice
|
Now,
|
||||||
---------------
|
open http://localhost:8080/ in a web browser.
|
||||||
|
You should see a "test-category" category,
|
||||||
|
with a single 1-point puzzle.
|
||||||
|
Click that puzzle,
|
||||||
|
and you should see your animal puzzle presented.
|
||||||
|
|
||||||
|
The two answers we're accepting can be improved,
|
||||||
|
because some people might use the plural.
|
||||||
|
Edit `puzzle.md` to add two new answers:
|
||||||
|
`Elephants` and `elephants`.
|
||||||
|
Reload the web page,
|
||||||
|
and check the debug section to verify that all four answers are now accepted.
|
||||||
|
|
||||||
|
Next Steps
|
||||||
|
==========
|
||||||
|
|
||||||
The [Writing Puzzles](writing-puzzles.md) document
|
The [Writing Puzzles](writing-puzzles.md) document
|
||||||
has some tips on how we approach puzzle writing.
|
has some tips on how we approach puzzle writing.
|
||||||
There may be something in here that will help you out!
|
There may be something in here that will help you out!
|
||||||
|
|
||||||
|
|
||||||
Stop the server
|
|
||||||
-------
|
|
||||||
|
|
||||||
You can hit Control-C in the terminal where you started the server,
|
|
||||||
and it will exit.
|
|
||||||
|
|
||||||
|
|
||||||
Mothballs
|
|
||||||
=======
|
|
||||||
|
|
||||||
In the list of puzzle categories and puzzles,
|
|
||||||
there will be a button to download a mothball.
|
|
||||||
|
|
||||||
Once your category is set up the way you like it,
|
Once your category is set up the way you like it,
|
||||||
download a mothball for it,
|
download a mothball for it,
|
||||||
and you're ready to [get started](getting-started.md)
|
and you're ready for [getting started](getting-started.md)
|
||||||
with the production server.
|
with the production server.
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
Download All Unlocked Puzzles
|
||||||
|
========================
|
||||||
|
|
||||||
|
We get a lot of requests to "download everything" from an event.
|
||||||
|
Here's how you could do that:
|
||||||
|
|
||||||
|
What You Need
|
||||||
|
------------
|
||||||
|
|
||||||
|
* The URL to your puzzle server. We will call this `$url`.
|
||||||
|
* Your Team ID. We will call this `$teamid`.
|
||||||
|
* A way to POST `id=$teamid`
|
||||||
|
with `Content-Type: x-www-form-encoded` to a URL,
|
||||||
|
and save the result.
|
||||||
|
We will call this procedure "Fetch".
|
||||||
|
* A way to parse JSON files
|
||||||
|
|
||||||
|
Steps
|
||||||
|
-----
|
||||||
|
|
||||||
|
1. Fetch `$url/state`. This is the State object.
|
||||||
|
2. In the State object, `Puzzles` maps category name to a list of open puzzle point values.
|
||||||
|
3. For each category (we will call this `$category`):
|
||||||
|
1. For each point value:
|
||||||
|
1. If the point value is 0, skip it. 0 indicates all puzzles in this category are unlocked.
|
||||||
|
2. Fetch `$url/content/$category/$points/index.json`. This is the Puzzle object.
|
||||||
|
3. In the Puzzle object, `Body` contains the HTML body of the puzzle.
|
||||||
|
4. In the Puzzle object, `Attachments` contains a list of attachments.
|
||||||
|
For each attachment (we will call this `$attachment`):
|
||||||
|
1. Fetch `$url/content/$category/$points/$attachment`.
|
|
@ -49,10 +49,10 @@ It ought to import into any spreadsheet program painlessly.
|
||||||
|
|
||||||
Each line has six fields minimum:
|
Each line has six fields minimum:
|
||||||
|
|
||||||
| `timestamp` | `event` | `participantID` | `teamID` | `category` | `points` | `extra`... |
|
| `timestamp` | `event` | `teamID` | `category` | `points` | `extra`... |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| int | string | string | string | string | int | string... |
|
| int | string | string | string | int | string... |
|
||||||
| Unix epoch | Event type | Participant's (hopefully) unique ID | Team's unique ID | Name of category, if any | Points awarded, if any | Additional fields, if any |
|
| Unix epoch | Event type | Team's unique ID | Name of category, if any | Points awarded, if any | Additional fields, if any |
|
||||||
|
|
||||||
Fields after `points` contain extra fields associated with the event.
|
Fields after `points` contain extra fields associated with the event.
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
MOTH Metadata
|
||||||
|
============
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Standard Metadata
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
The following are considered "standard" MOTH metadata.
|
||||||
|
Clients *should* check for,
|
||||||
|
and take appropriate action on,
|
||||||
|
all of these metadata names.
|
||||||
|
|
||||||
|
|
||||||
|
| name | description | permitted values | example |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| author | Puzzle author(s). | free text [(ref)](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name) | `Neale Pickett` |
|
||||||
|
| moth.style | Whether the client should inject a style sheet. Default: `inherit` | `override`, `inherit` | `override` |
|
||||||
|
| moth.answerhash | Answer hash, used for "possibly correct" check in client. | MOTHv5: first 8 characters of answer's SHA1 checksum | `a5b6bb92` |
|
||||||
|
| moth.answerpattern | Answer pattern, to use as `pattern` attribute of `<input>` element for answer. | Regular Expression [(ref)](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/pattern) | `\w{3,16}`
|
||||||
|
| moth.ksa | [NICE KSA](https://niccs.cisa.gov/workforce-development/nice-framework) achieved by completing this puzzle. | NICE KSA identifier | `K0052` |
|
||||||
|
| moth.objective | Learning objective of this puzzle. | free text | `Count in octal` |
|
||||||
|
| moth.success.acceptable | The minimum work required to be considered successfully understanding this puzzle's concepts | free text | `Recognize pattern` |
|
||||||
|
| moth.success.mastery | The work required to be considered mastering this puzzle's concepts | free text | `Understand 8s place in octal` |
|
||||||
|
|
||||||
|
|
||||||
|
Standard Debugging Metadata
|
||||||
|
----------------
|
||||||
|
|
||||||
|
These metadata names are for debugging purposes.
|
||||||
|
The *must not* be present in a production instance.
|
||||||
|
|
||||||
|
| name | description | permitted values | example |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| moth.debug.answer | An accepted answer | free text | `pink hat horse race` |
|
||||||
|
| moth.debug.summary | A summary of the puzzle, to help staff remember what it is | free text | `Hidden white text in the rendered image` |
|
||||||
|
| moth.debug.hint | A hint that staff can provide to participants | free text | `This puzzle can be solved by a grade school student with no special tools` |
|
||||||
|
| moth.debug.notes | Notes to staff intended to help better understand the puzzle | free text | `We used this image because Scott likes tigers` |
|
||||||
|
| moth.debug.log | A log message | free text | `iterations: 5` |
|
||||||
|
| moth.debug.errors | Error messages | free text | `unable to open foo.bin` |
|
||||||
|
|
||||||
|
|
||||||
|
Client Metadata
|
||||||
|
-----------
|
||||||
|
|
||||||
|
Clients wishing to implement additional metadata
|
||||||
|
*should* either submit a merge request to this document,
|
||||||
|
or use a `moth/$client.` prefix.
|
||||||
|
For example, the "tofu" client might use a
|
||||||
|
`moth/tofu.difficulty` name.
|
|
@ -33,24 +33,13 @@ The state directory is also used to communicate actions to mothd.
|
||||||
Remove this file to reset the state. This will blow away team assignments and the points log.
|
Remove this file to reset the state. This will blow away team assignments and the points log.
|
||||||
|
|
||||||
|
|
||||||
`disabled`
|
`hours.txt`
|
||||||
----------
|
|
||||||
|
|
||||||
Create this file to pause collection of points and other maintenance.
|
|
||||||
Contestants can still submit answers,
|
|
||||||
but they won't show up on the scoreboard until you remove this file.
|
|
||||||
|
|
||||||
This file does not normally exist.
|
|
||||||
|
|
||||||
|
|
||||||
`until`
|
|
||||||
-------
|
-------
|
||||||
|
|
||||||
Put an RFC3337 date/time stamp in here to have the server pause itself at a given time.
|
A list of start and stop hours.
|
||||||
Remember that time zones exist!
|
If all the hours are in the future, the event defaults to running.
|
||||||
I recommend always using Zulu time.
|
"Stop" here just pertains to scoreboard updates and puzzle unlocking.
|
||||||
|
People can still submit answers and their awards are queued up for the next start.
|
||||||
This file does not normally exist.
|
|
||||||
|
|
||||||
|
|
||||||
`teamids.txt`
|
`teamids.txt`
|
||||||
|
|
|
@ -0,0 +1,183 @@
|
||||||
|
MOTH Puzzle Format
|
||||||
|
===========
|
||||||
|
|
||||||
|
MOTH puzzles are HTML5 documents,
|
||||||
|
with optional metadata.
|
||||||
|
Puzzles may contain stylesheets and scripts,
|
||||||
|
or any other feature made available by HTML5.
|
||||||
|
|
||||||
|
Typically, a puzzle will be rendered in an `<object>` tag
|
||||||
|
in the MOTH client.
|
||||||
|
Some clients may copy over scripts, stylesheets,
|
||||||
|
and embed the puzzle's `<body>` in the page.
|
||||||
|
|
||||||
|
Within a puzzle directory,
|
||||||
|
the puzzle itself is named `index.html`.
|
||||||
|
|
||||||
|
|
||||||
|
MOTH Metadata
|
||||||
|
=============
|
||||||
|
|
||||||
|
Puzzles may contain metadata,
|
||||||
|
which can be used by MOTH clients to alter display of puzzles,
|
||||||
|
or provide additional information in the UI.
|
||||||
|
|
||||||
|
Metadata is provided in HTML `<meta>` elements,
|
||||||
|
with the `name` attribute specifying the metadata name,
|
||||||
|
and the `content` attribute specifying the metadata content.
|
||||||
|
Multiple elements with the same `name` are generally permitted.
|
||||||
|
|
||||||
|
Metadata names are defined in detail in
|
||||||
|
[MOTH Metadata](metadata.md).
|
||||||
|
|
||||||
|
For example, the following `<meta>` elements
|
||||||
|
could appear in the `<head>` section of a puzzle's HTML:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<meta name="author" content="Neale Pickett">
|
||||||
|
<meta name="moth.answerhash" content="87bcc390">
|
||||||
|
<meta name="moth.answerhash" content="622fcbe8">
|
||||||
|
<meta name="moth.objective" content="Understand radix 8 (octal)">
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Images, Attachments, Scripts, and Style Sheets
|
||||||
|
===================
|
||||||
|
|
||||||
|
Related files can be referenced directly in HTML.
|
||||||
|
Related files *should* be located in the same directory as `index.html`,
|
||||||
|
but situations may exist where it makes more sense
|
||||||
|
to locate a file in the parent directory.
|
||||||
|
|
||||||
|
Related files are not hidden:
|
||||||
|
they can be discovered with an http `PROPFIND` method.
|
||||||
|
|
||||||
|
For example, assuming `honey.jpg` exists in the same directory
|
||||||
|
as `index.html`, a standard `<img>` tag will work:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<img src="honey.jpg"
|
||||||
|
alt="A clay jar with the word 'honey' printed on the front."
|
||||||
|
title="Honey jar">
|
||||||
|
```
|
||||||
|
|
||||||
|
Puzzle Events
|
||||||
|
==============
|
||||||
|
|
||||||
|
As HTML5 documents,
|
||||||
|
MOTH puzzles can communicate with the MOTH client
|
||||||
|
using HTML5 events.
|
||||||
|
|
||||||
|
setAnswer
|
||||||
|
--------
|
||||||
|
|
||||||
|
A MOTH Puzzle may advise the client to fill the answer field with text
|
||||||
|
by emitting an `setAnswer` custom event.
|
||||||
|
|
||||||
|
For example, the following code will advice the client to set the answer field to the string `bloop`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
let answerEvent = new CustomEvent(
|
||||||
|
"setAnswer",
|
||||||
|
{
|
||||||
|
detail: {value: 'bloop'},
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
document.dispatchEvent(answerEvent)
|
||||||
|
```
|
||||||
|
|
||||||
|
MOTH clients *should* listen for such events,
|
||||||
|
and fill the answer input field with the event's value.
|
||||||
|
Puzzles *must* provide the user with a copy/paste-able representation of the answer, in the event the event is not handled correctly by the client.
|
||||||
|
|
||||||
|
|
||||||
|
Example Puzzles
|
||||||
|
=========
|
||||||
|
|
||||||
|
Minimally Valid Puzzle
|
||||||
|
---------
|
||||||
|
|
||||||
|
This puzzle provides the absolute minimum required:
|
||||||
|
a title, and puzzle contents.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<title>Counting</title>
|
||||||
|
<p>1 2 3 4 5 _</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Puzzle with metadata
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
Typically, puzzles will provide metadata,
|
||||||
|
to enable client features such as "possibly correct" validation,
|
||||||
|
author display, learning objectives
|
||||||
|
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Counting Sheep</title>
|
||||||
|
<meta name="author" content="Neale Pickett">
|
||||||
|
<meta name="moth.answerhash" content="089c7244">
|
||||||
|
<meta name="moth.answerhash" content="92837b4f">
|
||||||
|
<meta name="moth.objective" content="Recognize the difference between a sheep and a wolf">
|
||||||
|
<meta name="moth.objective" content="Count to a high number">
|
||||||
|
<meta name="moth.success.acceptable" content="Count using fingers">
|
||||||
|
<meta name="moth.success.mastery" content="Count using software tools, and provide answer in hexadecimal">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>🐑🐑🐑🐑🐑🐑🐑🐑🐺🐑🐑</p>
|
||||||
|
<p>How many sheep?</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Puzzle with images, scripts, and style
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Since they are rendered as HTML documents,
|
||||||
|
puzzles may include any HTML5 feature.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Basic Sight Reading</title>
|
||||||
|
<meta name="author" content="Neale Pickett">
|
||||||
|
<meta name="moth.answerhash" content="baabaa08">
|
||||||
|
<meta name="moth.objective" content="Play a tune provided in sheet music">
|
||||||
|
<meta name="moth.success.acceptable" content="Play the requested tune with no mistakes">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<script src="midi-transcriber.mjs" type="module"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>
|
||||||
|
Using the provided sheet music,
|
||||||
|
play "May Had A Little Lamb" on your MIDI keyboard.
|
||||||
|
If you make a mistake,
|
||||||
|
press the "reset" button and start over.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Once you have played from start to finish with no mistakes,
|
||||||
|
paste the computed answer into the answer box.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<img src="mary-lamb.png"
|
||||||
|
alt="Sheet music: |EDCD|EEE.|DDD.|EEE.|"
|
||||||
|
title="Sheet music for 'Mary Had A Little Lamb">
|
||||||
|
|
||||||
|
<label for="notes">Notes Played</label>
|
||||||
|
<output id="notes"></output>
|
||||||
|
<button id="reset">Reset</button>
|
||||||
|
|
||||||
|
<label for="answer">Answer</label>
|
||||||
|
<output id="answer"></output>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
|
@ -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.
|
|
@ -0,0 +1 @@
|
||||||
|
Boop!
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
authors:
|
||||||
|
- neale
|
||||||
|
answers:
|
||||||
|
- 146
|
||||||
|
attachments:
|
||||||
|
- boop.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
Some puzzles can have embedded code.
|
||||||
|
|
||||||
|
Your theme may turn this into a full in-browser development environment!
|
||||||
|
|
||||||
|
## Python ##
|
||||||
|
```python
|
||||||
|
print(open("boop.txt").read())
|
||||||
|
setanswer(0x58 + 58)
|
||||||
|
```
|
||||||
|
|
||||||
|
## JavaScript ##
|
||||||
|
```javascript
|
||||||
|
console.log("moo")
|
||||||
|
```
|
15
go.mod
15
go.mod
|
@ -1,12 +1,15 @@
|
||||||
module github.com/dirtbags/moth
|
module github.com/dirtbags/moth/v4
|
||||||
|
|
||||||
go 1.13
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/spf13/afero v1.8.2
|
||||||
|
github.com/yuin/goldmark v1.4.13
|
||||||
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/spf13/afero v1.5.1
|
golang.org/x/text v0.3.8 // indirect
|
||||||
github.com/yuin/goldmark v1.3.1
|
|
||||||
golang.org/x/text v0.3.5 // indirect
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
|
||||||
)
|
)
|
||||||
|
|
438
go.sum
438
go.sum
|
@ -1,45 +1,445 @@
|
||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||||
|
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||||
|
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||||
|
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||||
|
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||||
|
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||||
|
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||||
|
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||||
|
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||||
|
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
||||||
|
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
||||||
|
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
||||||
|
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
||||||
|
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
||||||
|
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
||||||
|
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
||||||
|
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
|
||||||
|
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||||
|
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||||
|
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||||
|
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||||
|
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||||
|
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||||
|
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||||
|
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||||
|
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||||
|
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||||
|
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||||
|
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
||||||
|
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||||
|
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||||
|
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||||
|
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||||
|
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||||
|
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
|
||||||
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
|
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||||
|
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||||
|
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
|
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
|
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
|
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
|
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
|
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||||
|
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/spf13/afero v1.4.0 h1:jsLTaI1zwYO3vjrzHalkVcIHXTNmdQFepW4OI8H3+x8=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
github.com/spf13/afero v1.4.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/spf13/afero v1.5.1 h1:VHu76Lk0LSP1x254maIu2bplkWpfBWI+B+6fdoZprcg=
|
github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo=
|
||||||
github.com/spf13/afero v1.5.1/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
|
github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/yuin/goldmark v1.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM=
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.3.1 h1:eVwehsLsZlCJCwXyGLgg+Q4iFWE/eTIMG0e8waCmm/I=
|
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
|
||||||
github.com/yuin/goldmark v1.3.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||||
|
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||||
|
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||||
|
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
|
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||||
|
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||||
|
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||||
|
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||||
|
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
|
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||||
|
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||||
|
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||||
|
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||||
|
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
|
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||||
|
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||||
|
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||||
|
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
||||||
|
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||||
|
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||||
|
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||||
|
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||||
|
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||||
|
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||||
|
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
||||||
|
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
||||||
|
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
|
||||||
|
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
||||||
|
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||||
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||||
|
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
||||||
|
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
||||||
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
|
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
||||||
|
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
|
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||||
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
|
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
||||||
|
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||||
|
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
|
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
||||||
|
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||||
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
|
||||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||||
|
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
|
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
|
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||||
|
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||||
|
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||||
|
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha1"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -37,24 +37,50 @@ type PuzzleDebug struct {
|
||||||
Summary string
|
Summary string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Puzzle contains everything about a puzzle that a client would see.
|
// Puzzle contains everything about a puzzle that a client will see.
|
||||||
type Puzzle struct {
|
type Puzzle struct {
|
||||||
|
// Debug 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
|
||||||
|
|
||||||
|
// Answers lists all acceptable answers, omitted in mothballs
|
||||||
|
Answers []string
|
||||||
|
|
||||||
|
// Extra is send unchanged to the client.
|
||||||
|
// Eventually, Objective, KSAs, and Success will move into Extra.
|
||||||
|
Extra map[string]any
|
||||||
|
|
||||||
|
// 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 concepts
|
||||||
Mastery string
|
Mastery string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Answers will be empty in a mothball
|
|
||||||
Answers []string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (puzzle *Puzzle) computeAnswerHashes() {
|
func (puzzle *Puzzle) computeAnswerHashes() {
|
||||||
|
@ -63,9 +89,9 @@ func (puzzle *Puzzle) computeAnswerHashes() {
|
||||||
}
|
}
|
||||||
puzzle.AnswerHashes = make([]string, len(puzzle.Answers))
|
puzzle.AnswerHashes = make([]string, len(puzzle.Answers))
|
||||||
for i, answer := range puzzle.Answers {
|
for i, answer := range puzzle.Answers {
|
||||||
sum := sha256.Sum256([]byte(answer))
|
sum := sha1.Sum([]byte(answer))
|
||||||
hexsum := fmt.Sprintf("%x", sum)
|
hexsum := fmt.Sprintf("%x", sum)
|
||||||
puzzle.AnswerHashes[i] = hexsum
|
puzzle.AnswerHashes[i] = hexsum[:4]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,14 +101,15 @@ type StaticPuzzle struct {
|
||||||
Attachments []StaticAttachment
|
Attachments []StaticAttachment
|
||||||
Scripts []StaticAttachment
|
Scripts []StaticAttachment
|
||||||
AnswerPattern string
|
AnswerPattern string
|
||||||
|
Answers []string
|
||||||
|
Debug PuzzleDebug
|
||||||
|
Extra map[string]any
|
||||||
Objective string
|
Objective string
|
||||||
Success struct {
|
Success struct {
|
||||||
Acceptable string
|
Acceptable string
|
||||||
Mastery string
|
Mastery string
|
||||||
}
|
}
|
||||||
KSAs []string
|
KSAs []string
|
||||||
Debug PuzzleDebug
|
|
||||||
Answers []string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StaticAttachment carries information about an attached file.
|
// StaticAttachment carries information about an attached file.
|
||||||
|
@ -183,6 +210,7 @@ func (fp FsPuzzle) Puzzle() (Puzzle, error) {
|
||||||
puzzle.Debug = static.Debug
|
puzzle.Debug = static.Debug
|
||||||
puzzle.Answers = static.Answers
|
puzzle.Answers = static.Answers
|
||||||
puzzle.Authors = static.Authors
|
puzzle.Authors = static.Authors
|
||||||
|
puzzle.Extra = static.Extra
|
||||||
puzzle.Objective = static.Objective
|
puzzle.Objective = static.Objective
|
||||||
puzzle.KSAs = static.KSAs
|
puzzle.KSAs = static.KSAs
|
||||||
puzzle.Success = static.Success
|
puzzle.Success = static.Success
|
||||||
|
@ -220,7 +248,7 @@ func (fp FsPuzzle) Open(name string) (ReadSeekCloser, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if fsPath == "" {
|
if fsPath == "" {
|
||||||
return empty, fmt.Errorf("Not listed in attachments or scripts: %s", name)
|
return empty, fmt.Errorf("not listed in attachments or scripts: %s", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fp.fs.Open(fsPath)
|
return fp.fs.Open(fsPath)
|
||||||
|
@ -306,7 +334,7 @@ func rfc822HeaderParser(r io.Reader) (StaticPuzzle, error) {
|
||||||
p := StaticPuzzle{}
|
p := StaticPuzzle{}
|
||||||
m, err := mail.ReadMessage(r)
|
m, err := mail.ReadMessage(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p, fmt.Errorf("Parsing RFC822 headers: %v", err)
|
return p, fmt.Errorf("parsing RFC822 headers: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, val := range m.Header {
|
for key, val := range m.Header {
|
||||||
|
@ -337,7 +365,7 @@ func rfc822HeaderParser(r io.Reader) (StaticPuzzle, error) {
|
||||||
case "success.mastery":
|
case "success.mastery":
|
||||||
p.Success.Mastery = val[0]
|
p.Success.Mastery = val[0]
|
||||||
default:
|
default:
|
||||||
return p, fmt.Errorf("Unknown header field: %s", key)
|
return p, fmt.Errorf("unknown header field: %s", key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,12 @@ func TestPuzzle(t *testing.T) {
|
||||||
if (len(p.Answers) == 0) || (p.Answers[0] != "YAML answer") {
|
if (len(p.Answers) == 0) || (p.Answers[0] != "YAML answer") {
|
||||||
t.Error("Answers are wrong", p.Answers)
|
t.Error("Answers are wrong", p.Answers)
|
||||||
}
|
}
|
||||||
|
if len(p.Answers) != len(p.AnswerHashes) {
|
||||||
|
t.Error("Answer hashes length does not match answers length")
|
||||||
|
}
|
||||||
|
if len(p.AnswerHashes[0]) != 4 {
|
||||||
|
t.Error("Answer hash is wrong length")
|
||||||
|
}
|
||||||
if (len(p.Authors) != 3) || (p.Authors[1] != "Buster") {
|
if (len(p.Authors) != 3) || (p.Authors[1] != "Buster") {
|
||||||
t.Error("Authors are wrong", p.Authors)
|
t.Error("Authors are wrong", p.Authors)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
269
theme/basic.css
269
theme/basic.css
|
@ -1,132 +1,182 @@
|
||||||
/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */
|
/* Color palette: http://paletton.com/#uid=33x0u0klrl-4ON9dhtKtAdqMQ4T */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #010e19;
|
||||||
|
--fg: #edd488;
|
||||||
|
--bg-main: #000d;
|
||||||
|
--heading: #cb2408cc;
|
||||||
|
--bg-heading1: #cb240844;
|
||||||
|
--fg-link: #b9cbd8;
|
||||||
|
--bg-input: #ccc4;
|
||||||
|
--bg-input-hover: #8884;
|
||||||
|
--bg-notification: #ac8f3944;
|
||||||
|
--bg-error: #f00;
|
||||||
|
--fg-error: white;
|
||||||
|
--bg-category: #ccc4;
|
||||||
|
--bg-input-invalid: #800;
|
||||||
|
--fg-input-invalid: white;
|
||||||
|
--bg-mothball: #ccc;
|
||||||
|
--bg-debug: #cccc;
|
||||||
|
--fg-debug: black;
|
||||||
|
--bg-toast: #333;
|
||||||
|
--fg-toast: #eee;
|
||||||
|
--box-toast: #0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--bg: #b9cbd8;
|
||||||
|
--fg: black;
|
||||||
|
--bg-main: #fffd;
|
||||||
|
--fg-link: #092b45;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
|
background: var(--bg) url("bg.png") center fixed;
|
||||||
|
background-size: cover;
|
||||||
|
background-blend-mode: soft-light;
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
canvas.wallpaper {
|
||||||
|
position: fixed;
|
||||||
|
display: block;
|
||||||
|
z-index: -1000;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
opacity: 0.2;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion) {
|
||||||
|
canvas.wallpaper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main {
|
||||||
max-width: 40em;
|
max-width: 40em;
|
||||||
background: #282a33;
|
margin: 1em auto;
|
||||||
color: #f6efdc;
|
padding: 1px 3px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--bg-main);
|
||||||
}
|
}
|
||||||
body.wide {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
max-width: 100%;
|
color: var(--heading);
|
||||||
}
|
|
||||||
a:any-link {
|
|
||||||
color: #8b969a;
|
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
background: #5e576b;
|
background: var(--bg-heading1);
|
||||||
color: #9e98a8;
|
padding: 3px;
|
||||||
}
|
|
||||||
.Fail, .Error, #messages {
|
|
||||||
background: #3a3119;
|
|
||||||
color: #ffcc98;
|
|
||||||
}
|
|
||||||
.Fail:before {
|
|
||||||
content: "Fail: ";
|
|
||||||
}
|
|
||||||
.Error:before {
|
|
||||||
content: "Error: ";
|
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
margin: 1em 0em;
|
margin: 1em 0em;
|
||||||
}
|
}
|
||||||
|
a:any-link {
|
||||||
|
color: var(--fg-link);
|
||||||
|
}
|
||||||
form, pre {
|
form, pre {
|
||||||
margin: 1em;
|
margin: 1em;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
input, select {
|
input, select {
|
||||||
padding: 0.6em;
|
padding: 0.6em;
|
||||||
margin: 0.2em;
|
margin: 0.2em;
|
||||||
max-width: 30em;
|
max-width: 30em;
|
||||||
}
|
}
|
||||||
nav {
|
input {
|
||||||
border: solid black 2px;
|
background-color: var(--bg-input);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
input:hover {
|
||||||
|
background-color: var(--bg-input-hover);
|
||||||
|
}
|
||||||
|
input:active {
|
||||||
|
background-color: inherit;
|
||||||
|
}
|
||||||
|
.notification, .error {
|
||||||
|
padding: 0 1em;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.notification {
|
||||||
|
background: var(--bg-notification);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background: var(--bg-error);
|
||||||
|
color: var(--fg-error);
|
||||||
|
}
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puzzles list */
|
||||||
|
.category {
|
||||||
|
margin: 5px 0;
|
||||||
|
background: var(--bg-category);
|
||||||
|
}
|
||||||
|
.category h2 {
|
||||||
|
margin: 0 0.2em;
|
||||||
|
}
|
||||||
|
.category .solved {
|
||||||
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
nav ul, .category ul {
|
nav ul, .category ul {
|
||||||
padding: 1em;
|
margin: 0;
|
||||||
|
padding: 0.2em 1em;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
}
|
}
|
||||||
nav li, .category li {
|
nav li, .category li {
|
||||||
display: inline;
|
display: inline;
|
||||||
margin: 1em;
|
|
||||||
}
|
}
|
||||||
iframe#body {
|
.category li.entitled {
|
||||||
border: inherit;
|
flex-basis: 100%;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
img {
|
.mothball {
|
||||||
|
float: right;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--bg-mothball);
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puzzle content */
|
||||||
|
#puzzle {
|
||||||
|
border-bottom: solid;
|
||||||
|
padding: 0 0.5em;
|
||||||
|
}
|
||||||
|
#puzzle img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
input:invalid {
|
input:invalid {
|
||||||
border-color: red;
|
background-color: var(--bg-input-invalid);
|
||||||
|
color: var(--fg-input-invalid);
|
||||||
}
|
}
|
||||||
#messages {
|
.answer_ok {
|
||||||
min-height: 3em;
|
cursor: help;
|
||||||
border: solid black 2px;
|
|
||||||
}
|
|
||||||
#rankings {
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#rankings span {
|
/** Development mode information */
|
||||||
font-size: 75%;
|
.debug {
|
||||||
display: inline-block;
|
overflow: auto;
|
||||||
overflow: hidden;
|
padding: 1em;
|
||||||
height: 1.7em;
|
border-radius: 10px;
|
||||||
}
|
margin: 2em auto;
|
||||||
#rankings span.teamname {
|
background: var(--bg-debug);
|
||||||
font-size: inherit;
|
color: var(--fg-debug);
|
||||||
color: white;
|
|
||||||
text-shadow: 0 0 3px black;
|
|
||||||
opacity: 0.8;
|
|
||||||
position: absolute;
|
|
||||||
right: 0.2em;
|
|
||||||
}
|
|
||||||
#rankings div * {white-space: nowrap;}
|
|
||||||
.cat0, .cat8, .cat16 {background-color: #a6cee3; color: black;}
|
|
||||||
.cat1, .cat9, .cat17 {background-color: #1f78b4; color: white;}
|
|
||||||
.cat2, .cat10, .cat18 {background-color: #b2df8a; color: black;}
|
|
||||||
.cat3, .cat11, .cat19 {background-color: #33a02c; color: white;}
|
|
||||||
.cat4, .cat12, .cat20 {background-color: #fb9a99; color: black;}
|
|
||||||
.cat5, .cat13, .cat21 {background-color: #e31a1c; color: white;}
|
|
||||||
.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;}
|
|
||||||
.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;}
|
|
||||||
|
|
||||||
|
|
||||||
#devel {
|
|
||||||
background-color: #eee;
|
|
||||||
color: black;
|
|
||||||
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 {
|
li[draggable]::before {
|
||||||
content: "↕";
|
content: "↕";
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
|
@ -144,6 +194,31 @@ 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: var(--bg-toast);
|
||||||
|
color: var(--fg-toast);
|
||||||
|
box-shadow: 0px 0px 8px 0px var(--box-toast);
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 180 KiB |
|
@ -0,0 +1,88 @@
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
|
||||||
|
/** A channel to monitor for state updates (or to notify of state updates) */
|
||||||
|
const StateUpdateChannel = new BroadcastChannel("StateUpdate")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
StateUpdateChannel,
|
||||||
|
BaseURL,
|
||||||
|
Toast,
|
||||||
|
WhenDOMLoaded,
|
||||||
|
Truthy,
|
||||||
|
Config,
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"PuzzleList": {
|
||||||
|
"TrackSolved": true,
|
||||||
|
"Titles": false,
|
||||||
|
"": 0
|
||||||
|
},
|
||||||
|
"Puzzle": {
|
||||||
|
"": 0
|
||||||
|
},
|
||||||
|
"Scoreboard": {
|
||||||
|
"DisplayServerURLWhenEnabled": true,
|
||||||
|
"ShowCategoryLeaders": true,
|
||||||
|
"ReplayHistory": true,
|
||||||
|
"ReplayFPS": 6,
|
||||||
|
"ReplayDurationMS": 2000,
|
||||||
|
"NoScoresHtml": "<div class='notification'><h2>~ no scores ~</h2></div>",
|
||||||
|
"": 0
|
||||||
|
},
|
||||||
|
"Messages": "<!-- Messages can go here (HTML) -->",
|
||||||
|
"": "this is here so you don't have to remember to take the comma off the last item"
|
||||||
|
}
|
Binary file not shown.
|
@ -1,38 +1,44 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>MOTH</title>
|
<title>MOTH</title>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<link rel="icon" href="luna-moth.svg">
|
||||||
<link rel="stylesheet" href="basic.css">
|
<link rel="stylesheet" href="basic.css">
|
||||||
<script src="moth.js"></script>
|
<script src="index.mjs" type="module" async></script>
|
||||||
<link rel="manifest" href="manifest.json">
|
<script src="background.mjs" type="module" async></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1 id="title">MOTH</h1>
|
<h1 class="title" title="Monarch Of The Hill">MOTH</h1>
|
||||||
<section>
|
<main>
|
||||||
<div id="messages">
|
<div class="messages notification">
|
||||||
<div id="notices"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="login">
|
<form class="login">
|
||||||
<!--
|
|
||||||
<span id="pid">
|
|
||||||
Participant ID: <input name="pid"> (optional) <br>
|
|
||||||
</span>
|
|
||||||
-->
|
|
||||||
Team ID: <input name="id"> <br>
|
Team ID: <input name="id"> <br>
|
||||||
Team name: <input name="name"> <br>
|
Team name: <input name="name"> <br>
|
||||||
<input type="submit" value="Sign In">
|
<input type="submit" value="Sign In">
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div id="puzzles"></div>
|
<div class="puzzles"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="notification" data-track-solved="no">
|
||||||
|
<p>
|
||||||
|
Solved puzzle tracking: <b>disabled</b>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Your team's Incident Coordinator can help coordinate team activity.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toasts"></div>
|
||||||
|
|
||||||
</section>
|
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="scoreboard.html">Scoreboard</a></li>
|
<li><a href="scoreboard.html" target="_blank">Scoreboard</a></li>
|
||||||
<li><a href="logout.html">Sign Out</a></li>
|
<li><button class="logout">Sign Out</button></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
@ -0,0 +1,203 @@
|
||||||
|
/**
|
||||||
|
* 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
common.StateUpdateChannel.addEventListener("message", () => {
|
||||||
|
// Give mothd time to catch up
|
||||||
|
setTimeout(() => this.UpdateState(), 1/2 * common.Second)
|
||||||
|
})
|
||||||
|
|
||||||
|
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.PuzzleList?.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.PuzzleList?.TrackSolved) {
|
||||||
|
a.classList.toggle("solved", this.state.IsSolved(puzzle))
|
||||||
|
}
|
||||||
|
if (this.config.PuzzleList?.Titles) {
|
||||||
|
this.loadTitle(puzzle, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.state.ContainsUnsolved(cat)) {
|
||||||
|
l.appendChild(document.createElement("li")).textContent = "✿"
|
||||||
|
}
|
||||||
|
|
||||||
|
element.appendChild(pdiv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously loads a puzzle, in order to populate the title.
|
||||||
|
*
|
||||||
|
* Calling this for every open puzzle will generate a lot of load on the server.
|
||||||
|
* If we decide we want this for a multi-participant server,
|
||||||
|
* we should implement some sort of cache.
|
||||||
|
*
|
||||||
|
* @param {Puzzle} puzzle
|
||||||
|
* @param {Element} element
|
||||||
|
*/
|
||||||
|
async loadTitle(puzzle, element) {
|
||||||
|
await puzzle.Populate()
|
||||||
|
let title = puzzle.Extra.title
|
||||||
|
if (!title) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
element.classList.add("entitled")
|
||||||
|
for (let a of element.querySelectorAll("a")) {
|
||||||
|
a.textContent += `: ${title}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
window.app = new App()
|
||||||
|
}
|
||||||
|
|
||||||
|
common.WhenDOMLoaded(init)
|
|
@ -1,23 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>MOTH</title>
|
|
||||||
<meta name="viewport" content="width=device-width">
|
|
||||||
<link rel="stylesheet" href="basic.css">
|
|
||||||
<script>
|
|
||||||
sessionStorage.removeItem("id")
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1 id="title">MOTH</h1>
|
|
||||||
<section>
|
|
||||||
<p>Okay, you've been logged out.</p>
|
|
||||||
</section>
|
|
||||||
<nav>
|
|
||||||
<ul>
|
|
||||||
<li><a href="index.html">Sign In</a></li>
|
|
||||||
<li><a href="scoreboard.html">Scoreboard</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -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"
|
|
||||||
}
|
|
File diff suppressed because one or more lines are too long
203
theme/moth.js
203
theme/moth.js
|
@ -1,203 +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"
|
|
||||||
sessionStorage.pid = "rodney"
|
|
||||||
renderPuzzles(obj.Puzzles)
|
|
||||||
} else if (Object.keys(obj.Puzzles).length > 0) {
|
|
||||||
renderPuzzles(obj.Puzzles)
|
|
||||||
}
|
|
||||||
renderNotices(obj.Messages)
|
|
||||||
}
|
|
||||||
|
|
||||||
function heartbeat() {
|
|
||||||
let teamId = sessionStorage.id || ""
|
|
||||||
let participantId = sessionStorage.pid
|
|
||||||
let url = new URL("state", window.location)
|
|
||||||
url.searchParams.set("id", teamId)
|
|
||||||
if (participantId) {
|
|
||||||
url.searchParams.set("pid", participantId)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
let pide = document.querySelector("[name=pid]")
|
|
||||||
let participantId = pide?pide.value:Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
|
|
||||||
|
|
||||||
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
|
|
||||||
sessionStorage.pid = participantId
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,684 @@
|
||||||
|
/**
|
||||||
|
* 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.Scripts ||= []
|
||||||
|
this.Debug ||= {}
|
||||||
|
this.Debug.Errors ||= []
|
||||||
|
this.Debug.Hints ||= []
|
||||||
|
this.Debug.Log ||= []
|
||||||
|
this.Extra ||= {}
|
||||||
|
|
||||||
|
// Be ready to handle a future revision to the Puzzle structure
|
||||||
|
this.Objective ||= this.Extra.Objective
|
||||||
|
this.KSAs ||= this.Extra.KSAs || []
|
||||||
|
this.Success ||= this.Extra.Success || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the server is in enabled state, or if we don't know */
|
||||||
|
this.Enabled = obj.Enabled ?? true
|
||||||
|
|
||||||
|
/** 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() {
|
||||||
|
return [...this.ScoresHistory()].pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
State,
|
||||||
|
}
|
|
@ -0,0 +1,117 @@
|
||||||
|
@font-face {
|
||||||
|
font-family: "Go";
|
||||||
|
src: url("fonts/Go-Regular.ttf");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Go-Mono";
|
||||||
|
src: url("fonts/Go-Mono.ttf");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Workspace
|
||||||
|
*
|
||||||
|
* Tools for this puzzle: shows up in content.
|
||||||
|
* Right now this is just a Python interpreter.
|
||||||
|
*/
|
||||||
|
.workspace {
|
||||||
|
background-color: rgba(255, 240, 220, 0.3);
|
||||||
|
white-space: normal;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output {
|
||||||
|
background-color: #555;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding: 0.5em;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-height: 3em;
|
||||||
|
max-height: 24em;
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output, .editor {
|
||||||
|
font-family: Go, "source code pro", consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed .output, .fixed .editor {
|
||||||
|
font-family: "source code pro", consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
}
|
||||||
|
.controls .status {
|
||||||
|
font-size: 9pt;
|
||||||
|
flex-grow: 2;
|
||||||
|
}
|
||||||
|
.controls .language {
|
||||||
|
font-size: 9pt;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stdout,
|
||||||
|
.stderr,
|
||||||
|
.stdinfo,
|
||||||
|
.traceback {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.stderr {
|
||||||
|
color: #f88;
|
||||||
|
}
|
||||||
|
.traceback {
|
||||||
|
background-color: #222;
|
||||||
|
}
|
||||||
|
.stdinfo {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
border: 1px solid black;
|
||||||
|
overflow-y: scroll;
|
||||||
|
max-height: 24em;
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
font-size: 12pt;
|
||||||
|
line-height: 1.2rem;
|
||||||
|
}
|
||||||
|
.editor .linenos {
|
||||||
|
background-color: #eee;
|
||||||
|
white-space: pre;
|
||||||
|
min-width: 2em;
|
||||||
|
padding: 0 4px;
|
||||||
|
text-align: right;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
.editor .text {
|
||||||
|
background-color: #fff;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow-x: scroll;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding: 0 4px;
|
||||||
|
height: fit-content;
|
||||||
|
min-height: 8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Some things that crop up in puzzles */
|
||||||
|
[draggable] {
|
||||||
|
padding-left: 1em;
|
||||||
|
background-image: url(../images/drag-handle.svg);
|
||||||
|
background-position: 0 center;
|
||||||
|
background-size: 1em 1em;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-color: rgba(255, 255, 255, 0.4);
|
||||||
|
margin: 2px 0px;
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
[draggable].over,
|
||||||
|
[draggable].moving {
|
||||||
|
background-color: rgba(127, 127, 127, 0.5);
|
||||||
|
}
|
|
@ -1,37 +1,54 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>Puzzle</title>
|
<title>Puzzle</title>
|
||||||
<link rel="stylesheet" href="basic.css">
|
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<script src="puzzle.js"></script>
|
<link rel="icon" href="luna-moth.svg">
|
||||||
<script>
|
<link rel="stylesheet" href="basic.css">
|
||||||
|
<link rel="stylesheet" href="puzzle.css">
|
||||||
</script>
|
<script src="background.mjs" type="module" async></script>
|
||||||
|
<script src="puzzle.mjs" type="module" async></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Puzzle</h1>
|
<h1 id="title">[loading]</h1>
|
||||||
<section>
|
<main>
|
||||||
<div id="puzzle"><span class="spinner"></span></div>
|
<section id="puzzle">
|
||||||
<ul id="files"></ul>
|
<p class="notification">
|
||||||
<p>Puzzle by <span id="authors"></span></p>
|
Starting script...
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<div id="messages"></div>
|
<section class="meta"></section>
|
||||||
<form>
|
<ul id="files"></ul>
|
||||||
<input type="hidden" name="cat">
|
<p>Puzzle by <span id="authors">[loading]</span></p>
|
||||||
<input type="hidden" name="points">
|
</section>
|
||||||
<input type="hidden" name="xAnswer">
|
<form class="submit-answer">
|
||||||
Team ID: <input type="text" name="id"> <br>
|
<label for="answer">Answer:</label>
|
||||||
Answer: <input type="text" name="answer" id="answer"> <span id="answer_ok"></span><br>
|
<input type="text" name="answer" id="answer"> <span class="answer_ok"></span>
|
||||||
|
<br>
|
||||||
<input type="submit" value="Submit">
|
<input type="submit" value="Submit">
|
||||||
</form>
|
</form>
|
||||||
<div id="devel"></div>
|
</main>
|
||||||
<nav>
|
<div class="debug" class="notification"></div>
|
||||||
<ul>
|
<div class="toasts"></div>
|
||||||
<li><a href="index.html">Puzzles</a></li>
|
<template id="workspace">
|
||||||
<li><a href="scoreboard.html">Scoreboard</a></li>
|
<div class="editor">
|
||||||
</ul>
|
<div class="linenos"></div>
|
||||||
</nav>
|
<div class="text"></div>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<button class="run">Run</button>
|
||||||
|
<button class="font" title="Switch in and out of monospace font">Font</button>
|
||||||
|
<span class="status">Execution time: 0.03s</span>
|
||||||
|
<span class="language"></span>
|
||||||
|
<button class="revert" title="Reset code to original">Revert</button>
|
||||||
|
</div>
|
||||||
|
<div class="output">
|
||||||
|
<div class="stdout"></div>
|
||||||
|
<div class="stderr"></div>
|
||||||
|
<div class="traceback"></div>
|
||||||
|
<div class="stdinfo"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
225
theme/puzzle.js
225
theme/puzzle.js
|
@ -1,225 +0,0 @@
|
||||||
// jshint asi:true
|
|
||||||
|
|
||||||
// prettify adds classes to various types, returning an HTML string.
|
|
||||||
function prettify(key, val) {
|
|
||||||
switch (key) {
|
|
||||||
case "Body":
|
|
||||||
return '[HTML]'
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
// devel_addin drops a bunch of development extensions into element e.
|
|
||||||
// It will only modify stuff inside e.
|
|
||||||
function devel_addin(e) {
|
|
||||||
let h = e.appendChild(document.createElement("h2"))
|
|
||||||
h.textContent = "Developer Output"
|
|
||||||
|
|
||||||
let log = window.puzzle.Debug.Log || []
|
|
||||||
if (log.length > 0) {
|
|
||||||
e.appendChild(document.createElement("h3")).textContent = "Log"
|
|
||||||
let le = e.appendChild(document.createElement("ul"))
|
|
||||||
for (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()
|
|
||||||
}
|
|
|
@ -0,0 +1,271 @@
|
||||||
|
/**
|
||||||
|
* Functionality for puzzle.html (Puzzle display / answer form)
|
||||||
|
*/
|
||||||
|
import * as moth from "./moth.mjs"
|
||||||
|
import * as common from "./common.mjs"
|
||||||
|
|
||||||
|
const workspacePromise = import("./workspace/workspace.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)
|
||||||
|
common.StateUpdateChannel.postMessage({})
|
||||||
|
document.dispatchEvent(new CustomEvent("answerCorrect"))
|
||||||
|
}
|
||||||
|
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...")
|
||||||
|
let attachmentUrls = []
|
||||||
|
for (let fn of (puzzle.Attachments || [])) {
|
||||||
|
let li = document.createElement("li")
|
||||||
|
let a = document.createElement("a")
|
||||||
|
let url = new URL(fn, contentBase)
|
||||||
|
attachmentUrls.push(url)
|
||||||
|
a.href = url
|
||||||
|
a.innerText = fn
|
||||||
|
li.appendChild(a)
|
||||||
|
document.getElementById("files").appendChild(li)
|
||||||
|
}
|
||||||
|
|
||||||
|
workspacePromise.then(workspace => {
|
||||||
|
let codeBlocks = document.querySelectorAll("code[class^=language-]")
|
||||||
|
for (let i = 0; i < codeBlocks.length; i++) {
|
||||||
|
let codeBlock = codeBlocks[i]
|
||||||
|
let id = category + "#" + points + "#" + i
|
||||||
|
new workspace.Workspace(codeBlock, id, attachmentUrls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const confettiPromise = import("https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/+esm")
|
||||||
|
async function CorrectAnswer() {
|
||||||
|
setInterval(window.close, 3 * common.Second)
|
||||||
|
|
||||||
|
let confetti = await confettiPromise
|
||||||
|
confetti.default({
|
||||||
|
disableForReducedMotion: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
window.app = {}
|
||||||
|
window.setanswer = (str => SetAnswer(str))
|
||||||
|
window.checkAnswer = (str => window.app.puzzle.IsPossiblyCorrect(str))
|
||||||
|
|
||||||
|
for (let form of document.querySelectorAll("form.submit-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())
|
||||||
|
|
||||||
|
// Workspaces may trigger a "this is the answer" event
|
||||||
|
document.addEventListener("setAnswer", e => SetAnswer(e.detail.value))
|
||||||
|
|
||||||
|
// Celebrate on correct answer
|
||||||
|
document.addEventListener("answerCorrect", e => CorrectAnswer())
|
||||||
|
|
||||||
|
// 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
|
@ -0,0 +1,55 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>KSA Report</title>
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<script src="ksa.mjs" type="module" async></script>
|
||||||
|
<script src="../background.mjs" type="module" async></script>
|
||||||
|
<link rel="stylesheet" href="../basic.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>KSA Report</h1>
|
||||||
|
<main>
|
||||||
|
<p>
|
||||||
|
This report shows all KSAs covered by this server so far.
|
||||||
|
This is not a report on your progress, but rather
|
||||||
|
what you would have covered if you had worked every exercise available.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="notification">
|
||||||
|
<p class="doing"></p>
|
||||||
|
<progress class="doing"></progress>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>All KSAs across all content</h2>
|
||||||
|
<ul class="allKSAs"></ul>
|
||||||
|
|
||||||
|
<h2>All KSAs by Category</h2>
|
||||||
|
<div class="KSAsByCategory">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>KSAs by Puzzle</h2>
|
||||||
|
<table class="puzzles">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Points</th>
|
||||||
|
<th>KSAs</th>
|
||||||
|
<th>Errors</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template id="puzzlerow">
|
||||||
|
<tr>
|
||||||
|
<td class="category"></td>
|
||||||
|
<td class="points"></td>
|
||||||
|
<td class="ksas"></td>
|
||||||
|
<td><pre class="error"></pre></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,140 @@
|
||||||
|
import * as moth from "../moth.mjs"
|
||||||
|
import * as common from "../common.mjs"
|
||||||
|
|
||||||
|
const server = new moth.Server("../")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update "doing" indicators
|
||||||
|
*
|
||||||
|
* @param {String | null} what Text to display, or null to not update text
|
||||||
|
* @param {Number | null} finished Percentage complete to display, or null to not update progress
|
||||||
|
*/
|
||||||
|
function doing(what, finished = null) {
|
||||||
|
for (let e of document.querySelectorAll(".doing")) {
|
||||||
|
e.classList.remove("hidden")
|
||||||
|
if (what) {
|
||||||
|
e.textContent = what
|
||||||
|
}
|
||||||
|
if (finished) {
|
||||||
|
e.value = finished
|
||||||
|
} else {
|
||||||
|
e.removeAttribute("value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function done() {
|
||||||
|
for (let e of document.querySelectorAll(".doing")) {
|
||||||
|
e.classList.add("hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function GetNice() {
|
||||||
|
let NiceElementsByIdentifier = {}
|
||||||
|
let resp = await fetch("NICEFramework2017.json")
|
||||||
|
let obj = await resp.json()
|
||||||
|
for (let e of obj.elements) {
|
||||||
|
NiceElementsByIdentifier[e.element_identifier] = e
|
||||||
|
}
|
||||||
|
return NiceElementsByIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a puzzle, and fill its KSAs and rows.
|
||||||
|
*
|
||||||
|
* This is done once per puzzle, in an asynchronous function, allowing the
|
||||||
|
* application to perform multiple blocking operations simultaneously.
|
||||||
|
*/
|
||||||
|
async function FetchAndFill(puzzle, KSAs, rows) {
|
||||||
|
try {
|
||||||
|
await puzzle.Populate()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// Keep on going with whatever Populate was able to fill
|
||||||
|
}
|
||||||
|
for (let KSA of (puzzle.KSAs || [])) {
|
||||||
|
KSAs.add(KSA)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let row of rows) {
|
||||||
|
row.querySelector(".category").textContent = puzzle.Category
|
||||||
|
row.querySelector(".points").textContent = puzzle.Points
|
||||||
|
row.querySelector(".ksas").textContent = (puzzle.KSAs || []).join(" ")
|
||||||
|
row.querySelector(".error").textContent = puzzle.Error.Body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
doing("Fetching NICE framework data")
|
||||||
|
let nicePromise = GetNice()
|
||||||
|
|
||||||
|
doing("Retrieving server state")
|
||||||
|
let state = await server.GetState()
|
||||||
|
|
||||||
|
doing("Retrieving all puzzles")
|
||||||
|
let KSAsByCategory = {}
|
||||||
|
let puzzlerowTemplate = document.querySelector("template#puzzlerow")
|
||||||
|
let puzzles = state.Puzzles()
|
||||||
|
let promises = []
|
||||||
|
for (let category of state.Categories()) {
|
||||||
|
KSAsByCategory[category] = new Set()
|
||||||
|
}
|
||||||
|
let pending = puzzles.length
|
||||||
|
for (let puzzle of puzzles) {
|
||||||
|
// Make space in the table, so everything fills in sorted order
|
||||||
|
let rows = []
|
||||||
|
for (let tbody of document.querySelectorAll("tbody")) {
|
||||||
|
let row = puzzlerowTemplate.content.cloneNode(true).firstElementChild
|
||||||
|
tbody.appendChild(row)
|
||||||
|
rows.push(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue up a fetch, and update progress bar
|
||||||
|
let promise = FetchAndFill(puzzle, KSAsByCategory[puzzle.Category], rows)
|
||||||
|
promises.push(promise)
|
||||||
|
promise.then(() => doing(null, 1 - (--pending / puzzles.length)))
|
||||||
|
|
||||||
|
if (promises.length > 50) {
|
||||||
|
// Chrome runs out of resources if you queue up too many of these at once
|
||||||
|
await Promise.all(promises)
|
||||||
|
promises = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(promises)
|
||||||
|
|
||||||
|
doing("Retrieving NICE identifiers")
|
||||||
|
let NiceElementsByIdentifier = await nicePromise
|
||||||
|
|
||||||
|
|
||||||
|
doing("Filling KSAs By Category")
|
||||||
|
let allKSAs = new Set()
|
||||||
|
for (let div of document.querySelectorAll(".KSAsByCategory")) {
|
||||||
|
for (let category of state.Categories()) {
|
||||||
|
doing(`Filling KSAs for category: ${category}`)
|
||||||
|
let KSAs = [...KSAsByCategory[category]]
|
||||||
|
KSAs.sort()
|
||||||
|
|
||||||
|
div.appendChild(document.createElement("h3")).textContent = category
|
||||||
|
let ul = div.appendChild(document.createElement("ul"))
|
||||||
|
for (let k of KSAs) {
|
||||||
|
let ksa = k.split(/\s+/)[0]
|
||||||
|
let ne = NiceElementsByIdentifier[ksa] || { text: "???" }
|
||||||
|
let text = `${ksa}: ${ne.text}`
|
||||||
|
ul.appendChild(document.createElement("li")).textContent = text
|
||||||
|
allKSAs.add(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doing("Filling KSAs")
|
||||||
|
for (let e of document.querySelectorAll(".allKSAs")) {
|
||||||
|
let KSAs = [...allKSAs]
|
||||||
|
KSAs.sort()
|
||||||
|
for (let text of KSAs) {
|
||||||
|
e.appendChild(document.createElement("li")).textContent = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
|
||||||
|
common.WhenDOMLoaded(init)
|
|
@ -0,0 +1,19 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Scoreboard</title>
|
||||||
|
<link rel="stylesheet" href="basic.css">
|
||||||
|
<link rel="stylesheet" href="scoreboard.css">
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<link rel="icon" href="luna-moth.svg">
|
||||||
|
<script type="module" src="scoreboard.mjs"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="no-scores hidden"></div>
|
||||||
|
<div class="rotate">
|
||||||
|
<div class="rankings category"></div>
|
||||||
|
<div class="rankings classic"></div>
|
||||||
|
</div>
|
||||||
|
<div class="location"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,120 @@
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scores {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-height: calc(100vh - 2em);
|
||||||
|
}
|
||||||
|
.no-scores.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.no-scores img {
|
||||||
|
object-fit: cover;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only the first child of a rotate class is visible */
|
||||||
|
.rotate > div:nth-child(n + 2) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scoreboard */
|
||||||
|
.rankings.classic {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
background-color: #000c;
|
||||||
|
}
|
||||||
|
.rankings.classic div {
|
||||||
|
height: 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.rankings.classic div:nth-child(6n){
|
||||||
|
background-color: #ccc3;
|
||||||
|
}
|
||||||
|
.rankings.classic div:nth-child(6n+3) {
|
||||||
|
background-color: #0f03;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankings.classic span {
|
||||||
|
display: inline-block;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.rankings.classic span.category {
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
.rankings.classic span.teamname {
|
||||||
|
height: auto;
|
||||||
|
font-size: inherit;
|
||||||
|
color: white;
|
||||||
|
background-color: #000e;
|
||||||
|
border-radius: 3px;
|
||||||
|
position: absolute;
|
||||||
|
right: 0.2em;
|
||||||
|
}
|
||||||
|
.rankings.classic span.teamname:hover,
|
||||||
|
.rankings.classic span.category:hover {
|
||||||
|
width: inherit;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.topscore::before {
|
||||||
|
content: "✩";
|
||||||
|
font-size: 75%;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankings.category {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
||||||
|
.rankings.category div {
|
||||||
|
border: solid black 2px;
|
||||||
|
min-width: 15em;
|
||||||
|
}
|
||||||
|
.rankings.category table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.rankings.category td.number {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 450px) {
|
||||||
|
.rankings.classic 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,22 +3,14 @@
|
||||||
<head>
|
<head>
|
||||||
<title>Scoreboard</title>
|
<title>Scoreboard</title>
|
||||||
<link rel="stylesheet" href="basic.css">
|
<link rel="stylesheet" href="basic.css">
|
||||||
|
<link rel="stylesheet" href="scoreboard.css">
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<script src="moment.min.js" async></script>
|
<link rel="icon" href="luna-moth.svg">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" async></script>
|
<script type="module" src="scoreboard.mjs"></script>
|
||||||
<script src="scoreboard.js" async></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="wide">
|
<body>
|
||||||
<h4 id="location"></h4>
|
<div class="no-scores hidden"></div>
|
||||||
<section class="rotate">
|
<div class="rankings classic"></div>
|
||||||
<div id="chart"></div>
|
<div class="location"></div>
|
||||||
<div id="rankings"></div>
|
|
||||||
</section>
|
|
||||||
<nav>
|
|
||||||
<ul>
|
|
||||||
<li><a href="index.html">Puzzles</a></li>
|
|
||||||
<li><a href="scoreboard.html">Scoreboard</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -1,251 +0,0 @@
|
||||||
// jshint asi:true
|
|
||||||
|
|
||||||
function scoreboardInit() {
|
|
||||||
|
|
||||||
chartColors = [
|
|
||||||
"rgb(255, 99, 132)",
|
|
||||||
"rgb(255, 159, 64)",
|
|
||||||
"rgb(255, 205, 86)",
|
|
||||||
"rgb(75, 192, 192)",
|
|
||||||
"rgb(54, 162, 235)",
|
|
||||||
"rgb(153, 102, 255)",
|
|
||||||
"rgb(201, 203, 207)"
|
|
||||||
]
|
|
||||||
|
|
||||||
function update(state) {
|
|
||||||
window.state = state
|
|
||||||
|
|
||||||
for (let rotate of document.querySelectorAll(".rotate")) {
|
|
||||||
rotate.appendChild(rotate.firstElementChild)
|
|
||||||
}
|
|
||||||
|
|
||||||
let element = document.getElementById("rankings")
|
|
||||||
let teamNames = state.TeamNames
|
|
||||||
let pointsLog = state.PointsLog
|
|
||||||
|
|
||||||
// Every machine that's displaying the scoreboard helpfully stores the last 20 values of
|
|
||||||
// points.json for us, in case of catastrophe. Thanks, y'all!
|
|
||||||
//
|
|
||||||
// We have been doing some variation on this "everybody backs up the server state" trick since 2009.
|
|
||||||
// We have needed it 0 times.
|
|
||||||
let stateHistory = JSON.parse(localStorage.getItem("stateHistory")) || []
|
|
||||||
if (stateHistory.length >= 20) {
|
|
||||||
stateHistory.shift()
|
|
||||||
}
|
|
||||||
stateHistory.push(state)
|
|
||||||
localStorage.setItem("stateHistory", JSON.stringify(stateHistory))
|
|
||||||
|
|
||||||
let teams = {}
|
|
||||||
let highestCategoryScore = {} // map[string]int
|
|
||||||
|
|
||||||
// Initialize data structures
|
|
||||||
for (let teamId in teamNames) {
|
|
||||||
teams[teamId] = {
|
|
||||||
categoryScore: {}, // map[string]int
|
|
||||||
overallScore: 0, // int
|
|
||||||
historyLine: [], // []{x: int, y: int}
|
|
||||||
name: teamNames[teamId],
|
|
||||||
id: teamId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dole out points
|
|
||||||
for (let entry of pointsLog) {
|
|
||||||
let timestamp = entry[0]
|
|
||||||
let teamId = entry[1]
|
|
||||||
let category = entry[2]
|
|
||||||
let points = entry[3]
|
|
||||||
|
|
||||||
let team = teams[teamId]
|
|
||||||
|
|
||||||
let score = team.categoryScore[category] || 0
|
|
||||||
score += points
|
|
||||||
team.categoryScore[category] = score
|
|
||||||
|
|
||||||
let highest = highestCategoryScore[category] || 0
|
|
||||||
if (score > highest) {
|
|
||||||
highestCategoryScore[category] = score
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let teamId in teamNames) {
|
|
||||||
teams[teamId].categoryScore = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let entry of pointsLog) {
|
|
||||||
let timestamp = entry[0]
|
|
||||||
let teamId = entry[1]
|
|
||||||
let category = entry[2]
|
|
||||||
let points = entry[3]
|
|
||||||
|
|
||||||
let team = teams[teamId]
|
|
||||||
|
|
||||||
let score = team.categoryScore[category] || 0
|
|
||||||
score += points
|
|
||||||
team.categoryScore[category] = score
|
|
||||||
|
|
||||||
let overall = 0
|
|
||||||
for (let cat in team.categoryScore) {
|
|
||||||
overall += team.categoryScore[cat] / highestCategoryScore[cat]
|
|
||||||
}
|
|
||||||
|
|
||||||
team.historyLine.push({t: new Date(timestamp * 1000), y: overall})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute overall scores based on current highest
|
|
||||||
for (let teamId in teams) {
|
|
||||||
let team = teams[teamId]
|
|
||||||
team.overallScore = 0
|
|
||||||
for (let cat in team.categoryScore) {
|
|
||||||
team.overallScore += team.categoryScore[cat] / highestCategoryScore[cat]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by team score
|
|
||||||
function teamCompare(a, b) {
|
|
||||||
return a.overallScore - b.overallScore
|
|
||||||
}
|
|
||||||
|
|
||||||
// Figure out how to order each team on the scoreboard
|
|
||||||
let winners = []
|
|
||||||
for (let teamId in teams) {
|
|
||||||
winners.push(teams[teamId])
|
|
||||||
}
|
|
||||||
winners.sort(teamCompare)
|
|
||||||
winners.reverse()
|
|
||||||
|
|
||||||
// Let's make some better names for things we've computed
|
|
||||||
let winningScore = winners[0].overallScore
|
|
||||||
let numCategories = Object.keys(highestCategoryScore).length
|
|
||||||
|
|
||||||
// Clear out the element we're about to populate
|
|
||||||
Array.from(element.childNodes).map(e => e.remove())
|
|
||||||
|
|
||||||
let maxWidth = 100 / winningScore
|
|
||||||
for (let team of winners) {
|
|
||||||
let row = document.createElement("div")
|
|
||||||
let ncat = 0
|
|
||||||
for (let category in highestCategoryScore) {
|
|
||||||
let catHigh = highestCategoryScore[category]
|
|
||||||
let catTeam = team.categoryScore[category] || 0
|
|
||||||
let catPct = catTeam / catHigh
|
|
||||||
let width = maxWidth * catPct
|
|
||||||
|
|
||||||
let bar = document.createElement("span")
|
|
||||||
bar.classList.add("category")
|
|
||||||
bar.classList.add("cat" + ncat)
|
|
||||||
bar.style.width = width + "%"
|
|
||||||
bar.textContent = category + ": " + catTeam
|
|
||||||
bar.title = bar.textContent
|
|
||||||
|
|
||||||
row.appendChild(bar)
|
|
||||||
ncat += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
let te = document.createElement("span")
|
|
||||||
te.classList.add("teamname")
|
|
||||||
te.textContent = team.name
|
|
||||||
row.appendChild(te)
|
|
||||||
|
|
||||||
element.appendChild(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
let datasets = []
|
|
||||||
for (let i in winners) {
|
|
||||||
if (i > 5) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
let team = winners[i]
|
|
||||||
let color = chartColors[i % chartColors.length]
|
|
||||||
datasets.push({
|
|
||||||
label: team.name,
|
|
||||||
backgroundColor: color,
|
|
||||||
borderColor: color,
|
|
||||||
data: team.historyLine,
|
|
||||||
lineTension: 0,
|
|
||||||
fill: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
let config = {
|
|
||||||
type: "line",
|
|
||||||
data: {
|
|
||||||
datasets: datasets
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
scales: {
|
|
||||||
xAxes: [{
|
|
||||||
display: true,
|
|
||||||
type: "time",
|
|
||||||
time: {
|
|
||||||
tooltipFormat: "ll HH:mm"
|
|
||||||
},
|
|
||||||
scaleLabel: {
|
|
||||||
display: true,
|
|
||||||
labelString: "Time"
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
yAxes: [{
|
|
||||||
display: true,
|
|
||||||
scaleLabel: {
|
|
||||||
display: true,
|
|
||||||
labelString: "Points"
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
tooltips: {
|
|
||||||
mode: "index",
|
|
||||||
intersect: false
|
|
||||||
},
|
|
||||||
hover: {
|
|
||||||
mode: "nearest",
|
|
||||||
intersect: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let chart = document.querySelector("#chart")
|
|
||||||
if (chart) {
|
|
||||||
let canvas = chart.querySelector("canvas")
|
|
||||||
if (! canvas) {
|
|
||||||
canvas = document.createElement("canvas")
|
|
||||||
chart.appendChild(canvas)
|
|
||||||
}
|
|
||||||
|
|
||||||
let myline = new Chart(canvas.getContext("2d"), config)
|
|
||||||
myline.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
fetch("state")
|
|
||||||
.then(resp => {
|
|
||||||
return resp.json()
|
|
||||||
})
|
|
||||||
.then(obj => {
|
|
||||||
update(obj)
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.log(err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
let base = window.location.href.replace("scoreboard.html", "")
|
|
||||||
let location = document.querySelector("#location")
|
|
||||||
if (location) {
|
|
||||||
location.textContent = base
|
|
||||||
}
|
|
||||||
|
|
||||||
setInterval(refresh, 60000)
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
init()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
|
||||||
document.addEventListener("DOMContentLoaded", scoreboardInit)
|
|
||||||
} else {
|
|
||||||
scoreboardInit()
|
|
||||||
}
|
|
|
@ -0,0 +1,183 @@
|
||||||
|
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
|
||||||
|
if (!config.Scoreboard) {
|
||||||
|
console.warn("config.json has empty Scoreboard section")
|
||||||
|
}
|
||||||
|
let ScoreboardConfig = config.Scoreboard ?? {}
|
||||||
|
let state = await server.GetState()
|
||||||
|
|
||||||
|
// Show URL of server
|
||||||
|
for (let e of document.querySelectorAll(".location")) {
|
||||||
|
e.textContent = common.BaseURL
|
||||||
|
e.classList.toggle("hidden", !(ScoreboardConfig.DisplayServerURLWhenEnabled && state.Enabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate views
|
||||||
|
for (let e of document.querySelectorAll(".rotate")) {
|
||||||
|
e.appendChild(e.firstChild)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render rankings
|
||||||
|
for (let e of document.querySelectorAll(".rankings")) {
|
||||||
|
if (e.classList.contains("classic")) {
|
||||||
|
classicRankings(e, state, ScoreboardConfig)
|
||||||
|
} else if (e.classList.contains("category")) {
|
||||||
|
categoryRankings(e, state, ScoreboardConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function classicRankings(rankingsElement, state, ScoreboardConfig) {
|
||||||
|
let ReplayHistory = ScoreboardConfig.ReplayHistory ?? false
|
||||||
|
let ReplayDurationMS = ScoreboardConfig.ReplayDurationMS ?? 300
|
||||||
|
let ReplayFPS = ScoreboardConfig.ReplayFPS ?? 24
|
||||||
|
|
||||||
|
let logSize = state.PointsLog.length
|
||||||
|
|
||||||
|
// Figure out the timing so that we can replay the scoreboard in about
|
||||||
|
// ReplayDurationMS.
|
||||||
|
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] ?? "rodney"
|
||||||
|
|
||||||
|
let row = rankingsElement.appendChild(document.createElement("div"))
|
||||||
|
|
||||||
|
let teamname = row.appendChild(document.createElement("span"))
|
||||||
|
teamname.textContent = teamName
|
||||||
|
teamname.classList.add("teamname")
|
||||||
|
|
||||||
|
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
|
||||||
|
let categoryNumber = [...scores.Categories].indexOf(category)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let e of document.querySelectorAll(".no-scores")) {
|
||||||
|
e.innerHTML = ScoreboardConfig.NoScoresHtml
|
||||||
|
e.classList.toggle("hidden", frame > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} rankingsElement
|
||||||
|
* @param {moth.State} state
|
||||||
|
* @param {*} ScoreboardConfig
|
||||||
|
*/
|
||||||
|
async function categoryRankings(rankingsElement, state, ScoreboardConfig) {
|
||||||
|
while (rankingsElement.firstChild) rankingsElement.firstChild.remove()
|
||||||
|
let scores = state.CurrentScores()
|
||||||
|
for (let category of scores.Categories) {
|
||||||
|
let categoryBox = rankingsElement.appendChild(document.createElement("div"))
|
||||||
|
categoryBox.classList.add("category")
|
||||||
|
|
||||||
|
categoryBox.appendChild(document.createElement("h2")).textContent = category
|
||||||
|
|
||||||
|
let categoryScores = []
|
||||||
|
for (let teamID in state.TeamNames) {
|
||||||
|
categoryScores.push({
|
||||||
|
teamName: state.TeamNames[teamID],
|
||||||
|
score: scores.GetPoints(category, teamID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
categoryScores.sort((a, b) => b.score - a.score)
|
||||||
|
|
||||||
|
let table = categoryBox.appendChild(document.createElement("table"))
|
||||||
|
let rows = 0
|
||||||
|
for (let categoryScore of categoryScores) {
|
||||||
|
let row = table.appendChild(document.createElement("tr"))
|
||||||
|
row.appendChild(document.createElement("td")).textContent = categoryScore.teamName
|
||||||
|
let td = row.appendChild(document.createElement("td"))
|
||||||
|
td.textContent = categoryScore.score
|
||||||
|
td.classList.add("number")
|
||||||
|
rows += 1
|
||||||
|
if (rows == 5) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
setInterval(update, common.Minute)
|
||||||
|
update()
|
||||||
|
}
|
||||||
|
|
||||||
|
common.WhenDOMLoaded(init)
|
|
@ -0,0 +1,4 @@
|
||||||
|
html {
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Puzzle Viewer</title>
|
||||||
|
<link rel="stylesheet" href="index.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<object type="text/html" data="puzzle.html"></object>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,42 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Example MOTHv5 Puzzle</title>
|
||||||
|
|
||||||
|
<!-- style tells the client whether to inject its own stylesheet.
|
||||||
|
|
||||||
|
By default, a client will try to style a puzzle using its own stylesheet.
|
||||||
|
If you want to override this behavior, provide "style" with
|
||||||
|
content="override".
|
||||||
|
Omitting this meta element is the same as content="inherit".
|
||||||
|
-->
|
||||||
|
<meta name="moth.style" content="inherit">
|
||||||
|
|
||||||
|
|
||||||
|
<!-- moth.answerhash is used for the "possibly correct" check.
|
||||||
|
|
||||||
|
This is the first 8 characters of the hex-encoded sha1 checksum of the answer.
|
||||||
|
If you have multiple acceptable answers, provide multiple answerhash elements.
|
||||||
|
-->
|
||||||
|
<meta name="moth.answerhash" content="2c26b46b">
|
||||||
|
|
||||||
|
<!-- author specifies the author of this puzzle.
|
||||||
|
|
||||||
|
If you have multiple authors, specify multiple meta elements,
|
||||||
|
one author per element.
|
||||||
|
-->
|
||||||
|
<meta name="author" content="Neale Pickett">
|
||||||
|
<meta name="author" content="Ford Powers">
|
||||||
|
|
||||||
|
<script href="example.mjs" type="module"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Example Puzzle</h1>
|
||||||
|
<p>
|
||||||
|
This is an example puzzle, yo.
|
||||||
|
You can put whatever you want in the HTML.
|
||||||
|
If you do crazy tricks, it might break the client, though.
|
||||||
|
</p>
|
||||||
|
<img src="salad.jpg">
|
||||||
|
</body>
|
||||||
|
</html>
|
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
After Width: | Height: | Size: 29 KiB |
|
@ -1,45 +1,29 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>Redeem Token</title>
|
<title>Redeem Token</title>
|
||||||
<link rel="stylesheet" href="basic.css">
|
<link rel="stylesheet" href="basic.css">
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<script src="puzzle.js"></script>
|
<script src="token.mjs" type="module" async></script>
|
||||||
<script>
|
|
||||||
function tokenInput(e) {
|
|
||||||
let vals = e.target.value.split(":")
|
|
||||||
document.querySelector("input[name=cat]").value = vals[0]
|
|
||||||
document.querySelector("input[name=points]").value = vals[1]
|
|
||||||
document.querySelector("input[name=answer]").value = vals[2]
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenInit() {
|
|
||||||
document.querySelector("input[name=token]").addEventListener("input", tokenInput)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
|
||||||
document.addEventListener("DOMContentLoaded", tokenInit)
|
|
||||||
} else {
|
|
||||||
tokenInit()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Redeem Token</h1>
|
<h1>Redeem Token</h1>
|
||||||
<div id="messages"></div>
|
<main>
|
||||||
<form id="tokenForm">
|
<p>
|
||||||
<input type="hidden" name="cat">
|
Have you found a token?
|
||||||
<input type="hidden" name="points">
|
</p>
|
||||||
<input type="hidden" name="answer">
|
<p></p>
|
||||||
Team ID: <input type="text" name="id"> <br>
|
Tokens look like
|
||||||
Token: <input type="text" name="token"> <br>
|
<code>category:5:xylep-radar-nanox</code>
|
||||||
|
<p>
|
||||||
|
Tokens may be redeemed here for points in their category.
|
||||||
|
Tokens can appear anywhere: online, on slips of paper, projected onto screens…
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
<form class="token"</form>
|
||||||
|
<label for="token">Token:</label> <input type="text" name="token" id="token"> <br>
|
||||||
<input type="submit" value="Submit">
|
<input type="submit" value="Submit">
|
||||||
</form>
|
</form>
|
||||||
<nav>
|
<div class="toasts"></div>
|
||||||
<ul>
|
|
||||||
<li><a href="puzzle-list.html">Puzzles</a></li>
|
|
||||||
<li><a href="scoreboard.html">Scoreboard</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
* Functionality for token.html
|
||||||
|
*/
|
||||||
|
import * as moth from "./moth.mjs"
|
||||||
|
import * as common from "./common.mjs"
|
||||||
|
|
||||||
|
const server = new moth.Server(".")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a submit event on a form.
|
||||||
|
*
|
||||||
|
* @param {SubmitEvent} event
|
||||||
|
*/
|
||||||
|
async function formSubmitHandler(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
let formData = new FormData(event.target)
|
||||||
|
let token = formData.get("token")
|
||||||
|
let vals = token.split(":")
|
||||||
|
let category = vals[0]
|
||||||
|
let points = Number(vals[1])
|
||||||
|
let proposed = vals[2]
|
||||||
|
if (!category || !points || !proposed) {
|
||||||
|
console.info("Not a token:", vals)
|
||||||
|
common.Toast("This is not a properly-formed token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let message = await server.SubmitAnswer(category, points, proposed)
|
||||||
|
common.Toast(message)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (error.message == "incorrect answer") {
|
||||||
|
common.Toast("Unknown token")
|
||||||
|
} else {
|
||||||
|
console.error(error)
|
||||||
|
common.Toast(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
for (let form of document.querySelectorAll("form.token")) {
|
||||||
|
form.addEventListener("submit", formSubmitHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
common.WhenDOMLoaded(init)
|
|
@ -0,0 +1,78 @@
|
||||||
|
import * as pyodide from "https://cdn.jsdelivr.net/npm/pyodide@0.25.1/pyodide.mjs" // v0.16.1 known good
|
||||||
|
|
||||||
|
const HOME = "/home/web_user"
|
||||||
|
|
||||||
|
async function createInstance() {
|
||||||
|
let instance = await pyodide.loadPyodide()
|
||||||
|
instance.runPython("import sys")
|
||||||
|
self.postMessage({type: "loaded"})
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
const initialized = createInstance()
|
||||||
|
|
||||||
|
class Buffer {
|
||||||
|
constructor() {
|
||||||
|
this.buf = []
|
||||||
|
}
|
||||||
|
|
||||||
|
write(s) {
|
||||||
|
this.buf.push(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
value() {
|
||||||
|
return this.buf.join("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMessage(event) {
|
||||||
|
let data = event.data
|
||||||
|
|
||||||
|
let instance = await initialized
|
||||||
|
let fs = instance._module.FS
|
||||||
|
|
||||||
|
let ret = {
|
||||||
|
result: null,
|
||||||
|
answer: null,
|
||||||
|
stdout: null,
|
||||||
|
stderr: null,
|
||||||
|
traceback: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case "nop":
|
||||||
|
// You might want to do nothing in order to display to the user that a run can now be handled
|
||||||
|
break
|
||||||
|
case "run":
|
||||||
|
let sys = instance.globals.get("sys")
|
||||||
|
sys.stdout = new Buffer()
|
||||||
|
sys.stderr = new Buffer()
|
||||||
|
instance.globals.set("setanswer", (s) => {ret.answer = s})
|
||||||
|
|
||||||
|
try {
|
||||||
|
ret.result = await instance.runPythonAsync(data.code)
|
||||||
|
} catch (err) {
|
||||||
|
ret.traceback = err
|
||||||
|
}
|
||||||
|
ret.stdout = sys.stdout.value()
|
||||||
|
ret.stderr = sys.stderr.value()
|
||||||
|
break
|
||||||
|
case "wget":
|
||||||
|
let url = data.url
|
||||||
|
let dir = data.directory || fs.cwd()
|
||||||
|
let filename = url.split("/").pop()
|
||||||
|
let path = dir + "/" + filename
|
||||||
|
|
||||||
|
if (fs.analyzePath(path).exists) {
|
||||||
|
fs.unlink(path)
|
||||||
|
}
|
||||||
|
fs.createLazyFile(dir, filename, url, true, false)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
ret.result = "Unknown message type: " + data.type
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (data.channel) {
|
||||||
|
data.channel.postMessage(ret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.addEventListener("message", e => handleMessage(e))
|
|
@ -0,0 +1,237 @@
|
||||||
|
import {Toast} from "../common.mjs"
|
||||||
|
//import "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"
|
||||||
|
import "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"
|
||||||
|
import "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"
|
||||||
|
import * as CodeJar from "https://cdn.jsdelivr.net/npm/codejar@4.2.0"
|
||||||
|
|
||||||
|
Prism.plugins.autoloader.languages_path = "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/"
|
||||||
|
const prismCssUrl = "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css"
|
||||||
|
|
||||||
|
export class Workspace {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param codeBlock {HTMLElement} The element containing the source code
|
||||||
|
* @param id {string} A unique identifier of this workspace
|
||||||
|
* @param attachmentUrls {URL[]} List of attachment URLs
|
||||||
|
*/
|
||||||
|
constructor(codeBlock, id, attachmentUrls) {
|
||||||
|
// Show a progress bar
|
||||||
|
let loadingElement = document.createElement("progress")
|
||||||
|
codeBlock.insertAdjacentElement("afterend", loadingElement)
|
||||||
|
|
||||||
|
this.language = "unknown"
|
||||||
|
for (let c of codeBlock.classList) {
|
||||||
|
let parts = c.split("-")
|
||||||
|
if ((parts.length == 2) && parts[0].startsWith("lang")) {
|
||||||
|
this.language = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.element = document.createElement("div")
|
||||||
|
this.element.classList.add("workspace")
|
||||||
|
let template = document.querySelector("template#workspace")
|
||||||
|
this.element.appendChild(template.content.cloneNode(true))
|
||||||
|
|
||||||
|
this.originalCode = codeBlock.textContent
|
||||||
|
this.attachmentUrls = attachmentUrls
|
||||||
|
this.storageKey = "code:" + id
|
||||||
|
|
||||||
|
// Get our document and window
|
||||||
|
this.document = this.element.ownerDocument
|
||||||
|
this.window = this.document.defaultView
|
||||||
|
|
||||||
|
// Load user modifications, if there are any
|
||||||
|
this.code = localStorage[this.storageKey] || this.originalCode
|
||||||
|
|
||||||
|
this.status = this.element.querySelector(".status")
|
||||||
|
this.linenos = this.element.querySelector(".editor .linenos")
|
||||||
|
this.editor = this.element.querySelector(".editor .text")
|
||||||
|
this.stdout = this.element.querySelector(".stdout")
|
||||||
|
this.stderr = this.element.querySelector(".stderr")
|
||||||
|
this.traceback = this.element.querySelector(".traceback")
|
||||||
|
this.stdinfo = this.element.querySelector(".stdinfo")
|
||||||
|
this.runButton = this.element.querySelector("button.run")
|
||||||
|
this.revertButton = this.element.querySelector("button.revert")
|
||||||
|
this.fontButton = this.element.querySelector("button.font")
|
||||||
|
this.element.querySelector(".language").textContent = this.language
|
||||||
|
|
||||||
|
this.runButton.disabled = true
|
||||||
|
|
||||||
|
// Load in the editor
|
||||||
|
this.editor.classList.add("language-" + this.language)
|
||||||
|
this.jar = CodeJar.CodeJar(this.editor, (editor) => this.highlight(editor), {window: this.window})
|
||||||
|
this.jar.updateCode(this.code)
|
||||||
|
switch (this.language) {
|
||||||
|
case "python":
|
||||||
|
this.jar.updateOptions({
|
||||||
|
tab: " ",
|
||||||
|
indentOn: /:$/,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the interpreter
|
||||||
|
this.initLanguage(this.language)
|
||||||
|
.then(() => {
|
||||||
|
codeBlock.parentElement.replaceWith(this.element)
|
||||||
|
})
|
||||||
|
.catch(err => console.warn(`Unable to load interpreter: `, this.language))
|
||||||
|
.finally(() => {
|
||||||
|
loadingElement.remove()
|
||||||
|
})
|
||||||
|
this.runButton.addEventListener("click", () => this.run())
|
||||||
|
this.revertButton.addEventListener("click", () => this.revert())
|
||||||
|
this.fontButton.addEventListener("click", () => this.font())
|
||||||
|
}
|
||||||
|
|
||||||
|
initLanguage(language) {
|
||||||
|
let start = performance.now()
|
||||||
|
this.status.textContent = "Initializing..."
|
||||||
|
this.status.appendChild(document.createElement("progress"))
|
||||||
|
|
||||||
|
let workerUrl = new URL(language + ".mjs", import.meta.url)
|
||||||
|
this.worker = new Worker(workerUrl, {type: "module"})
|
||||||
|
|
||||||
|
// XXX: There has got to be a cleaner way to do this
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.worker.addEventListener("error", err => reject(err))
|
||||||
|
this.workerMessage({type: "nop"})
|
||||||
|
.then(() => {
|
||||||
|
let runtime = performance.now() - start
|
||||||
|
let duration = new Date(runtime).toISOString().slice(11, -1)
|
||||||
|
this.status.textContent = "Loaded in " + duration
|
||||||
|
this.runButton.disabled = false
|
||||||
|
|
||||||
|
for (let a of this.attachmentUrls) {
|
||||||
|
let filename = a.pathname.split("/").pop()
|
||||||
|
this.workerMessage({type: "wget", url: a.href || a})
|
||||||
|
.then(ret => {
|
||||||
|
this.stdinfo.appendChild(this.document.createElement("div")).textContent = "Downloaded " + filename
|
||||||
|
})
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
workerMessage(message) {
|
||||||
|
let chan = new MessageChannel()
|
||||||
|
message.channel = chan.port2
|
||||||
|
this.worker.postMessage(message, [chan.port2])
|
||||||
|
let p = new Promise(
|
||||||
|
(resolve, reject) => {
|
||||||
|
chan.port1.addEventListener("message", e => resolve(e.data), {once: true})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
chan.port1.start()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
workerReady() {
|
||||||
|
return this.workerMessage({type: "nop"})
|
||||||
|
}
|
||||||
|
|
||||||
|
workerWget(url) {
|
||||||
|
return this.workerMessage({
|
||||||
|
type: "wget",
|
||||||
|
url: url.href || url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* highlight provides a code highlighter for CodeJar
|
||||||
|
*
|
||||||
|
* It calls Prism.highlightElement, then updates line numbers
|
||||||
|
*/
|
||||||
|
highlight(editor) {
|
||||||
|
if (Prism) {
|
||||||
|
// Sometimes it loads slowly
|
||||||
|
Prism.highlightElement(editor)
|
||||||
|
} else {
|
||||||
|
console.warn("No highlighter!", Prism, this.window.document.scripts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a line numbers column
|
||||||
|
if (true) {
|
||||||
|
const code = editor.textContent || ""
|
||||||
|
const lines = code.split("\n")
|
||||||
|
let linesCount = lines.length
|
||||||
|
if (lines[linesCount-1]) {
|
||||||
|
linesCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let ltxt = ""
|
||||||
|
for (let i = 1; i < linesCount; i++) {
|
||||||
|
ltxt += i + "\n"
|
||||||
|
}
|
||||||
|
this.linenos.textContent = ltxt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnswer(answer) {
|
||||||
|
let evt = new CustomEvent("setAnswer", {detail: {value: answer}, bubbles: true, cancelable: true})
|
||||||
|
this.element.dispatchEvent(evt)
|
||||||
|
|
||||||
|
this.stdinfo.appendChild(this.document.createTextNode("Set answer to "))
|
||||||
|
this.stdinfo.appendChild(this.document.createElement("code")).textContent = answer
|
||||||
|
}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
let start = performance.now()
|
||||||
|
this.runButton.disabled = true
|
||||||
|
this.status.textContent = "Running..."
|
||||||
|
|
||||||
|
// Save first. Always save first.
|
||||||
|
let program = this.jar.toString()
|
||||||
|
if (program != this.originalCode) {
|
||||||
|
localStorage[this.storageKey] = program
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await this.workerMessage({
|
||||||
|
type: "run",
|
||||||
|
code: program,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.stdout.textContent = result.stdout
|
||||||
|
this.stderr.textContent = result.stderr
|
||||||
|
this.traceback.textContent = result.traceback
|
||||||
|
while (this.stdinfo.firstChild) this.stdinfo.firstChild.remove()
|
||||||
|
if (result.answer) {
|
||||||
|
this.setAnswer(result.answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtime = performance.now() - start
|
||||||
|
let duration = new Date(runtime).toISOString().slice(11, -1)
|
||||||
|
this.status.textContent = "Ran in " + duration
|
||||||
|
this.runButton.disabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
revert() {
|
||||||
|
let currentCode = this.jar.toString()
|
||||||
|
let savedCode = localStorage[this.storageKey]
|
||||||
|
if ((currentCode == this.originalCode) && savedCode) {
|
||||||
|
this.jar.updateCode(savedCode)
|
||||||
|
Toast("Re-loaded saved code")
|
||||||
|
} else {
|
||||||
|
this.jar.updateCode(this.originalCode)
|
||||||
|
Toast("Reverted to original code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
font() {
|
||||||
|
this.element.classList.toggle("fixed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
let link = document.head.appendChild(document.createElement("link"))
|
||||||
|
link.rel = "stylesheet"
|
||||||
|
link.href = prismCssUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init)
|
||||||
|
} else {
|
||||||
|
init()
|
||||||
|
}
|
Loading…
Reference in New Issue