mirror of https://github.com/dirtbags/moth.git
Merge branch 'libmoth' into github/fork/knewbetter/scoreboard-js-dependency-loading
This commit is contained in:
commit
34e51848be
|
@ -1,70 +0,0 @@
|
||||||
name: Build/Test/Push
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- v3
|
|
||||||
- devel
|
|
||||||
- main
|
|
||||||
tags:
|
|
||||||
- 'v*.*.*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test-mothd:
|
|
||||||
name: Test mothd
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Install Go
|
|
||||||
uses: actions/setup-go@v2
|
|
||||||
with:
|
|
||||||
go-version: 1.13
|
|
||||||
|
|
||||||
- name: Retrieve code
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: go test ./...
|
|
||||||
|
|
||||||
publish:
|
|
||||||
name: Publish container images
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Retrieve code
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Gitlab variables
|
|
||||||
id: vars
|
|
||||||
run: build/ci/gitlab-vars
|
|
||||||
|
|
||||||
- name: Login to GitHub Packages Docker Registry
|
|
||||||
uses: docker/login-action@v1
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.CR_PAT }}
|
|
||||||
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v1
|
|
||||||
with:
|
|
||||||
username: neale
|
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v1
|
|
||||||
|
|
||||||
# Currently required, because buildx doesn't support auto-push from docker
|
|
||||||
- name: Set up builder
|
|
||||||
uses: docker/setup-buildx-action@v1
|
|
||||||
id: buildx
|
|
||||||
|
|
||||||
- name: Build and push moth image
|
|
||||||
uses: docker/build-push-action@v2
|
|
||||||
with:
|
|
||||||
builder: ${{ steps.buildx.outputs.name }}
|
|
||||||
target: moth
|
|
||||||
file: build/package/Containerfile
|
|
||||||
push: true
|
|
||||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64
|
|
||||||
tags: |
|
|
||||||
dirtbags/moth:${{ steps.vars.outputs.tag }}
|
|
||||||
ghcr.io/dirtbags/moth:${{ steps.vars.outputs.tag }}
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
stages:
|
||||||
|
- test
|
||||||
|
- push
|
||||||
|
|
||||||
|
Run unit tests:
|
||||||
|
stage: test
|
||||||
|
image: &goimage golang:1.21
|
||||||
|
only:
|
||||||
|
refs:
|
||||||
|
- main
|
||||||
|
- tags
|
||||||
|
- merge_requests
|
||||||
|
script:
|
||||||
|
- 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
|
||||||
|
|
||||||
|
push:
|
||||||
|
stage: push
|
||||||
|
needs: ["Run unit tests"]
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
|
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||||
|
script:
|
||||||
|
- mkdir ~/.docker
|
||||||
|
- echo "$DOCKER_AUTH_CONFIG" | tee ~/.docker/config.json | md5sum
|
||||||
|
- sh build/ci/ci.sh publish
|
55
CHANGELOG.md
55
CHANGELOG.md
|
@ -4,7 +4,60 @@ 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.2.1] - unreleased
|
## [v4.6.0] - unreleased
|
||||||
|
### Changed
|
||||||
|
- Answer hashes are now the first 4 characters of the hex-encoded SHA1 digest
|
||||||
|
- Reworked the built-in theme
|
||||||
|
- [moth.mjs](theme/moth.mjs) is now the standard MOTH library for ECMAScript
|
||||||
|
- Devel mode no longer accepts an empty team ID
|
||||||
|
|
||||||
|
## [v4.4.9] - 2022-05-12
|
||||||
|
### Changed
|
||||||
|
- Added a performance optimization for events with a large number of teams
|
||||||
|
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
|
||||||
|
### Added
|
||||||
|
- State is now cached in memory, in an attempt to reduce filesystem metadata operations,
|
||||||
|
which kill NFS.
|
||||||
|
|
||||||
|
## [v4.4.5] - 2021-10-26
|
||||||
|
### Added
|
||||||
|
- Images deploying to docker hub too. We're now at capacity for our Docker Hub team.
|
||||||
|
|
||||||
|
## [v4.4.4] - 2021-10-20
|
||||||
|
### Changed
|
||||||
|
- Trying to get CI push of built images. I expect this to fail, too. But in a way that can help me debug the issue.
|
||||||
|
|
||||||
|
## [v4.3.3] - 2021-10-20
|
||||||
|
### Fixed
|
||||||
|
- Points awarded while scoring is paused are now correctly sorted (#168)
|
||||||
|
- Writing a new mothball with the same name is now detected and the new mothball loaded (#172)
|
||||||
|
- Regression test for issue where URL path leading directories were ignored (#144)
|
||||||
|
- A few other very minor bugs were closed when I couldn't reproduce them or decided they weren't actually bugs.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Many error messages were changed to start with a lower-case letter,
|
||||||
|
in order to satisfy a new linter check.
|
||||||
|
- CI/CD moved to our Cyber Fire Gitlab instance
|
||||||
|
- I attempted to have the build thingy automatically build moth:v4 and moth:v4.3 and moth:v4.3.3 images,
|
||||||
|
but I can't test it without tagging a release.
|
||||||
|
So v4.3.4 might come out very soon after this ;)
|
||||||
|
|
||||||
|
## [v4.2.2] - 2021-09-30
|
||||||
|
### Added
|
||||||
|
- `debug.notes` front matter field
|
||||||
|
|
||||||
|
## [v4.2.1] - 2021-04-13
|
||||||
### Fixed
|
### Fixed
|
||||||
- Transpiled KSAs no longer dropped
|
- Transpiled KSAs no longer dropped
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
@ -33,7 +32,7 @@ You can read more about why we made these decisions in [philosophy](docs/philoso
|
||||||
Run in demonstration mode
|
Run in demonstration mode
|
||||||
===========
|
===========
|
||||||
|
|
||||||
docker run --rm -it -p 8080:8080 dirtbags/moth-devel
|
docker run --rm -it -p 8080:8080 ghcr.io/dirtbags/moth-devel
|
||||||
|
|
||||||
Then open http://localhost:8080/ and check out the example puzzles.
|
Then open http://localhost:8080/ and check out the example puzzles.
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
#! /bin/sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
images="ghcr.io/dirtbags/moth dirtbags/moth"
|
||||||
|
|
||||||
|
ACTION=$1
|
||||||
|
if [ -z "$ACTION" ]; then
|
||||||
|
echo "Usage: $0 ACTION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log () {
|
||||||
|
printf "=== %s\n" "$*" 1>&2
|
||||||
|
}
|
||||||
|
|
||||||
|
fail () {
|
||||||
|
printf "\033[31;1m=== FAIL: %s\033[0m\n" "$*" 1>&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
run () {
|
||||||
|
printf "\033[32m\$\033[0m %s\n" "$*" 1>&2
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
tags () {
|
||||||
|
pfx=$1
|
||||||
|
for base in $images; do
|
||||||
|
echo $pfx $base:${CI_COMMIT_REF_NAME}
|
||||||
|
echo $pfx $base:${CI_COMMIT_REF_NAME%.*}
|
||||||
|
echo $pfx $base:${CI_COMMIT_REF_NAME%.*.*}
|
||||||
|
done | uniq
|
||||||
|
}
|
||||||
|
|
||||||
|
case $ACTION in
|
||||||
|
publish)
|
||||||
|
run docker build \
|
||||||
|
--file build/package/Containerfile \
|
||||||
|
$(tags --tag) \
|
||||||
|
.
|
||||||
|
tags | while read image; do
|
||||||
|
run docker push $image
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown action: $1" 1>&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
|
@ -7,7 +7,7 @@ COPY example-puzzles /target/puzzles/
|
||||||
COPY LICENSE.md /target/
|
COPY LICENSE.md /target/
|
||||||
RUN mkdir -p /target/state
|
RUN mkdir -p /target/state
|
||||||
WORKDIR /src/
|
WORKDIR /src/
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go install -i -a -ldflags '-extldflags "-static"' ./...
|
RUN CGO_ENABLED=0 GOOS=linux go install -a -ldflags '-extldflags "-static"' ./...
|
||||||
# I can't use /target/bin: doing so would cause the devel server to overwrite Ubuntu's /bin
|
# I can't use /target/bin: doing so would cause the devel server to overwrite Ubuntu's /bin
|
||||||
|
|
||||||
##########
|
##########
|
||||||
|
|
|
@ -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)
|
||||||
|
@ -117,11 +116,11 @@ func (h *HTTPServer) RegisterHandler(mh MothRequestHandler, w http.ResponseWrite
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := mh.Register(teamName); err == ErrAlreadyRegistered {
|
if err := mh.Register(teamName); err == ErrAlreadyRegistered {
|
||||||
jsend.Sendf(w, jsend.Success, "already registered", "Team ID has already been registered")
|
jsend.Sendf(w, jsend.Success, "already registered", "team ID has already been registered")
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
jsend.Sendf(w, jsend.Fail, "not registered", err.Error())
|
jsend.Sendf(w, jsend.Fail, "not registered", err.Error())
|
||||||
} else {
|
} else {
|
||||||
jsend.Sendf(w, jsend.Success, "registered", "Team ID registered")
|
jsend.Sendf(w, jsend.Success, "registered", "team ID registered")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,21 +7,15 @@ 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)
|
||||||
if args != nil {
|
for k, v := range args {
|
||||||
for k, v := range args {
|
vals.Set(k, v)
|
||||||
vals.Set(k, v)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
@ -35,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())
|
||||||
|
@ -56,22 +51,24 @@ func TestHttpd(t *testing.T) {
|
||||||
|
|
||||||
if r := hs.TestRequest("/register", map[string]string{"id": "bad team id", "name": "GoTeam"}); r.Result().StatusCode != 200 {
|
if r := hs.TestRequest("/register", map[string]string{"id": "bad team id", "name": "GoTeam"}); r.Result().StatusCode != 200 {
|
||||||
t.Error(r.Result())
|
t.Error(r.Result())
|
||||||
} else if r.Body.String() != `{"status":"fail","data":{"short":"not registered","description":"Team ID not found in list of valid Team IDs"}}` {
|
} else if r.Body.String() != `{"status":"fail","data":{"short":"not registered","description":"team ID not found in list of valid team IDs"}}` {
|
||||||
t.Error("Register bad team ID failed")
|
t.Error("Register bad team ID failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
if r := hs.TestRequest("/register", map[string]string{"name": "GoTeam"}); r.Result().StatusCode != 200 {
|
if r := hs.TestRequest("/register", map[string]string{"name": "GoTeam"}); r.Result().StatusCode != 200 {
|
||||||
t.Error(r.Result())
|
t.Error(r.Result())
|
||||||
} else if r.Body.String() != `{"status":"success","data":{"short":"registered","description":"Team ID registered"}}` {
|
} else if r.Body.String() != `{"status":"success","data":{"short":"registered","description":"team ID registered"}}` {
|
||||||
t.Error("Register failed")
|
t.Error("Register failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
if r := hs.TestRequest("/register", map[string]string{"name": "GoTeam"}); r.Result().StatusCode != 200 {
|
if r := hs.TestRequest("/register", map[string]string{"name": "GoTeam"}); r.Result().StatusCode != 200 {
|
||||||
t.Error(r.Result())
|
t.Error(r.Result())
|
||||||
} else if r.Body.String() != `{"status":"success","data":{"short":"already registered","description":"Team ID has already been registered"}}` {
|
} else if r.Body.String() != `{"status":"success","data":{"short":"already registered","description":"team ID has already been registered"}}` {
|
||||||
t.Error("Register failed", r.Body.String())
|
t.Error("Register failed", r.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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},"Messages":"messages.html","TeamNames":{"self":"GoTeam"},"PointsLog":[],"Puzzles":{"pategory":[1]}}` {
|
||||||
|
@ -102,7 +99,7 @@ func TestHttpd(t *testing.T) {
|
||||||
|
|
||||||
if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "moo"}); r.Result().StatusCode != 200 {
|
if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "moo"}); r.Result().StatusCode != 200 {
|
||||||
t.Error(r.Result())
|
t.Error(r.Result())
|
||||||
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"Incorrect answer"}}` {
|
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"incorrect answer"}}` {
|
||||||
t.Error("Unexpected body", r.Body.String())
|
t.Error("Unexpected body", 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,6 +19,7 @@ import (
|
||||||
type zipCategory struct {
|
type zipCategory struct {
|
||||||
afero.Fs
|
afero.Fs
|
||||||
io.Closer
|
io.Closer
|
||||||
|
mtime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mothballs provides a collection of active mothball files (puzzle categories)
|
// Mothballs provides a collection of active mothball files (puzzle categories)
|
||||||
|
@ -48,7 +49,7 @@ func (m *Mothballs) getCat(cat string) (zipCategory, bool) {
|
||||||
func (m *Mothballs) Open(cat string, points int, filename string) (ReadSeekCloser, time.Time, error) {
|
func (m *Mothballs) Open(cat string, points int, filename string) (ReadSeekCloser, time.Time, error) {
|
||||||
zc, ok := m.getCat(cat)
|
zc, ok := m.getCat(cat)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, time.Time{}, fmt.Errorf("No such category: %s", cat)
|
return nil, time.Time{}, fmt.Errorf("no such category: %s", cat)
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := zc.Open(fmt.Sprintf("%d/%s", points, filename))
|
f, err := zc.Open(fmt.Sprintf("%d/%s", points, filename))
|
||||||
|
@ -91,12 +92,12 @@ func (m *Mothballs) Inventory() []Category {
|
||||||
func (m *Mothballs) CheckAnswer(cat string, points int, answer string) (bool, error) {
|
func (m *Mothballs) CheckAnswer(cat string, points int, answer string) (bool, error) {
|
||||||
zfs, ok := m.getCat(cat)
|
zfs, ok := m.getCat(cat)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false, fmt.Errorf("No such category: %s", cat)
|
return false, fmt.Errorf("no such category: %s", cat)
|
||||||
}
|
}
|
||||||
|
|
||||||
af, err := zfs.Open("answers.txt")
|
af, err := zfs.Open("answers.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("No answers.txt file")
|
return false, fmt.Errorf("no answers.txt file")
|
||||||
}
|
}
|
||||||
defer af.Close()
|
defer af.Close()
|
||||||
|
|
||||||
|
@ -132,7 +133,18 @@ func (m *Mothballs) refresh() {
|
||||||
categoryName := strings.TrimSuffix(filename, ".mb")
|
categoryName := strings.TrimSuffix(filename, ".mb")
|
||||||
found[categoryName] = true
|
found[categoryName] = true
|
||||||
|
|
||||||
if _, ok := m.categories[categoryName]; !ok {
|
reopen := false
|
||||||
|
if existingMothball, ok := m.categories[categoryName]; !ok {
|
||||||
|
reopen = true
|
||||||
|
} else if si, err := m.Fs.Stat(filename); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
} else if si.ModTime().After(existingMothball.mtime) {
|
||||||
|
existingMothball.Close()
|
||||||
|
delete(m.categories, categoryName)
|
||||||
|
reopen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if reopen {
|
||||||
f, err := m.Fs.Open(filename)
|
f, err := m.Fs.Open(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
|
@ -156,6 +168,7 @@ func (m *Mothballs) refresh() {
|
||||||
m.categories[categoryName] = zipCategory{
|
m.categories[categoryName] = zipCategory{
|
||||||
Fs: zipfs.New(zrc),
|
Fs: zipfs.New(zrc),
|
||||||
Closer: f,
|
Closer: f,
|
||||||
|
mtime: fi.ModTime(),
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Adding category:", categoryName)
|
log.Println("Adding category:", categoryName)
|
||||||
|
@ -174,7 +187,7 @@ func (m *Mothballs) refresh() {
|
||||||
|
|
||||||
// Mothball just returns an error
|
// Mothball just returns an error
|
||||||
func (m *Mothballs) Mothball(cat string, w io.Writer) error {
|
func (m *Mothballs) Mothball(cat string, w io.Writer) error {
|
||||||
return fmt.Errorf("Refusing to repackage a compiled mothball")
|
return fmt.Errorf("refusing to repackage a compiled mothball")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maintain performs housekeeping for Mothballs.
|
// Maintain performs housekeeping for Mothballs.
|
||||||
|
|
|
@ -3,25 +3,27 @@ package main
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
var testFiles = []struct {
|
type testFileContents struct {
|
||||||
Name, Body string
|
Name, Body string
|
||||||
}{
|
}
|
||||||
|
|
||||||
|
var testFiles = []testFileContents{
|
||||||
{"puzzles.txt", "1\n3\n2\n"},
|
{"puzzles.txt", "1\n3\n2\n"},
|
||||||
{"answers.txt", "1 answer123\n1 answer456\n2 wat\n"},
|
{"answers.txt", "1 answer123\n1 answer456\n2 wat\n"},
|
||||||
{"1/puzzle.json", `{"name": "moo"}`},
|
{"1/puzzle.json", `{"name": "moo"}`},
|
||||||
{"1/moo.txt", `moo`},
|
|
||||||
{"2/puzzle.json", `{}`},
|
{"2/puzzle.json", `{}`},
|
||||||
{"2/moo.txt", `moo`},
|
{"2/moo.txt", `moo`},
|
||||||
{"3/puzzle.json", `{}`},
|
{"3/puzzle.json", `{}`},
|
||||||
{"3/moo.txt", `moo`},
|
{"3/moo.txt", `moo`},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mothballs) createMothball(cat string) {
|
func (m *Mothballs) createMothballWithFiles(cat string, contents []testFileContents) {
|
||||||
f, _ := m.Create(fmt.Sprintf("%s.mb", cat))
|
f, _ := m.Create(fmt.Sprintf("%s.mb", cat))
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
|
@ -32,6 +34,19 @@ func (m *Mothballs) createMothball(cat string) {
|
||||||
of, _ := w.Create(file.Name)
|
of, _ := w.Create(file.Name)
|
||||||
of.Write([]byte(file.Body))
|
of.Write([]byte(file.Body))
|
||||||
}
|
}
|
||||||
|
for _, file := range contents {
|
||||||
|
of, _ := w.Create(file.Name)
|
||||||
|
of.Write([]byte(file.Body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mothballs) createMothball(cat string) {
|
||||||
|
m.createMothballWithFiles(
|
||||||
|
cat,
|
||||||
|
[]testFileContents{
|
||||||
|
{"1/moo.txt", "moo"},
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTestMothballs() *Mothballs {
|
func NewTestMothballs() *Mothballs {
|
||||||
|
@ -92,10 +107,27 @@ func TestMothballs(t *testing.T) {
|
||||||
}
|
}
|
||||||
if ok, err := m.CheckAnswer("nealegory", 1, "moo"); ok {
|
if ok, err := m.CheckAnswer("nealegory", 1, "moo"); ok {
|
||||||
t.Error("Checking answer in non-existent category should fail")
|
t.Error("Checking answer in non-existent category should fail")
|
||||||
} else if err.Error() != "No such category: nealegory" {
|
} else if err.Error() != "no such category: nealegory" {
|
||||||
t.Error("Wrong error message")
|
t.Error("Wrong error message")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
goofyText := "bozonics"
|
||||||
|
//time.Sleep(1 * time.Second) // I don't love this, but we need the mtime to increase, and it's only accurate to 1s
|
||||||
|
m.createMothballWithFiles(
|
||||||
|
"pategory",
|
||||||
|
[]testFileContents{
|
||||||
|
{"1/moo.txt", goofyText},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
m.refresh()
|
||||||
|
if f, _, err := m.Open("pategory", 1, "moo.txt"); err != nil {
|
||||||
|
t.Error("pategory/1/moo.txt", err)
|
||||||
|
} else if contents, err := ioutil.ReadAll(f); err != nil {
|
||||||
|
t.Error("read all pategory/1/moo.txt", err)
|
||||||
|
} else if string(contents) != goofyText {
|
||||||
|
t.Error("read all replacement pategory/1/moo.txt contents wrong, got", string(contents))
|
||||||
|
}
|
||||||
|
|
||||||
m.createMothball("test2")
|
m.createMothball("test2")
|
||||||
m.Fs.Remove("pategory.mb")
|
m.Fs.Remove("pategory.mb")
|
||||||
m.refresh()
|
m.refresh()
|
||||||
|
|
|
@ -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
|
||||||
|
@ -125,7 +125,7 @@ func (pc ProviderCommand) CheckAnswer(cat string, points int, answer string) (bo
|
||||||
|
|
||||||
// Mothball just returns an error
|
// Mothball just returns an error
|
||||||
func (pc ProviderCommand) Mothball(cat string) (*bytes.Reader, error) {
|
func (pc ProviderCommand) Mothball(cat string) (*bytes.Reader, error) {
|
||||||
return nil, fmt.Errorf("Can't package a command-generated category")
|
return nil, fmt.Errorf("can't package a command-generated category")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maintain does nothing: a command puzzle ProviderCommand has no housekeeping
|
// Maintain does nothing: a command puzzle ProviderCommand has no housekeeping
|
||||||
|
|
|
@ -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.
|
||||||
|
@ -58,7 +58,7 @@ type StateProvider interface {
|
||||||
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,19 +92,17 @@ 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PuzzlesOpen opens a file associated with a puzzle.
|
// PuzzlesOpen opens a file associated with a puzzle.
|
||||||
|
@ -115,7 +116,7 @@ func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
return nil, time.Time{}, fmt.Errorf("Puzzle does not exist or is locked")
|
return nil, time.Time{}, fmt.Errorf("puzzle does not exist or is locked")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try every provider until someone doesn't return an error
|
// Try every provider until someone doesn't return an error
|
||||||
|
@ -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,12 +184,15 @@ 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.Messages = mh.State.Messages()
|
||||||
export.TeamNames = make(map[string]string)
|
export.TeamNames = make(map[string]string)
|
||||||
|
@ -254,7 +257,7 @@ func (mh *MothRequestHandler) Mothball(cat string, w io.Writer) error {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if !mh.Config.Devel {
|
if !mh.Config.Devel {
|
||||||
return fmt.Errorf("Cannot mothball in production mode")
|
return fmt.Errorf("cannot mothball in production mode")
|
||||||
}
|
}
|
||||||
for _, provider := range mh.PuzzleProviders {
|
for _, provider := range mh.PuzzleProviders {
|
||||||
if err = provider.Mothball(cat, w); err == nil {
|
if err = provider.Mothball(cat, w); err == nil {
|
||||||
|
|
|
@ -3,34 +3,46 @@ 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)
|
afero.WriteFile(state, "messages.html", []byte("messages.html"), 0644)
|
||||||
go state.Maintain(TestMaintenanceInterval)
|
state.refresh()
|
||||||
|
|
||||||
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 +57,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,13 +91,15 @@ func TestProdServer(t *testing.T) {
|
||||||
t.Error("index.html wrong contents", contents)
|
t.Error("index.html wrong contents", contents)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
server.refresh()
|
||||||
|
|
||||||
{
|
{
|
||||||
es := handler.ExportState()
|
es := handler.ExportState()
|
||||||
if es.Config.Devel {
|
if es.Config.Devel {
|
||||||
t.Error("Marked as development server", es.Config)
|
t.Error("Marked as development server", es.Config)
|
||||||
}
|
}
|
||||||
if len(es.Puzzles) != 1 {
|
if len(es.Puzzles) != 1 {
|
||||||
t.Error("Puzzle categories wrong length")
|
t.Error("Puzzle categories wrong length", len(es.Puzzles))
|
||||||
}
|
}
|
||||||
if es.Messages != "messages.html" {
|
if es.Messages != "messages.html" {
|
||||||
t.Error("Messages has wrong contents")
|
t.Error("Messages has wrong contents")
|
||||||
|
@ -131,7 +144,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()
|
||||||
|
@ -160,7 +173,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()
|
||||||
|
@ -168,9 +181,8 @@ func TestProdServer(t *testing.T) {
|
||||||
t.Error("Anonymous TeamNames is wrong:", es.TeamNames)
|
t.Error("Anonymous TeamNames is wrong:", es.TeamNames)
|
||||||
}
|
}
|
||||||
if len(es.PointsLog) != 2 {
|
if len(es.PointsLog) != 2 {
|
||||||
t.Error("Points log wrong length")
|
t.Errorf("Points log wrong length: got %d, wanted 2", len(es.PointsLog))
|
||||||
}
|
} else if es.PointsLog[1].TeamID != "0" {
|
||||||
if es.PointsLog[1].TeamID != "0" {
|
|
||||||
t.Error("Second point log didn't anonymize team ID correctly:", es.PointsLog[1])
|
t.Error("Second point log didn't anonymize team ID correctly:", es.PointsLog[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,9 +11,10 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dirtbags/moth/pkg/award"
|
"github.com/dirtbags/moth/v4/pkg/award"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -27,7 +28,7 @@ const DistinguishableChars = "34678abcdefhikmnpqrtwxy="
|
||||||
const RFC3339Space = "2006-01-02 15:04:05Z07:00"
|
const RFC3339Space = "2006-01-02 15:04:05Z07:00"
|
||||||
|
|
||||||
// ErrAlreadyRegistered means a team cannot be registered because it was registered previously.
|
// ErrAlreadyRegistered means a team cannot be registered because it was registered previously.
|
||||||
var ErrAlreadyRegistered = errors.New("Team ID has already been registered")
|
var ErrAlreadyRegistered = errors.New("team ID has already been registered")
|
||||||
|
|
||||||
// State defines the current state of a MOTH instance.
|
// State defines the current state of a MOTH instance.
|
||||||
// We use the filesystem for synchronization between threads.
|
// We use the filesystem for synchronization between threads.
|
||||||
|
@ -38,10 +39,18 @@ type State struct {
|
||||||
// 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
|
||||||
|
teamNamesLastChange time.Time
|
||||||
|
teamNames map[string]string
|
||||||
|
pointsLog award.List
|
||||||
|
messages string
|
||||||
|
lock sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewState returns a new State struct backed by the given Fs
|
// NewState returns a new State struct backed by the given Fs
|
||||||
|
@ -51,6 +60,8 @@ func NewState(fs afero.Fs) *State {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
refreshNow: make(chan bool, 5),
|
refreshNow: make(chan bool, 5),
|
||||||
eventStream: make(chan []string, 80),
|
eventStream: make(chan []string, 80),
|
||||||
|
|
||||||
|
teamNames: make(map[string]string),
|
||||||
}
|
}
|
||||||
if err := s.reopenEventLog(); err != nil {
|
if err := s.reopenEventLog(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
@ -61,11 +72,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() {
|
||||||
|
@ -85,59 +95,64 @@ 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
|
|
||||||
why = "`state/enabled` missing"
|
|
||||||
}
|
|
||||||
|
|
||||||
if nextEnabled != s.Enabled {
|
|
||||||
s.Enabled = nextEnabled
|
s.Enabled = nextEnabled
|
||||||
log.Printf("Setting enabled=%v: %s", s.Enabled, why)
|
s.enabledWhy = why
|
||||||
|
log.Printf("Setting enabled=%v: %s", s.Enabled, s.enabledWhy)
|
||||||
if s.Enabled {
|
if s.Enabled {
|
||||||
s.LogEvent("enabled", "", "", "", 0, why)
|
s.LogEvent("enabled", "", "", 0, s.enabledWhy)
|
||||||
} else {
|
} else {
|
||||||
s.LogEvent("disabled", "", "", "", 0, why)
|
s.LogEvent("disabled", "", "", 0, s.enabledWhy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TeamName returns team name given a team ID.
|
// TeamName returns team name given a team ID.
|
||||||
func (s *State) TeamName(teamID string) (string, error) {
|
func (s *State) TeamName(teamID string) (string, error) {
|
||||||
teamFs := afero.NewBasePathFs(s.Fs, "teams")
|
s.lock.RLock()
|
||||||
teamNameBytes, err := afero.ReadFile(teamFs, teamID)
|
name, ok := s.teamNames[teamID]
|
||||||
if os.IsNotExist(err) {
|
s.lock.RUnlock()
|
||||||
return "", fmt.Errorf("Unregistered team ID: %s", teamID)
|
if !ok {
|
||||||
} else if err != nil {
|
return "", fmt.Errorf("unregistered team ID: %s", teamID)
|
||||||
return "", fmt.Errorf("Unregistered team ID: %s (%s)", teamID, err)
|
|
||||||
}
|
}
|
||||||
|
return name, nil
|
||||||
teamName := strings.TrimSpace(string(teamNameBytes))
|
|
||||||
return teamName, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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")
|
||||||
}
|
}
|
||||||
defer idsFile.Close()
|
defer idsFile.Close()
|
||||||
found := false
|
found := false
|
||||||
|
@ -149,7 +164,7 @@ func (s *State) SetTeamName(teamID, teamName string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
return fmt.Errorf("Team ID not found in list of valid Team IDs")
|
return fmt.Errorf("team ID not found in list of valid team IDs")
|
||||||
}
|
}
|
||||||
|
|
||||||
teamFilename := filepath.Join("teams", teamID)
|
teamFilename := filepath.Join("teams", teamID)
|
||||||
|
@ -163,36 +178,26 @@ func (s *State) SetTeamName(teamID, teamName string) error {
|
||||||
log.Printf("Setting team name [%s] in file %s", teamName, teamFilename)
|
log.Printf("Setting team name [%s] in file %s", teamName, teamFilename)
|
||||||
fmt.Fprintln(teamFile, teamName)
|
fmt.Fprintln(teamFile, teamName)
|
||||||
teamFile.Close()
|
teamFile.Close()
|
||||||
|
|
||||||
|
s.refreshNow <- true
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PointsLog retrieves the current points log.
|
// PointsLog retrieves the current points log.
|
||||||
func (s *State) PointsLog() award.List {
|
func (s *State) PointsLog() award.List {
|
||||||
f, err := s.Open("points.log")
|
s.lock.RLock()
|
||||||
if err != nil {
|
ret := make(award.List, len(s.pointsLog))
|
||||||
log.Println(err)
|
copy(ret, s.pointsLog)
|
||||||
return nil
|
s.lock.RUnlock()
|
||||||
}
|
return ret
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
pointsLog := make(award.List, 0, 200)
|
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
cur, err := award.Parse(line)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Skipping malformed award line %s: %s", line, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pointsLog = append(pointsLog, cur)
|
|
||||||
}
|
|
||||||
return pointsLog
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Messages retrieves the current messages.
|
// Messages retrieves the current messages.
|
||||||
func (s *State) Messages() string {
|
func (s *State) Messages() string {
|
||||||
bMessages, _ := afero.ReadFile(s, "messages.html")
|
s.lock.RLock() // It's not clear to me that this actually needs to happen
|
||||||
return string(bMessages)
|
defer s.lock.RUnlock()
|
||||||
|
return s.messages
|
||||||
}
|
}
|
||||||
|
|
||||||
// AwardPoints gives points to teamID in category.
|
// AwardPoints gives points to teamID in category.
|
||||||
|
@ -202,8 +207,12 @@ func (s *State) Messages() string {
|
||||||
// It's just a courtesy to the user.
|
// It's just a courtesy to the user.
|
||||||
// The update task makes sure we never have duplicate points in the log.
|
// The update task makes sure we never have duplicate points in the log.
|
||||||
func (s *State) AwardPoints(teamID, category string, points int) error {
|
func (s *State) AwardPoints(teamID, category string, points int) error {
|
||||||
|
return s.awardPointsAtTime(time.Now().Unix(), teamID, category, points)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) awardPointsAtTime(when int64, teamID string, category string, points int) error {
|
||||||
a := award.T{
|
a := award.T{
|
||||||
When: time.Now().Unix(),
|
When: when,
|
||||||
TeamID: teamID,
|
TeamID: teamID,
|
||||||
Category: category,
|
Category: category,
|
||||||
Points: points,
|
Points: points,
|
||||||
|
@ -211,11 +220,12 @@ func (s *State) AwardPoints(teamID, category string, points int) error {
|
||||||
|
|
||||||
for _, e := range s.PointsLog() {
|
for _, e := range s.PointsLog() {
|
||||||
if a.Equal(e) {
|
if a.Equal(e) {
|
||||||
return fmt.Errorf("Points already awarded to this team in this category")
|
return fmt.Errorf("points already awarded to this team in this category")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn := fmt.Sprintf("%s-%s-%d", teamID, category, points)
|
//fn := fmt.Sprintf("%s-%s-%d", a.TeamID, a.Category, a.Points)
|
||||||
|
fn := a.Filename()
|
||||||
tmpfn := filepath.Join("points.tmp", fn)
|
tmpfn := filepath.Join("points.tmp", fn)
|
||||||
newfn := filepath.Join("points.new", fn)
|
newfn := filepath.Join("points.new", fn)
|
||||||
|
|
||||||
|
@ -255,12 +265,14 @@ func (s *State) collectPoints() {
|
||||||
}
|
}
|
||||||
|
|
||||||
duplicate := false
|
duplicate := false
|
||||||
for _, e := range s.PointsLog() {
|
s.lock.RLock()
|
||||||
|
for _, e := range s.pointsLog {
|
||||||
if awd.Equal(e) {
|
if awd.Equal(e) {
|
||||||
duplicate = true
|
duplicate = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
s.lock.RUnlock()
|
||||||
|
|
||||||
if duplicate {
|
if duplicate {
|
||||||
log.Print("Skipping duplicate points: ", awd.String())
|
log.Print("Skipping duplicate points: ", awd.String())
|
||||||
|
@ -274,6 +286,11 @@ func (s *State) collectPoints() {
|
||||||
}
|
}
|
||||||
fmt.Fprintln(logf, awd.String())
|
fmt.Fprintln(logf, awd.String())
|
||||||
logf.Close()
|
logf.Close()
|
||||||
|
|
||||||
|
// Stick this on the cache too
|
||||||
|
s.lock.Lock()
|
||||||
|
s.pointsLog = append(s.pointsLog, awd)
|
||||||
|
s.lock.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Remove(filename); err != nil {
|
if err := s.Remove(filename); err != nil {
|
||||||
|
@ -295,6 +312,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("events.csv")
|
||||||
s.Remove("messages.html")
|
s.Remove("messages.html")
|
||||||
s.Remove("mothd.log")
|
s.Remove("mothd.log")
|
||||||
s.RemoveAll("points.tmp")
|
s.RemoveAll("points.tmp")
|
||||||
|
@ -305,7 +323,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)
|
||||||
|
@ -333,21 +351,19 @@ 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()
|
||||||
|
@ -363,20 +379,12 @@ func (s *State) maybeInitialize() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func logstr(s string) string {
|
|
||||||
if s == "" {
|
|
||||||
return "-"
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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),
|
||||||
|
@ -404,12 +412,72 @@ func (s *State) reopenEventLog() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *State) updateCaches() {
|
||||||
|
s.lock.Lock()
|
||||||
|
defer s.lock.Unlock()
|
||||||
|
|
||||||
|
if f, err := s.Open("points.log"); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
} else {
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
pointsLog := make(award.List, 0, 200)
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
cur, err := award.Parse(line)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Skipping malformed award line %s: %s", line, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pointsLog = append(pointsLog, cur)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
for k := range s.teamNames {
|
||||||
|
delete(s.teamNames, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
teamsFs := afero.NewBasePathFs(s.Fs, "teams")
|
||||||
|
if dirents, err := afero.ReadDir(teamsFs, "."); err != nil {
|
||||||
|
log.Printf("Reading team ids: %v", err)
|
||||||
|
} else {
|
||||||
|
for _, dirent := range dirents {
|
||||||
|
teamID := dirent.Name()
|
||||||
|
if teamNameBytes, err := afero.ReadFile(teamsFs, teamID); err != nil {
|
||||||
|
log.Printf("Reading team %s: %v", teamID, err)
|
||||||
|
} else {
|
||||||
|
teamName := strings.TrimSpace(string(teamNameBytes))
|
||||||
|
s.teamNames[teamID] = teamName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maintain performs housekeeping on a State struct.
|
// Maintain performs housekeeping on a State struct.
|
||||||
|
@ -454,6 +522,9 @@ func (ds *DevelState) TeamName(teamID string) (string, error) {
|
||||||
if name, err := ds.StateProvider.TeamName(teamID); err == nil {
|
if name, err := ds.StateProvider.TeamName(teamID); err == nil {
|
||||||
return name, nil
|
return name, nil
|
||||||
}
|
}
|
||||||
|
if teamID == "" {
|
||||||
|
return "", fmt.Errorf("Empty team ID")
|
||||||
|
}
|
||||||
return fmt.Sprintf("«devel:%s»", teamID), nil
|
return fmt.Sprintf("«devel:%s»", teamID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
@ -57,11 +64,12 @@ func TestState(t *testing.T) {
|
||||||
|
|
||||||
teamName := "My Team"
|
teamName := "My Team"
|
||||||
if err := s.SetTeamName(teamID, teamName); err != nil {
|
if err := s.SetTeamName(teamID, teamName); err != nil {
|
||||||
t.Errorf("Setting team name: %w", err)
|
t.Errorf("Setting team name: %v", err)
|
||||||
}
|
}
|
||||||
if err := s.SetTeamName(teamID, "wat"); err == nil {
|
if err := s.SetTeamName(teamID, "wat"); err == nil {
|
||||||
t.Errorf("Registering team a second time didn't fail")
|
t.Errorf("Registering team a second time didn't fail")
|
||||||
}
|
}
|
||||||
|
s.refresh()
|
||||||
if name, err := s.TeamName(teamID); err != nil {
|
if name, err := s.TeamName(teamID); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
} else if name != teamName {
|
} else if name != teamName {
|
||||||
|
@ -73,9 +81,6 @@ func TestState(t *testing.T) {
|
||||||
if err := s.AwardPoints(teamID, category, points); err != nil {
|
if err := s.AwardPoints(teamID, category, points); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
if err := s.AwardPoints(teamID, category, points); err != nil {
|
|
||||||
t.Error("Two awards before refresh:", err)
|
|
||||||
}
|
|
||||||
// Flex duplicate detection with different timestamp
|
// Flex duplicate detection with different timestamp
|
||||||
if f, err := s.Create("points.new/moo"); err != nil {
|
if f, err := s.Create("points.new/moo"); err != nil {
|
||||||
t.Error("Creating duplicate points file:", err)
|
t.Error("Creating duplicate points file:", err)
|
||||||
|
@ -83,24 +88,34 @@ func TestState(t *testing.T) {
|
||||||
fmt.Fprintln(f, time.Now().Unix()+1, teamID, category, points)
|
fmt.Fprintln(f, time.Now().Unix()+1, teamID, category, points)
|
||||||
f.Close()
|
f.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.AwardPoints(teamID, category, points)
|
||||||
s.refresh()
|
s.refresh()
|
||||||
|
pl = s.PointsLog()
|
||||||
|
if len(pl) != 1 {
|
||||||
|
for i, award := range pl {
|
||||||
|
t.Logf("pl[%d] == %s", i, award.String())
|
||||||
|
}
|
||||||
|
t.Errorf("After awarding duplicate points, points log has length %d", len(pl))
|
||||||
|
} else if (pl[0].TeamID != teamID) || (pl[0].Category != category) || (pl[0].Points != points) {
|
||||||
|
t.Errorf("Incorrect logged award %v", pl)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.AwardPoints(teamID, category, points); err == nil {
|
if err := s.AwardPoints(teamID, category, points); err == nil {
|
||||||
t.Error("Duplicate points award didn't fail")
|
t.Error("Duplicate points award after refresh didn't fail")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.AwardPoints(teamID, category, points+1); err != nil {
|
if err := s.AwardPoints(teamID, category, points+1); err != nil {
|
||||||
t.Error("Awarding more points:", err)
|
t.Error("Awarding more points:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pl = s.PointsLog()
|
s.refresh()
|
||||||
if len(pl) != 1 {
|
if len(s.PointsLog()) != 2 {
|
||||||
t.Errorf("After awarding points, points log has length %d", len(pl))
|
t.Errorf("There should be two awards")
|
||||||
} else if (pl[0].TeamID != teamID) || (pl[0].Category != category) || (pl[0].Points != points) {
|
|
||||||
t.Errorf("Incorrect logged award %v", pl)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
afero.WriteFile(s, "points.log", []byte("intentional parse error\n"), 0644)
|
afero.WriteFile(s, "points.log", []byte("intentional parse error\n"), 0644)
|
||||||
|
s.refresh()
|
||||||
if len(s.PointsLog()) != 0 {
|
if len(s.PointsLog()) != 0 {
|
||||||
t.Errorf("Intentional parse error breaks pointslog")
|
t.Errorf("Intentional parse error breaks pointslog")
|
||||||
}
|
}
|
||||||
|
@ -108,7 +123,8 @@ func TestState(t *testing.T) {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
s.refresh()
|
s.refresh()
|
||||||
if len(s.PointsLog()) != 2 {
|
if len(s.PointsLog()) != 1 {
|
||||||
|
t.Log(s.PointsLog())
|
||||||
t.Error("Intentional parse error screws up all parsing")
|
t.Error("Intentional parse error screws up all parsing")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -122,18 +138,45 @@ func TestState(t *testing.T) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Out of order points insertion, issue #168
|
||||||
|
func TestStateOutOfOrderAward(t *testing.T) {
|
||||||
|
s := NewTestState()
|
||||||
|
|
||||||
|
category := "meow"
|
||||||
|
points := 100
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if err := s.awardPointsAtTime(now+20, "AA", category, points); err != nil {
|
||||||
|
t.Error("Awarding points to team ZZ:", err)
|
||||||
|
}
|
||||||
|
if err := s.awardPointsAtTime(now+10, "ZZ", category, points); err != nil {
|
||||||
|
t.Error("Awarding points to team AA:", err)
|
||||||
|
}
|
||||||
|
s.refresh()
|
||||||
|
pl := s.PointsLog()
|
||||||
|
if len(pl) != 2 {
|
||||||
|
t.Error("Wrong length for points log")
|
||||||
|
}
|
||||||
|
if pl[0].TeamID != "ZZ" {
|
||||||
|
t.Error("Out of order points insertion not properly sorted in points log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -151,19 +194,37 @@ 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, "")
|
||||||
|
@ -171,7 +232,7 @@ func TestStateDisabled(t *testing.T) {
|
||||||
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")
|
||||||
|
@ -185,7 +246,7 @@ func TestStateDisabled(t *testing.T) {
|
||||||
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 {
|
||||||
|
@ -196,14 +257,6 @@ func TestStateDisabled(t *testing.T) {
|
||||||
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 {
|
||||||
|
@ -233,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")
|
||||||
|
@ -258,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
|
||||||
|
}
|
||||||
|
|
|
@ -32,6 +32,11 @@ func TestTheme(t *testing.T) {
|
||||||
t.Error("Timestamp compared wrong")
|
t.Error("Timestamp compared wrong")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if f, _, err := s.Open("/foo/bar/index.html"); err == nil {
|
||||||
|
f.Close()
|
||||||
|
t.Error("Path is ignored")
|
||||||
|
}
|
||||||
|
|
||||||
if f, _, err := s.Open("nofile"); err == nil {
|
if f, _, err := s.Open("nofile"); err == nil {
|
||||||
f.Close()
|
f.Close()
|
||||||
t.Error("Opening non-existent file didn't return an error")
|
t.Error("Opening non-existent file didn't return an error")
|
||||||
|
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
@ -81,7 +81,7 @@ func (t *T) ParseArgs() (Command, error) {
|
||||||
default:
|
default:
|
||||||
fmt.Fprintln(t.Stderr, "ERROR:", t.Args[1], "is not a valid command")
|
fmt.Fprintln(t.Stderr, "ERROR:", t.Args[1], "is not a valid command")
|
||||||
usage(t.Stderr)
|
usage(t.Stderr)
|
||||||
return nothing, fmt.Errorf("Invalid command")
|
return nothing, fmt.Errorf("invalid command")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := flags.Parse(t.Args[2:]); err != nil {
|
if err := flags.Parse(t.Args[2:]); err != nil {
|
||||||
|
|
|
@ -4,11 +4,13 @@ import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -202,3 +204,32 @@ func TestFilesystem(t *testing.T) {
|
||||||
t.Error("Wrong file pulled", stdout.String())
|
t.Error("Wrong file pulled", stdout.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCwd(t *testing.T) {
|
||||||
|
testwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Error("Can't get current working directory!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Chdir(testwd)
|
||||||
|
|
||||||
|
stdin := new(bytes.Buffer)
|
||||||
|
stdout := new(bytes.Buffer)
|
||||||
|
stderr := new(bytes.Buffer)
|
||||||
|
tp := T{
|
||||||
|
Stdin: stdin,
|
||||||
|
Stdout: stdout,
|
||||||
|
Stderr: stderr,
|
||||||
|
BaseFs: afero.NewOsFs(),
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.Reset()
|
||||||
|
os.Chdir("/")
|
||||||
|
if err := tp.Run(
|
||||||
|
"file",
|
||||||
|
fmt.Sprintf("-dir=%s/testdata/cat1/1", testwd),
|
||||||
|
"moo.txt",
|
||||||
|
); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -25,12 +25,12 @@ so you can watch the access log and any error messages.
|
||||||
|
|
||||||
### Podman
|
### Podman
|
||||||
|
|
||||||
podman run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro dirtbags/moth-devel
|
podman run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro ghcr.io/dirtbags/moth-devel
|
||||||
|
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
docker run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro dirtbags/moth-devel
|
docker run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro ghcr.io/dirtbags/moth-devel
|
||||||
|
|
||||||
### Native
|
### Native
|
||||||
|
|
||||||
|
|
|
@ -33,11 +33,11 @@ We're going to assume you put everything in `/srv/moth`, like we suggested.
|
||||||
|
|
||||||
### Podman
|
### Podman
|
||||||
|
|
||||||
podman run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state dirtbags/moth
|
podman run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state ghcr.io/dirtbags/moth
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
docker run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state dirtbags/moth
|
docker run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state ghcr.io/dirtbags/moth
|
||||||
|
|
||||||
### Native
|
### Native
|
||||||
|
|
||||||
|
|
31
docs/logs.md
31
docs/logs.md
|
@ -41,16 +41,18 @@ Each line has four fields:
|
||||||
1602702913 2255 sequence 16
|
1602702913 2255 sequence 16
|
||||||
```
|
```
|
||||||
|
|
||||||
`events.log` format
|
`events.csv` format
|
||||||
----------------------
|
----------------------
|
||||||
|
|
||||||
The events log is a space-separated file.
|
The events log is a comma-separated variable (CSV) file.
|
||||||
|
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 | Team's unique ID| Participant's (hopefully) 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.
|
||||||
|
|
||||||
|
@ -61,6 +63,7 @@ These may change in the future.
|
||||||
* init: startup of server
|
* init: startup of server
|
||||||
* disabled: points accumulation disabled
|
* disabled: points accumulation disabled
|
||||||
* enabled: points accumulation re-enabled
|
* enabled: points accumulation re-enabled
|
||||||
|
* register: team registration
|
||||||
* load: puzzle load
|
* load: puzzle load
|
||||||
* wrong: wrong answer submitted
|
* wrong: wrong answer submitted
|
||||||
* correct: correct answer submitted
|
* correct: correct answer submitted
|
||||||
|
@ -68,14 +71,14 @@ These may change in the future.
|
||||||
### Example
|
### Example
|
||||||
|
|
||||||
```
|
```
|
||||||
1602716345 init - - - - 0
|
1602716345,init,-,-,-,-,0
|
||||||
1602716349 load 2255 player5 sequence 1
|
1602716349,load,2255,player5,sequence,1
|
||||||
1602716450 load 4824 player3 sequence 1
|
1602716450,load,4824,player3,sequence,1
|
||||||
1602716359 correct 2255 player5 sequence 1
|
1602716359,correct,2255,player5,sequence,1
|
||||||
1602716423 wrong 4824 player3 sequence 1
|
1602716423,wrong,4824,player3,sequence,1
|
||||||
1602716428 correct 4824 player3 sequence 1
|
1602716428,correct,4824,player3,sequence,1
|
||||||
1602716530 correct 4824 player3 sequence 1
|
1602716530,correct,4824,player3,sequence,1
|
||||||
1602716546 abduction 4824 player3 - 0 alien FM1490
|
1602716546,abduction,4824,player3,-,0,alien,FM1490
|
||||||
```
|
```
|
||||||
|
|
||||||
The final entry is a made-up "alien abduction" entry,
|
The final entry is a made-up "alien abduction" entry,
|
||||||
|
|
|
@ -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,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.
|
8
go.mod
8
go.mod
|
@ -1,12 +1,12 @@
|
||||||
module github.com/dirtbags/moth
|
module github.com/dirtbags/moth/v4
|
||||||
|
|
||||||
go 1.13
|
go 1.13
|
||||||
|
|
||||||
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
|
github.com/spf13/afero v1.8.2
|
||||||
github.com/yuin/goldmark v1.3.1
|
github.com/yuin/goldmark v1.4.13
|
||||||
golang.org/x/text v0.3.5 // indirect
|
golang.org/x/text v0.3.8 // 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
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
447
go.sum
447
go.sum
|
@ -1,45 +1,454 @@
|
||||||
|
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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
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/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
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/sync v0.0.0-20220722155255-886fb9371eb4/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/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc=
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
|
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/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
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=
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -49,7 +50,7 @@ func Parse(s string) (T, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ret, err
|
return ret, err
|
||||||
} else if n != 4 {
|
} else if n != 4 {
|
||||||
return ret, fmt.Errorf("Malformed award string: only parsed %d fields", n)
|
return ret, fmt.Errorf("malformed award string: only parsed %d fields", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ret, nil
|
return ret, nil
|
||||||
|
@ -60,6 +61,17 @@ func (a T) String() string {
|
||||||
return fmt.Sprintf("%d %s %s %d", a.When, a.TeamID, a.Category, a.Points)
|
return fmt.Sprintf("%d %s %s %d", a.When, a.TeamID, a.Category, a.Points)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filename returns a string version of an award suitable for a filesystem
|
||||||
|
func (a T) Filename() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%d-%s-%s-%d.award",
|
||||||
|
a.When,
|
||||||
|
url.PathEscape(a.TeamID),
|
||||||
|
url.PathEscape(a.Category),
|
||||||
|
a.Points,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MarshalJSON returns the award event, encoded as a list.
|
// MarshalJSON returns the award event, encoded as a list.
|
||||||
func (a T) MarshalJSON() ([]byte, error) {
|
func (a T) MarshalJSON() ([]byte, error) {
|
||||||
ao := []interface{}{
|
ao := []interface{}{
|
||||||
|
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha1"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -37,23 +37,45 @@ type PuzzleDebug struct {
|
||||||
Summary string
|
Summary string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Puzzle contains everything about a puzzle that a client would see.
|
// Puzzle contains everything about a puzzle that a client will see.
|
||||||
type Puzzle struct {
|
type Puzzle struct {
|
||||||
Debug PuzzleDebug
|
// Debug contains debugging information, omitted in mothballs
|
||||||
Authors []string
|
Debug PuzzleDebug
|
||||||
Attachments []string
|
|
||||||
Scripts []string
|
// Authors names all authors of this puzzle
|
||||||
Body string
|
Authors []string
|
||||||
|
|
||||||
|
// Attachments is a list of filenames used by this puzzle
|
||||||
|
Attachments []string
|
||||||
|
|
||||||
|
// Scripts is a list of EMCAScript files needed by the client for this puzzle
|
||||||
|
Scripts []string
|
||||||
|
|
||||||
|
// Body is the HTML rendering of this puzzle
|
||||||
|
Body string
|
||||||
|
|
||||||
|
// AnswerPattern contains the pattern (regular expression?) used to match valid answers
|
||||||
AnswerPattern string
|
AnswerPattern string
|
||||||
AnswerHashes []string
|
|
||||||
Objective string
|
// AnswerHashes contains hashes of all answers for this puzzle
|
||||||
KSAs []string
|
AnswerHashes []string
|
||||||
Success struct {
|
|
||||||
|
// Objective is the learning objective for this puzzle
|
||||||
|
Objective string
|
||||||
|
|
||||||
|
// KSAs lists all KSAs achieved upon successfull completion of this puzzle
|
||||||
|
KSAs []string
|
||||||
|
|
||||||
|
// Success lists the criteria for successfully understanding this puzzle
|
||||||
|
Success struct {
|
||||||
|
// Acceptable describes the minimum work required to be considered successfully understanding this puzzle's concepts
|
||||||
Acceptable string
|
Acceptable string
|
||||||
Mastery string
|
|
||||||
|
// Mastery describes the work required to be considered mastering this puzzle's conceptss
|
||||||
|
Mastery string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Answers will be empty in a mothball
|
// Answers lists all acceptable answers, omitted in mothballs
|
||||||
Answers []string
|
Answers []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,9 +85,9 @@ func (puzzle *Puzzle) computeAnswerHashes() {
|
||||||
}
|
}
|
||||||
puzzle.AnswerHashes = make([]string, len(puzzle.Answers))
|
puzzle.AnswerHashes = make([]string, len(puzzle.Answers))
|
||||||
for i, answer := range puzzle.Answers {
|
for i, answer := range puzzle.Answers {
|
||||||
sum := sha256.Sum256([]byte(answer))
|
sum := sha1.Sum([]byte(answer))
|
||||||
hexsum := fmt.Sprintf("%x", sum)
|
hexsum := fmt.Sprintf("%x", sum)
|
||||||
puzzle.AnswerHashes[i] = hexsum
|
puzzle.AnswerHashes[i] = hexsum[:4]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
328
theme/basic.css
328
theme/basic.css
|
@ -1,227 +1,133 @@
|
||||||
/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */
|
/* Color palette: http://paletton.com/#uid=33x0u0klrl-4ON9dhtKtAdqMQ4T */
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
|
background: #010e19 url("bg.png") center fixed;
|
||||||
|
background-size: cover;
|
||||||
|
background-blend-mode: soft-light;
|
||||||
|
background-color: #010e19;
|
||||||
|
color: #edd488;
|
||||||
|
}
|
||||||
|
canvas.wallpaper {
|
||||||
|
position: fixed;
|
||||||
|
display: block;
|
||||||
|
z-index: -1000;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
opacity: 0.2;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
main {
|
||||||
max-width: 40em;
|
max-width: 40em;
|
||||||
background: #282a33;
|
margin: 1em auto;
|
||||||
color: #f6efdc;
|
padding: 1px 3px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #000d;
|
||||||
}
|
}
|
||||||
body.wide {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
max-width: 100%;
|
color: #cb2408cc;
|
||||||
}
|
|
||||||
a:any-link {
|
|
||||||
color: #8b969a;
|
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
background: #5e576b;
|
background: #cb240844;
|
||||||
color: #9e98a8;
|
padding: 3px;
|
||||||
}
|
|
||||||
.Fail, .Error, #messages {
|
|
||||||
background: #3a3119;
|
|
||||||
color: #ffcc98;
|
|
||||||
}
|
|
||||||
.Fail:before {
|
|
||||||
content: "Fail: ";
|
|
||||||
}
|
|
||||||
.Error:before {
|
|
||||||
content: "Error: ";
|
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
margin: 1em 0em;
|
margin: 1em 0em;
|
||||||
}
|
}
|
||||||
|
a:any-link {
|
||||||
|
color: #b9cbd8;
|
||||||
|
}
|
||||||
form, pre {
|
form, pre {
|
||||||
margin: 1em;
|
margin: 1em;
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
input, select {
|
input, select {
|
||||||
padding: 0.6em;
|
padding: 0.6em;
|
||||||
margin: 0.2em;
|
margin: 0.2em;
|
||||||
max-width: 30em;
|
max-width: 30em;
|
||||||
}
|
}
|
||||||
nav {
|
input {
|
||||||
border: solid black 2px;
|
background-color: #ccc4;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.notification, .error {
|
||||||
|
padding: 0 1em;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.notification {
|
||||||
|
background: #ac8f3944;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background: red;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puzzles list */
|
||||||
|
.category {
|
||||||
|
margin: 5px 0;
|
||||||
|
background: #ccc4;
|
||||||
|
}
|
||||||
|
.category h2 {
|
||||||
|
margin: 0 0.2em;
|
||||||
|
}
|
||||||
|
.category .solved {
|
||||||
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
nav ul, .category ul {
|
nav ul, .category ul {
|
||||||
padding: 1em;
|
margin: 0;
|
||||||
|
padding: 0.2em 1em;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
}
|
}
|
||||||
nav li, .category li {
|
nav li, .category li {
|
||||||
display: inline;
|
display: inline;
|
||||||
margin: 1em;
|
|
||||||
}
|
}
|
||||||
iframe#body {
|
.mothball {
|
||||||
border: inherit;
|
float: right;
|
||||||
width: 100%;
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #ccc;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 5px;
|
||||||
}
|
}
|
||||||
img {
|
|
||||||
|
/** Puzzle content */
|
||||||
|
#puzzle {
|
||||||
|
border-bottom: solid;
|
||||||
|
padding: 0 0.5em;
|
||||||
|
}
|
||||||
|
#puzzle img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
input:invalid {
|
input:invalid {
|
||||||
border-color: red;
|
background-color: #800;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
#messages {
|
.answer_ok {
|
||||||
min-height: 3em;
|
cursor: help;
|
||||||
border: solid black 2px;
|
|
||||||
}
|
|
||||||
#rankings {
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
#rankings span {
|
|
||||||
font-size: 75%;
|
|
||||||
display: inline-block;
|
|
||||||
overflow: hidden;
|
|
||||||
height: 1.7em;
|
|
||||||
}
|
|
||||||
#rankings span.teamname {
|
|
||||||
font-size: inherit;
|
|
||||||
color: white;
|
|
||||||
text-shadow: 0 0 3px black;
|
|
||||||
opacity: 0.8;
|
|
||||||
position: absolute;
|
|
||||||
right: 0.2em;
|
|
||||||
background-color: #292929;
|
|
||||||
background-blend-mode: darken;
|
|
||||||
padding: 0em 0.2em;
|
|
||||||
border-top-left-radius: 0.5em;
|
|
||||||
border-bottom-left-radius: 0.5em;
|
|
||||||
margin:0em;
|
|
||||||
height: 1.5em;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
transition-property: max-width;
|
|
||||||
transition-duration: 2s;
|
|
||||||
transition-delay: 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
#rankings span.teamname:hover {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#rankings span.teampoints {
|
|
||||||
font-size:100%;
|
|
||||||
height:1.2em;
|
|
||||||
margin:0em;
|
|
||||||
padding:0em;
|
|
||||||
width:99%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#rankings div * {white-space: nowrap;}
|
/** Development mode information */
|
||||||
.cat0, .cat8, .cat16 {background-color: #a6cee3; color: black;}
|
.debug {
|
||||||
.cat1, .cat9, .cat17 {background-color: #1f78b4; color: white;}
|
overflow: auto;
|
||||||
.cat2, .cat10, .cat18 {background-color: #b2df8a; color: black;}
|
padding: 1em;
|
||||||
.cat3, .cat11, .cat19 {background-color: #33a02c; color: white;}
|
border-radius: 10px;
|
||||||
.cat4, .cat12, .cat20 {background-color: #fb9a99; color: black;}
|
margin: 2em auto;
|
||||||
.cat5, .cat13, .cat21 {background-color: #e31a1c; color: white;}
|
background: #cccc;
|
||||||
.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;}
|
|
||||||
.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;}
|
|
||||||
|
|
||||||
|
|
||||||
/* Responsive design */
|
|
||||||
/* Defaults */
|
|
||||||
#rankings span.teampoints {
|
|
||||||
max-width:89%;
|
|
||||||
}
|
|
||||||
#rankings span.teamname {
|
|
||||||
max-width:10%;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Monitors with large enough screens to do side by side */
|
|
||||||
@media only screen and (min-width: 170em) {
|
|
||||||
#chart, #rankings {
|
|
||||||
width: 49%;
|
|
||||||
display:inline-block;
|
|
||||||
vertical-align:middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Monitor
|
|
||||||
@media only screen and (max-width: 130em) {
|
|
||||||
#chart, #rankings {
|
|
||||||
width: 49%;
|
|
||||||
display:inline-block;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#rankings span.teampoints {
|
|
||||||
max-width:89%;
|
|
||||||
}
|
|
||||||
#rankings span.teamname {
|
|
||||||
max-width:10%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Laptop size screen */
|
|
||||||
@media only screen and (max-width: 100em) {
|
|
||||||
#rankings span.teampoints {
|
|
||||||
max-width:84%;
|
|
||||||
}
|
|
||||||
#rankings span.teamname {
|
|
||||||
max-width:15%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Roughly Tablet size */
|
|
||||||
@media only screen and (max-width: 70em) {
|
|
||||||
#rankings span.teampoints {
|
|
||||||
max-width:79%;
|
|
||||||
}
|
|
||||||
#rankings span.teamname {
|
|
||||||
max-width:20%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Small screens phone size */
|
|
||||||
@media only screen and (max-width: 40em) {
|
|
||||||
#rankings span.teampoints {
|
|
||||||
max-width:65%;
|
|
||||||
}
|
|
||||||
#rankings span.teamname {
|
|
||||||
max-width:34%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#devel {
|
|
||||||
background-color: #eee;
|
|
||||||
color: black;
|
color: black;
|
||||||
overflow: scroll;
|
|
||||||
}
|
}
|
||||||
#devel .string {
|
.debug dt {
|
||||||
color: #9c27b0;
|
font-weight: bold;
|
||||||
}
|
|
||||||
#devel .body {
|
|
||||||
background-color: #ffc107;
|
|
||||||
}
|
|
||||||
.kvpair {
|
|
||||||
border: solid black 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: inline-block;
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
display: block;
|
|
||||||
width: 46px;
|
|
||||||
height: 46px;
|
|
||||||
margin: 1px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 5px solid #fff;
|
|
||||||
border-color: #fff transparent #fff transparent;
|
|
||||||
animation: rotate 1.2s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes rotate {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Draggable items, from the draggable plugin */
|
||||||
li[draggable]::before {
|
li[draggable]::before {
|
||||||
content: "↕";
|
content: "↕";
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
|
@ -239,6 +145,48 @@ li[draggable] {
|
||||||
border: 1px white dashed;
|
border: 1px white dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
#cacheButton.disabled {
|
|
||||||
display: none;
|
|
||||||
|
|
||||||
|
|
||||||
|
/** Toasts are little pop-up informational messages. */
|
||||||
|
.toasts {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
text-align: center;
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.toast {
|
||||||
|
border-radius: 0.5em;
|
||||||
|
padding: 0.2em 2em;
|
||||||
|
animation: fadeIn ease 1s;
|
||||||
|
margin: 2px auto;
|
||||||
|
background: #333;
|
||||||
|
color: #eee;
|
||||||
|
box-shadow: 0px 0px 8px 0px #0b0;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
/* We uses the alpha channel to apply hue tinting to elements, to get a
|
||||||
|
* similar effect in light or dark mode. That means there aren't a whole lot of
|
||||||
|
* things to change between light and dark mode.
|
||||||
|
*/
|
||||||
|
body {
|
||||||
|
background-color: #b9cbd8;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
background-color: #fffd;
|
||||||
|
}
|
||||||
|
a:any-link {
|
||||||
|
color: #092b45;
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 180 KiB |
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* Common functionality
|
||||||
|
*/
|
||||||
|
const Millisecond = 1
|
||||||
|
const Second = Millisecond * 1000
|
||||||
|
const Minute = Second * 60
|
||||||
|
|
||||||
|
/** URL to the top of this MOTH server */
|
||||||
|
const BaseURL = new URL(".", location)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a transient message to the user.
|
||||||
|
*
|
||||||
|
* @param {String} message Message to display
|
||||||
|
* @param {Number} timeout How long before removing this message
|
||||||
|
*/
|
||||||
|
function Toast(message, timeout=5*Second) {
|
||||||
|
console.info(message)
|
||||||
|
for (let toasts of document.querySelectorAll(".toasts")) {
|
||||||
|
let p = toasts.appendChild(document.createElement("p"))
|
||||||
|
p.classList.add("toast")
|
||||||
|
p.textContent = message
|
||||||
|
setTimeout(() => p.remove(), timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a function when the DOM has been loaded.
|
||||||
|
*
|
||||||
|
* @param {function():void} cb Callback function
|
||||||
|
*/
|
||||||
|
function WhenDOMLoaded(cb) {
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", cb)
|
||||||
|
} else {
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interprets a String as a Boolean.
|
||||||
|
*
|
||||||
|
* Values like "no" or "disabled" to mean false here.
|
||||||
|
*
|
||||||
|
* @param {String} s
|
||||||
|
* @returns {Boolean}
|
||||||
|
*/
|
||||||
|
function Truthy(s) {
|
||||||
|
switch (s.toLowerCase()) {
|
||||||
|
case "disabled":
|
||||||
|
case "no":
|
||||||
|
case "off":
|
||||||
|
case "false":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the configuration object for this theme.
|
||||||
|
*
|
||||||
|
* @returns {Promise.<Object>}
|
||||||
|
*/
|
||||||
|
async function Config() {
|
||||||
|
let resp = await fetch(
|
||||||
|
new URL("config.json", BaseURL),
|
||||||
|
{
|
||||||
|
cache: "no-cache"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Millisecond,
|
||||||
|
Second,
|
||||||
|
Minute,
|
||||||
|
BaseURL,
|
||||||
|
Toast,
|
||||||
|
WhenDOMLoaded,
|
||||||
|
Truthy,
|
||||||
|
Config,
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"TrackSolved": true,
|
||||||
|
"URLInScoreboard": true,
|
||||||
|
"Messages": "<!-- Messages can go here (HTML) -->",
|
||||||
|
"__sentry__": "this is here so you don't have to remember to take the comma off the last item"
|
||||||
|
}
|
|
@ -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">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,174 @@
|
||||||
|
/**
|
||||||
|
* Functionality for index.html (Login / Puzzles list)
|
||||||
|
*/
|
||||||
|
import * as moth from "./moth.mjs"
|
||||||
|
import * as common from "./common.mjs"
|
||||||
|
|
||||||
|
class App {
|
||||||
|
constructor(basePath=".") {
|
||||||
|
this.config = {}
|
||||||
|
|
||||||
|
this.server = new moth.Server(basePath)
|
||||||
|
|
||||||
|
for (let form of document.querySelectorAll("form.login")) {
|
||||||
|
form.addEventListener("submit", event => this.handleLoginSubmit(event))
|
||||||
|
}
|
||||||
|
for (let e of document.querySelectorAll(".logout")) {
|
||||||
|
e.addEventListener("click", () => this.Logout())
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => this.UpdateState(), common.Minute/3)
|
||||||
|
setInterval(() => this.UpdateConfig(), common.Minute* 5)
|
||||||
|
|
||||||
|
this.UpdateConfig()
|
||||||
|
.finally(() => this.UpdateState())
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLoginSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
console.log(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to log in to the server.
|
||||||
|
*
|
||||||
|
* @param {string} teamID
|
||||||
|
* @param {string} teamName
|
||||||
|
*/
|
||||||
|
async Login(teamID, teamName) {
|
||||||
|
try {
|
||||||
|
await this.server.Login(teamID, teamName)
|
||||||
|
common.Toast(`Logged in (team id = ${teamID})`)
|
||||||
|
this.UpdateState()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
common.Toast(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log out of the server by clearing the saved Team ID.
|
||||||
|
*/
|
||||||
|
async Logout() {
|
||||||
|
try {
|
||||||
|
this.server.Reset()
|
||||||
|
common.Toast("Logged out")
|
||||||
|
this.UpdateState()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
common.Toast(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update app configuration.
|
||||||
|
*
|
||||||
|
* Configuration can be updated less frequently than state, to reduce server
|
||||||
|
* load, since configuration should (hopefully) change less frequently.
|
||||||
|
*/
|
||||||
|
async UpdateConfig() {
|
||||||
|
this.config = await common.Config()
|
||||||
|
|
||||||
|
for (let e of document.querySelectorAll(".messages")) {
|
||||||
|
e.innerHTML = this.config.Messages || ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the entire page.
|
||||||
|
*
|
||||||
|
* Fetch a new state, and rebuild all dynamic elements on this bage based on
|
||||||
|
* what's returned. If we're in development mode and not logged in, auto
|
||||||
|
* login too.
|
||||||
|
*/
|
||||||
|
async UpdateState() {
|
||||||
|
this.state = await this.server.GetState()
|
||||||
|
|
||||||
|
// Update elements with data-track-solved
|
||||||
|
for (let e of document.querySelectorAll("[data-track-solved]")) {
|
||||||
|
// Only display if data-track-solved is the same as config.trackSolved
|
||||||
|
e.classList.toggle("hidden", common.Truthy(e.dataset.trackSolved) != this.config.TrackSolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let e of document.querySelectorAll(".login")) {
|
||||||
|
this.renderLogin(e, !this.server.LoggedIn())
|
||||||
|
}
|
||||||
|
for (let e of document.querySelectorAll(".puzzles")) {
|
||||||
|
this.renderPuzzles(e, this.server.LoggedIn())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.DevelopmentMode() && !this.server.LoggedIn()) {
|
||||||
|
let teamID = Math.floor(Math.random() * 1000000).toString(16)
|
||||||
|
common.Toast("Automatically logging in to devel server")
|
||||||
|
console.info(`Logging in with generated Team ID: ${teamID}`)
|
||||||
|
return this.Login(teamID, `Team ${teamID}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a login box.
|
||||||
|
*
|
||||||
|
* Just toggles visibility, there's nothing dynamic in a login box.
|
||||||
|
*/
|
||||||
|
renderLogin(element, visible) {
|
||||||
|
element.classList.toggle("hidden", !visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a puzzles box.
|
||||||
|
*
|
||||||
|
* Displays the list of open puzzles, and adds mothball download links
|
||||||
|
* if the server is in development mode.
|
||||||
|
*/
|
||||||
|
renderPuzzles(element, visible) {
|
||||||
|
element.classList.toggle("hidden", !visible)
|
||||||
|
while (element.firstChild) element.firstChild.remove()
|
||||||
|
for (let cat of this.state.Categories()) {
|
||||||
|
let pdiv = element.appendChild(document.createElement("div"))
|
||||||
|
pdiv.classList.add("category")
|
||||||
|
|
||||||
|
let h = pdiv.appendChild(document.createElement("h2"))
|
||||||
|
h.textContent = cat
|
||||||
|
|
||||||
|
// Extras if we're running a devel server
|
||||||
|
if (this.state.DevelopmentMode()) {
|
||||||
|
let a = h.appendChild(document.createElement('a'))
|
||||||
|
a.classList.add("mothball")
|
||||||
|
a.textContent = "⬇️"
|
||||||
|
a.href = this.server.URL(`mothballer/${cat}.mb`)
|
||||||
|
a.title = "Download a compiled puzzle for this category"
|
||||||
|
}
|
||||||
|
|
||||||
|
// List out puzzles in this category
|
||||||
|
let l = pdiv.appendChild(document.createElement("ul"))
|
||||||
|
for (let puzzle of this.state.Puzzles(cat)) {
|
||||||
|
let i = l.appendChild(document.createElement("li"))
|
||||||
|
|
||||||
|
let url = new URL("puzzle.html", common.BaseURL)
|
||||||
|
url.hash = `${puzzle.Category}:${puzzle.Points}`
|
||||||
|
let a = i.appendChild(document.createElement("a"))
|
||||||
|
a.textContent = puzzle.Points
|
||||||
|
a.href = url
|
||||||
|
a.target = "_blank"
|
||||||
|
|
||||||
|
if (this.config.TrackSolved) {
|
||||||
|
a.classList.toggle("solved", this.state.IsSolved(puzzle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.state.ContainsUnsolved(cat)) {
|
||||||
|
l.appendChild(document.createElement("li")).textContent = "✿"
|
||||||
|
}
|
||||||
|
|
||||||
|
element.appendChild(pdiv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
window.app = {
|
||||||
|
server: new App()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
common.WhenDOMLoaded(init)
|
|
@ -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,681 @@
|
||||||
|
/**
|
||||||
|
* Hash/digest functions
|
||||||
|
*/
|
||||||
|
class Hash {
|
||||||
|
/**
|
||||||
|
* Dan Bernstein hash
|
||||||
|
*
|
||||||
|
* Used until MOTH v3.5
|
||||||
|
*
|
||||||
|
* @param {string} buf Input
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
static djb2(buf) {
|
||||||
|
let h = 5381
|
||||||
|
for (let c of (new TextEncoder()).encode(buf)) { // Encode as UTF-8 and read in each byte
|
||||||
|
// JavaScript converts everything to a signed 32-bit integer when you do bitwise operations.
|
||||||
|
// So we have to do "unsigned right shift" by zero to get it back to unsigned.
|
||||||
|
h = ((h * 33) + c) >>> 0
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dan Bernstein hash with xor
|
||||||
|
*
|
||||||
|
* @param {string} buf Input
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
static djb2xor(buf) {
|
||||||
|
let h = 5381
|
||||||
|
for (let c of (new TextEncoder()).encode(buf)) {
|
||||||
|
h = ((h * 33) ^ c) >>> 0
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHA 256
|
||||||
|
*
|
||||||
|
* Used until MOTH v4.5
|
||||||
|
*
|
||||||
|
* @param {string} buf Input
|
||||||
|
* @returns {Promise.<string>} hex-encoded digest
|
||||||
|
*/
|
||||||
|
static async sha256(buf) {
|
||||||
|
const msgUint8 = new TextEncoder().encode(buf)
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
return this.hexlify(hashArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHA 1, but only the first 4 hexits (2 octets).
|
||||||
|
*
|
||||||
|
* Git uses this technique with 7 hexits (default) as a "short identifier".
|
||||||
|
*
|
||||||
|
* @param {string} buf Input
|
||||||
|
*/
|
||||||
|
static async sha1_slice(buf, end=4) {
|
||||||
|
const msgUint8 = new TextEncoder().encode(buf)
|
||||||
|
const hashBuffer = await crypto.subtle.digest("SHA-1", msgUint8)
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
const hexits = this.hexlify(hashArray)
|
||||||
|
return hexits.slice(0, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hex-encode a byte array
|
||||||
|
*
|
||||||
|
* @param {number[]} buf Byte array
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
static hexlify(buf) {
|
||||||
|
return buf.map(b => b.toString(16).padStart(2, "0")).join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply every hash to the input buffer.
|
||||||
|
*
|
||||||
|
* @param {string} buf Input
|
||||||
|
* @returns {Promise.<string[]>}
|
||||||
|
*/
|
||||||
|
static async All(buf) {
|
||||||
|
return [
|
||||||
|
String(this.djb2(buf)),
|
||||||
|
await this.sha256(buf),
|
||||||
|
await this.sha1_slice(buf),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A point award.
|
||||||
|
*/
|
||||||
|
class Award {
|
||||||
|
constructor(when, teamid, category, points) {
|
||||||
|
/** Unix epoch timestamp for this award
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
this.When = when
|
||||||
|
/** Team ID this award belongs to
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
this.TeamID = teamid
|
||||||
|
/** Puzzle category for this award
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
this.Category = category
|
||||||
|
/** Points value of this award
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
this.Points = points
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A puzzle.
|
||||||
|
*
|
||||||
|
* A new Puzzle only knows its category and point value.
|
||||||
|
* If you want to populate it with meta-information, you must call Populate().
|
||||||
|
*
|
||||||
|
* Parameters created by Populate are described in the server source code:
|
||||||
|
* {@link https://pkg.go.dev/github.com/dirtbags/moth/v4/pkg/transpile#Puzzle}
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class Puzzle {
|
||||||
|
/**
|
||||||
|
* @param {Server} server
|
||||||
|
* @param {string} category
|
||||||
|
* @param {number} points
|
||||||
|
*/
|
||||||
|
constructor (server, category, points) {
|
||||||
|
if (points < 1) {
|
||||||
|
throw(`Invalid points value: ${points}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server where this puzzle lives
|
||||||
|
* @type {Server}
|
||||||
|
*/
|
||||||
|
this.server = server
|
||||||
|
|
||||||
|
/** Category this puzzle belongs to */
|
||||||
|
this.Category = String(category)
|
||||||
|
|
||||||
|
/** Point value of this puzzle */
|
||||||
|
this.Points = Number(points)
|
||||||
|
|
||||||
|
/** Error returned trying to retrieve this puzzle */
|
||||||
|
this.Error = {
|
||||||
|
/** Status code provided by server */
|
||||||
|
Status: 0,
|
||||||
|
/** Status text provided by server */
|
||||||
|
StatusText: "",
|
||||||
|
/** Full text of server error */
|
||||||
|
Body: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate this Puzzle object with meta-information from the server.
|
||||||
|
*/
|
||||||
|
async Populate() {
|
||||||
|
let resp = await this.Get("puzzle.json")
|
||||||
|
if (!resp.ok) {
|
||||||
|
let body = await resp.text()
|
||||||
|
this.Error = {
|
||||||
|
Status: resp.status,
|
||||||
|
StatusText: resp.statusText,
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
|
throw(this.Error)
|
||||||
|
}
|
||||||
|
let obj = await resp.json()
|
||||||
|
Object.assign(this, obj)
|
||||||
|
|
||||||
|
// Make sure lists are lists
|
||||||
|
this.AnswerHashes ||= []
|
||||||
|
this.Answers ||= []
|
||||||
|
this.Attachments ||= []
|
||||||
|
this.Authors ||= []
|
||||||
|
this.Debug.Errors ||= []
|
||||||
|
this.Debug.Hints ||= []
|
||||||
|
this.Debug.Log ||= []
|
||||||
|
this.KSAs ||= []
|
||||||
|
this.Scripts ||= []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a resource associated with this puzzle.
|
||||||
|
*
|
||||||
|
* @param {string} filename Attachment/Script to retrieve
|
||||||
|
* @returns {Promise.<Response>}
|
||||||
|
*/
|
||||||
|
Get(filename) {
|
||||||
|
return this.server.GetContent(this.Category, this.Points, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a string is possibly correct.
|
||||||
|
*
|
||||||
|
* The server sends a list of answer hashes with each puzzle: this method
|
||||||
|
* checks to see if any of those hashes match a hash of the string.
|
||||||
|
*
|
||||||
|
* The MOTH development team likes obscure hash functions with a lot of
|
||||||
|
* collisions, which means that a given input may match another possible
|
||||||
|
* string's hash. We do this so that if you run a brute force attack against
|
||||||
|
* the list of hashes, you have to write your own brute force program, and
|
||||||
|
* you still have to pick through a lot of potentially correct answers when
|
||||||
|
* it's done.
|
||||||
|
*
|
||||||
|
* @param {string} str User-submitted possible answer
|
||||||
|
* @returns {Promise.<boolean>}
|
||||||
|
*/
|
||||||
|
async IsPossiblyCorrect(str) {
|
||||||
|
let userAnswerHashes = await Hash.All(str)
|
||||||
|
|
||||||
|
for (let pah of this.AnswerHashes) {
|
||||||
|
for (let uah of userAnswerHashes) {
|
||||||
|
if (pah == uah) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a proposed answer for points.
|
||||||
|
*
|
||||||
|
* The returned promise will fail if anything goes wrong, including the
|
||||||
|
* proposed answer being rejected.
|
||||||
|
*
|
||||||
|
* @param {string} proposed Answer to submit
|
||||||
|
* @returns {Promise.<string>} Success message
|
||||||
|
*/
|
||||||
|
SubmitAnswer(proposed) {
|
||||||
|
return this.server.SubmitAnswer(this.Category, this.Points, proposed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A snapshot of scores.
|
||||||
|
*/
|
||||||
|
class Scores {
|
||||||
|
constructor() {
|
||||||
|
/**
|
||||||
|
* Timestamp of this score snapshot
|
||||||
|
* @type number
|
||||||
|
*/
|
||||||
|
this.Timestamp = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All categories present in this snapshot.
|
||||||
|
*
|
||||||
|
* ECMAScript sets preserve order, so iterating over this will yield
|
||||||
|
* categories as they were added to the points log.
|
||||||
|
*
|
||||||
|
* @type {Set.<string>}
|
||||||
|
*/
|
||||||
|
this.Categories = new Set()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All team IDs present in this snapshot
|
||||||
|
* @type {Set.<string>}
|
||||||
|
*/
|
||||||
|
this.TeamIDs = new Set()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highest score in each category
|
||||||
|
* @type {Object.<string,number>}
|
||||||
|
*/
|
||||||
|
this.MaxPoints = {}
|
||||||
|
|
||||||
|
this.categoryTeamPoints = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a sorted list of category names
|
||||||
|
*
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
SortedCategories() {
|
||||||
|
let categories = [...this.Categories]
|
||||||
|
categories.sort((a,b) => a.localeCompare(b, "en", {sensitivity: "base"}))
|
||||||
|
return categories
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an award to a team's score.
|
||||||
|
*
|
||||||
|
* Updates this.Timestamp to the award's timestamp.
|
||||||
|
*
|
||||||
|
* @param {Award} award
|
||||||
|
*/
|
||||||
|
Add(award) {
|
||||||
|
this.Timestamp = award.Timestamp
|
||||||
|
this.Categories.add(award.Category)
|
||||||
|
this.TeamIDs.add(award.TeamID)
|
||||||
|
|
||||||
|
let teamPoints = (this.categoryTeamPoints[award.Category] ??= {})
|
||||||
|
let points = (teamPoints[award.TeamID] || 0) + award.Points
|
||||||
|
teamPoints[award.TeamID] = points
|
||||||
|
|
||||||
|
let max = this.MaxPoints[award.Category] || 0
|
||||||
|
this.MaxPoints[award.Category] = Math.max(max, points)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a team's score within a category.
|
||||||
|
*
|
||||||
|
* @param {string} category
|
||||||
|
* @param {string} teamID
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
GetPoints(category, teamID) {
|
||||||
|
let teamPoints = this.categoryTeamPoints[category] || {}
|
||||||
|
return teamPoints[teamID] || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate a team's score in a category, using the Cyber Fire algorithm.
|
||||||
|
*
|
||||||
|
*@param {string} category
|
||||||
|
* @param {string} teamID
|
||||||
|
*/
|
||||||
|
CyFiCategoryScore(category, teamID) {
|
||||||
|
return this.GetPoints(category, teamID) / this.MaxPoints[category]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate a team's overall score, using the Cyber Fire algorithm.
|
||||||
|
*
|
||||||
|
*@param {string} category
|
||||||
|
* @param {string} teamID
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
CyFiScore(teamID) {
|
||||||
|
let score = 0
|
||||||
|
for (let category of this.Categories) {
|
||||||
|
score += this.CyFiCategoryScore(category, teamID)
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MOTH instance state.
|
||||||
|
*/
|
||||||
|
class State {
|
||||||
|
/**
|
||||||
|
* @param {Server} server Server where we got this
|
||||||
|
* @param {Object} obj Raw state data
|
||||||
|
*/
|
||||||
|
constructor(server, obj) {
|
||||||
|
for (let key of ["Config", "TeamNames", "PointsLog"]) {
|
||||||
|
if (!obj[key]) {
|
||||||
|
throw(`Missing state property: ${key}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.server = server
|
||||||
|
|
||||||
|
/** Configuration */
|
||||||
|
this.Config = {
|
||||||
|
/** Is the server in development mode?
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
Devel: obj.Config.Devel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Global messages, in HTML
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
this.Messages = obj.Messages
|
||||||
|
|
||||||
|
/** Map from Team ID to Team Name
|
||||||
|
* @type {Object.<string,string>}
|
||||||
|
*/
|
||||||
|
this.TeamNames = obj.TeamNames
|
||||||
|
|
||||||
|
/** Map from category name to puzzle point values
|
||||||
|
* @type {Object.<string,number>}
|
||||||
|
*/
|
||||||
|
this.PointsByCategory = obj.Puzzles
|
||||||
|
|
||||||
|
/** Log of points awarded
|
||||||
|
* @type {Award[]}
|
||||||
|
*/
|
||||||
|
this.PointsLog = obj.PointsLog.map(entry => new Award(entry[0], entry[1], entry[2], entry[3]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a sorted list of open category names
|
||||||
|
*
|
||||||
|
* @returns {string[]} List of categories
|
||||||
|
*/
|
||||||
|
Categories() {
|
||||||
|
let ret = []
|
||||||
|
for (let category in this.PointsByCategory) {
|
||||||
|
ret.push(category)
|
||||||
|
}
|
||||||
|
ret.sort()
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a category contains unsolved puzzles.
|
||||||
|
*
|
||||||
|
* The server adds a puzzle with 0 points in every "solved" category,
|
||||||
|
* so this just checks whether there is a 0-point puzzle in the category's point list.
|
||||||
|
*
|
||||||
|
* @param {string} category
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
ContainsUnsolved(category) {
|
||||||
|
return !this.PointsByCategory[category].includes(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the server in development mode?
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
DevelopmentMode() {
|
||||||
|
return this.Config && this.Config.Devel
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return all open puzzles.
|
||||||
|
*
|
||||||
|
* The returned list will be sorted by (category, points).
|
||||||
|
* If not categories are given, all puzzles will be returned.
|
||||||
|
*
|
||||||
|
* @param {string} categories Limit results to these categories
|
||||||
|
* @returns {Puzzle[]}
|
||||||
|
*/
|
||||||
|
Puzzles(...categories) {
|
||||||
|
if (categories.length == 0) {
|
||||||
|
categories = this.Categories()
|
||||||
|
}
|
||||||
|
let ret = []
|
||||||
|
for (let category of categories) {
|
||||||
|
for (let points of this.PointsByCategory[category]) {
|
||||||
|
if (0 == points) {
|
||||||
|
// This means all potential puzzles in the category are open
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let p = new Puzzle(this.server, category, points)
|
||||||
|
ret.push(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Has this puzzle been solved by this team?
|
||||||
|
*
|
||||||
|
* @param {Puzzle} puzzle
|
||||||
|
* @param {string} teamID Team to check, default the logged-in team
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
IsSolved(puzzle, teamID="self") {
|
||||||
|
for (let award of this.PointsLog) {
|
||||||
|
if (
|
||||||
|
(award.Category == puzzle.Category)
|
||||||
|
&& (award.Points == puzzle.Points)
|
||||||
|
&& (award.TeamID == teamID)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay scores.
|
||||||
|
*
|
||||||
|
* MOTH has no notion of who is "winning", we consider this a user interface
|
||||||
|
* decision. There are lots of interesting options: see
|
||||||
|
* [scoring]{@link ../docs/scoring.md} for more.
|
||||||
|
*
|
||||||
|
* @yields {Scores} Snapshot at a point in time
|
||||||
|
*/
|
||||||
|
* ScoresHistory() {
|
||||||
|
let scores = new Scores()
|
||||||
|
for (let award of this.PointsLog) {
|
||||||
|
scores.Add(award)
|
||||||
|
yield scores
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the current scores.
|
||||||
|
*
|
||||||
|
* @returns {Scores}
|
||||||
|
*/
|
||||||
|
CurrentScores() {
|
||||||
|
let scores
|
||||||
|
for (scores of this.ScoreHistory());
|
||||||
|
return scores
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A MOTH Server interface.
|
||||||
|
*
|
||||||
|
* This uses localStorage to remember Team ID,
|
||||||
|
* and will send a Team ID with every request, if it can find one.
|
||||||
|
*/
|
||||||
|
class Server {
|
||||||
|
/**
|
||||||
|
* @param {string | URL} baseUrl Base URL to server, for constructing API URLs
|
||||||
|
*/
|
||||||
|
constructor(baseUrl) {
|
||||||
|
if (!baseUrl) {
|
||||||
|
throw("Must provide baseURL")
|
||||||
|
}
|
||||||
|
this.baseUrl = new URL(baseUrl, location)
|
||||||
|
this.teamIDKey = this.baseUrl.toString() + " teamID"
|
||||||
|
this.TeamID = localStorage[this.teamIDKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a MOTH resource.
|
||||||
|
*
|
||||||
|
* If anything other than a 2xx code is returned,
|
||||||
|
* this function throws an error.
|
||||||
|
*
|
||||||
|
* This always sends teamID.
|
||||||
|
* If args is set, POST will be used instead of GET
|
||||||
|
*
|
||||||
|
* @param {string} path Path to API endpoint
|
||||||
|
* @param {Object.<string,string>} args Key/Values to send in POST data
|
||||||
|
* @returns {Promise.<Response>} Response
|
||||||
|
*/
|
||||||
|
fetch(path, args={}) {
|
||||||
|
let body = new URLSearchParams(args)
|
||||||
|
if (this.TeamID && !body.has("id")) {
|
||||||
|
body.set("id", this.TeamID)
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = new URL(path, this.baseUrl)
|
||||||
|
return fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
cache: "no-cache",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a request to a JSend API endpoint.
|
||||||
|
*
|
||||||
|
* @param {string} path Path to API endpoint
|
||||||
|
* @param {Object.<string,string>} args Key/Values to send in POST
|
||||||
|
* @returns {Promise.<Object>} JSend Data
|
||||||
|
*/
|
||||||
|
async call(path, args={}) {
|
||||||
|
let resp = await this.fetch(path, args)
|
||||||
|
let obj = await resp.json()
|
||||||
|
switch (obj.status) {
|
||||||
|
case "success":
|
||||||
|
return obj.data
|
||||||
|
case "fail":
|
||||||
|
throw new Error(obj.data.description || obj.data.short || obj.data)
|
||||||
|
case "error":
|
||||||
|
throw new Error(obj.message)
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown JSend status: ${obj.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a new URL for the given resource.
|
||||||
|
*
|
||||||
|
* The returned URL instance will be absolute, and immune to changes to the
|
||||||
|
* page that would affect relative URLs.
|
||||||
|
*
|
||||||
|
* @returns {URL}
|
||||||
|
*/
|
||||||
|
URL(url) {
|
||||||
|
return new URL(url, this.baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Are we logged in to the server?
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
LoggedIn() {
|
||||||
|
return this.TeamID ? true : false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget about any previous Team ID.
|
||||||
|
*
|
||||||
|
* This is equivalent to logging out.
|
||||||
|
*/
|
||||||
|
Reset() {
|
||||||
|
localStorage.removeItem(this.teamIDKey)
|
||||||
|
this.TeamID = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch current contest state.
|
||||||
|
*
|
||||||
|
* @returns {Promise.<State>}
|
||||||
|
*/
|
||||||
|
async GetState() {
|
||||||
|
let resp = await this.fetch("/state")
|
||||||
|
let obj = await resp.json()
|
||||||
|
return new State(this, obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log in to a team.
|
||||||
|
*
|
||||||
|
* This calls the server's registration endpoint; if the call succeds, or
|
||||||
|
* fails with "team already exists", the login is returned as successful.
|
||||||
|
*
|
||||||
|
* @param {string} teamID
|
||||||
|
* @param {string} teamName
|
||||||
|
* @returns {Promise.<string>} Success message from server
|
||||||
|
*/
|
||||||
|
async Login(teamID, teamName) {
|
||||||
|
let data = await this.call("/register", {id: teamID, name: teamName})
|
||||||
|
this.TeamID = teamID
|
||||||
|
this.TeamName = teamName
|
||||||
|
localStorage[this.teamIDKey] = teamID
|
||||||
|
return data.description || data.short
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a proposed answer for points.
|
||||||
|
*
|
||||||
|
* The returned promise will fail if anything goes wrong, including the
|
||||||
|
* proposed answer being rejected.
|
||||||
|
*
|
||||||
|
* @param {string} category Category of puzzle
|
||||||
|
* @param {number} points Point value of puzzle
|
||||||
|
* @param {string} proposed Answer to submit
|
||||||
|
* @returns {Promise.<string>} Success message
|
||||||
|
*/
|
||||||
|
async SubmitAnswer(category, points, proposed) {
|
||||||
|
let data = await this.call("/answer", {
|
||||||
|
cat: category,
|
||||||
|
points,
|
||||||
|
answer: proposed,
|
||||||
|
})
|
||||||
|
return data.description || data.short
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a file associated with a puzzle.
|
||||||
|
*
|
||||||
|
* @param {string} category Category of puzzle
|
||||||
|
* @param {number} points Point value of puzzle
|
||||||
|
* @param {string} filename
|
||||||
|
* @returns {Promise.<Response>}
|
||||||
|
*/
|
||||||
|
GetContent(category, points, filename) {
|
||||||
|
return this.fetch(`/content/${category}/${points}/${filename}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a Puzzle object.
|
||||||
|
*
|
||||||
|
* New Puzzle objects only know their category and point value.
|
||||||
|
* See docstrings on the Puzzle object for more information.
|
||||||
|
*
|
||||||
|
* @param {string} category
|
||||||
|
* @param {number} points
|
||||||
|
* @returns {Puzzle}
|
||||||
|
*/
|
||||||
|
GetPuzzle(category, points) {
|
||||||
|
return new Puzzle(this, category, points)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Hash,
|
||||||
|
Server,
|
||||||
|
}
|
|
@ -1,37 +1,34 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>Puzzle</title>
|
<title>Puzzle</title>
|
||||||
<link rel="stylesheet" href="basic.css">
|
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<script src="puzzle.js"></script>
|
<link rel="icon" href="luna-moth.svg">
|
||||||
<script>
|
<link rel="stylesheet" href="basic.css">
|
||||||
|
<script src="background.mjs" type="module" async></script>
|
||||||
</script>
|
<script src="puzzle.mjs" type="module" async></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Puzzle</h1>
|
<h1 id="title">[loading]</h1>
|
||||||
<section>
|
<main>
|
||||||
<div id="puzzle"><span class="spinner"></span></div>
|
<section id="puzzle">
|
||||||
<ul id="files"></ul>
|
<p class="notification">
|
||||||
<p>Puzzle by <span id="authors"></span></p>
|
Starting script...
|
||||||
</section>
|
</p>
|
||||||
<div id="messages"></div>
|
</section>
|
||||||
<form>
|
<section class="meta"></section>
|
||||||
<input type="hidden" name="cat">
|
<ul id="files"></ul>
|
||||||
<input type="hidden" name="points">
|
<p>Puzzle by <span id="authors">[loading]</span></p>
|
||||||
<input type="hidden" name="xAnswer">
|
</section>
|
||||||
Team ID: <input type="text" name="id"> <br>
|
<form class="answer">
|
||||||
Answer: <input type="text" name="answer" id="answer"> <span id="answer_ok"></span><br>
|
<label for="answer">Answer:</label>
|
||||||
<input type="submit" value="Submit">
|
<input type="text" name="answer" id="answer"> <span class="answer_ok"></span>
|
||||||
</form>
|
<br>
|
||||||
<div id="devel"></div>
|
<input type="submit" value="Submit">
|
||||||
<nav>
|
</form>
|
||||||
<ul>
|
</main>
|
||||||
<li><a href="index.html">Puzzles</a></li>
|
<div class="debug" class="notification"></div>
|
||||||
<li><a href="scoreboard.html">Scoreboard</a></li>
|
<div class="toasts"></div>
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
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,239 @@
|
||||||
|
/**
|
||||||
|
* Functionality for puzzle.html (Puzzle display / answer form)
|
||||||
|
*/
|
||||||
|
import * as moth from "./moth.mjs"
|
||||||
|
import * as common from "./common.mjs"
|
||||||
|
|
||||||
|
const server = new moth.Server(".")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a submit event on a form.
|
||||||
|
*
|
||||||
|
* Called when the user submits the form,
|
||||||
|
* either by clicking a "submit" button,
|
||||||
|
* or by some other means provided by the browser,
|
||||||
|
* like hitting the Enter key.
|
||||||
|
*
|
||||||
|
* @param {Event} event
|
||||||
|
*/
|
||||||
|
async function formSubmitHandler(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
let data = new FormData(event.target)
|
||||||
|
let proposed = data.get("answer")
|
||||||
|
let message
|
||||||
|
|
||||||
|
console.groupCollapsed("Submit answer")
|
||||||
|
console.info(`Proposed answer: ${proposed}`)
|
||||||
|
try {
|
||||||
|
message = await window.app.puzzle.SubmitAnswer(proposed)
|
||||||
|
common.Toast(message)
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
common.Toast(err)
|
||||||
|
}
|
||||||
|
console.groupEnd("Submit answer")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an input event on the answer field.
|
||||||
|
*
|
||||||
|
* @param {Event} event
|
||||||
|
*/
|
||||||
|
async function answerInputHandler(event) {
|
||||||
|
let answer = event.target.value
|
||||||
|
let correct = await window.app.puzzle.IsPossiblyCorrect(answer)
|
||||||
|
for (let ok of event.target.parentElement.querySelectorAll(".answer_ok")) {
|
||||||
|
if (correct) {
|
||||||
|
ok.textContent = "⭕"
|
||||||
|
ok.title = "Possibly correct"
|
||||||
|
} else {
|
||||||
|
ok.textContent = "❌"
|
||||||
|
ok.title = "Definitely not correct"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the puzzle content element, possibly with everything cleared out of it.
|
||||||
|
*
|
||||||
|
* @param {boolean} clear Should the element be cleared of children? Default true.
|
||||||
|
* @returns {Element}
|
||||||
|
*/
|
||||||
|
function puzzleElement(clear=true) {
|
||||||
|
let e = document.querySelector("#puzzle")
|
||||||
|
if (clear) {
|
||||||
|
while (e.firstChild) e.firstChild.remove()
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display an error in the puzzle area, and also send it to the console.
|
||||||
|
*
|
||||||
|
* Errors are rendered in the puzzle area, so the user can see a bit more about
|
||||||
|
* what the problem is.
|
||||||
|
*
|
||||||
|
* @param {string} error
|
||||||
|
*/
|
||||||
|
function error(error) {
|
||||||
|
console.error(error)
|
||||||
|
let e = puzzleElement().appendChild(document.createElement("pre"))
|
||||||
|
e.classList.add("error")
|
||||||
|
e.textContent = error.Body || error
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the answer and invoke input handlers.
|
||||||
|
*
|
||||||
|
* Makes sure the Circle Of Success gets updated.
|
||||||
|
*
|
||||||
|
* @param {string} s
|
||||||
|
*/
|
||||||
|
function SetAnswer(s) {
|
||||||
|
let e = document.querySelector("#answer")
|
||||||
|
e.value = s
|
||||||
|
e.dispatchEvent(new Event("input"))
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeObject(e, obj) {
|
||||||
|
let keys = Object.keys(obj)
|
||||||
|
keys.sort()
|
||||||
|
for (let key of keys) {
|
||||||
|
let val = obj[key]
|
||||||
|
if ((key === "Body") || (!val) || (val.length === 0)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let d = e.appendChild(document.createElement("dt"))
|
||||||
|
d.textContent = key
|
||||||
|
|
||||||
|
let t = e.appendChild(document.createElement("dd"))
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
let vi = t.appendChild(document.createElement("ul"))
|
||||||
|
vi.multiple = true
|
||||||
|
for (let a of val) {
|
||||||
|
let opt = vi.appendChild(document.createElement("li"))
|
||||||
|
opt.textContent = a
|
||||||
|
}
|
||||||
|
} else if (typeof(val) === "object") {
|
||||||
|
writeObject(t, val)
|
||||||
|
} else {
|
||||||
|
t.textContent = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the given puzzle.
|
||||||
|
*
|
||||||
|
* @param {string} category
|
||||||
|
* @param {number} points
|
||||||
|
*/
|
||||||
|
async function loadPuzzle(category, points) {
|
||||||
|
console.groupCollapsed("Loading puzzle:", category, points)
|
||||||
|
let contentBase = new URL(`content/${category}/${points}/`, common.BaseURL)
|
||||||
|
|
||||||
|
// Tell user we're loading
|
||||||
|
puzzleElement().appendChild(document.createElement("progress"))
|
||||||
|
for (let qs of ["#authors", "#title", "title"]) {
|
||||||
|
for (let e of document.querySelectorAll(qs)) {
|
||||||
|
e.textContent = "[loading]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let puzzle = server.GetPuzzle(category, points)
|
||||||
|
|
||||||
|
console.time("Populate")
|
||||||
|
try {
|
||||||
|
await puzzle.Populate()
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
let error = puzzleElement().appendChild(document.createElement("pre"))
|
||||||
|
error.classList.add("notification", "error")
|
||||||
|
error.textContent = puzzle.Error.Body
|
||||||
|
return
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
console.timeEnd("Populate")
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`Setting base tag to ${contentBase}`)
|
||||||
|
let baseElement = document.head.appendChild(document.createElement("base"))
|
||||||
|
baseElement.href = contentBase
|
||||||
|
|
||||||
|
console.info("Tweaking HTML...")
|
||||||
|
let title = `${category} ${points}`
|
||||||
|
document.querySelector("title").textContent = title
|
||||||
|
document.querySelector("#title").textContent = title
|
||||||
|
document.querySelector("#authors").textContent = puzzle.Authors.join(", ")
|
||||||
|
if (puzzle.AnswerPattern) {
|
||||||
|
document.querySelector("#answer").pattern = puzzle.AnswerPattern
|
||||||
|
}
|
||||||
|
puzzleElement().innerHTML = puzzle.Body
|
||||||
|
|
||||||
|
console.info("Adding attached scripts...")
|
||||||
|
for (let script of (puzzle.Scripts || [])) {
|
||||||
|
let st = document.createElement("script")
|
||||||
|
document.head.appendChild(st)
|
||||||
|
st.src = new URL(script, contentBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("Listing attached files...")
|
||||||
|
for (let fn of (puzzle.Attachments || [])) {
|
||||||
|
let li = document.createElement("li")
|
||||||
|
let a = document.createElement("a")
|
||||||
|
a.href = new URL(fn, contentBase)
|
||||||
|
a.innerText = fn
|
||||||
|
li.appendChild(a)
|
||||||
|
document.getElementById("files").appendChild(li)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
console.info("Filling debug information...")
|
||||||
|
for (let e of document.querySelectorAll(".debug")) {
|
||||||
|
if (puzzle.Answers.length > 0) {
|
||||||
|
writeObject(e, puzzle)
|
||||||
|
} else {
|
||||||
|
e.classList.add("hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.app.puzzle = puzzle
|
||||||
|
console.info("window.app.puzzle =", window.app.puzzle)
|
||||||
|
|
||||||
|
console.groupEnd()
|
||||||
|
|
||||||
|
return puzzle
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
window.app = {}
|
||||||
|
window.setanswer = (str => SetAnswer(str))
|
||||||
|
|
||||||
|
for (let form of document.querySelectorAll("form.answer")) {
|
||||||
|
form.addEventListener("submit", formSubmitHandler)
|
||||||
|
for (let e of form.querySelectorAll("[name=answer]")) {
|
||||||
|
e.addEventListener("input", answerInputHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// There isn't a more graceful way to "unload" scripts attached to the current puzzle
|
||||||
|
window.addEventListener("hashchange", () => location.reload())
|
||||||
|
|
||||||
|
// Make all links absolute, because we're going to be changing the base URL
|
||||||
|
for (let e of document.querySelectorAll("[href]")) {
|
||||||
|
e.href = new URL(e.href, common.BaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
let hashpart = location.hash.split("#")[1] || ""
|
||||||
|
let catpoints = hashpart.split(":")
|
||||||
|
let category = catpoints[0]
|
||||||
|
let points = Number(catpoints[1])
|
||||||
|
if (!category && !points) {
|
||||||
|
error(`Doesn't look like a puzzle reference: ${hashpart}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.app.puzzle = await loadPuzzle(category, points)
|
||||||
|
}
|
||||||
|
|
||||||
|
common.WhenDOMLoaded(init)
|
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,114 @@
|
||||||
|
/* GHC displays: 1024x1820 */
|
||||||
|
@media screen and (max-aspect-ratio: 4/5) and (min-height: 1600px) {
|
||||||
|
html {
|
||||||
|
font-size: 20pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#chart {
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
text-align: center;
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
font-family: Montserrat, sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
.cyber {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.fire {
|
||||||
|
color: #d94a1f;
|
||||||
|
}
|
||||||
|
.announcement.floating {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100hw;
|
||||||
|
max-width: inherit;
|
||||||
|
}
|
||||||
|
.announcement {
|
||||||
|
background-color: rgba(255,255,255,0.5);
|
||||||
|
color: black;
|
||||||
|
padding: 0.25em;
|
||||||
|
border-radius: 5px;
|
||||||
|
max-width: 20em;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-around;
|
||||||
|
font-size: 1.3em;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.announcement div {
|
||||||
|
margin: 1em;
|
||||||
|
max-width: 45vw;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.qrcode {
|
||||||
|
width: 30vw;
|
||||||
|
}
|
||||||
|
.examples {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.examples > div {
|
||||||
|
margin: 0.5em;
|
||||||
|
max-width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scoreboard */
|
||||||
|
#rankings {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
background-color: #000c;
|
||||||
|
}
|
||||||
|
#rankings div {
|
||||||
|
height: 1.4rem;
|
||||||
|
}
|
||||||
|
#rankings div:nth-child(6n){
|
||||||
|
background-color: #ccc1;
|
||||||
|
}
|
||||||
|
#rankings div:nth-child(6n+3) {
|
||||||
|
background-color: #0f01;
|
||||||
|
}
|
||||||
|
|
||||||
|
#rankings span {
|
||||||
|
font-size: 75%;
|
||||||
|
display: inline-block;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 1.4em;
|
||||||
|
}
|
||||||
|
#rankings span.teamname {
|
||||||
|
height: auto;
|
||||||
|
font-size: inherit;
|
||||||
|
color: white;
|
||||||
|
background-color: #000e;
|
||||||
|
border-radius: 3px;
|
||||||
|
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;}
|
|
@ -3,22 +3,15 @@
|
||||||
<head>
|
<head>
|
||||||
<title>Scoreboard</title>
|
<title>Scoreboard</title>
|
||||||
<link rel="stylesheet" href="basic.css">
|
<link rel="stylesheet" href="basic.css">
|
||||||
|
<link rel="stylesheet" href="scoreboard.css">
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<script src="moment.min.js" async></script>
|
<script src="https://cdn.jsdelivr.net/npm/luxon@1.26.0"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" async></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.0.2"></script>
|
||||||
<script src="scoreboard.js" async></script>
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@0.2.1"></script>
|
||||||
|
<script type="module" src="scoreboard.mjs"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="wide">
|
<body>
|
||||||
<h4 id="location"></h4>
|
<div id="rankings"></div>
|
||||||
<section class="rotate">
|
<div class="location"></div>
|
||||||
<div id="chart"></div>
|
|
||||||
<div id="rankings"></div>
|
|
||||||
</section>
|
|
||||||
<nav>
|
|
||||||
<ul>
|
|
||||||
<li><a href="index.html">Puzzles</a></li>
|
|
||||||
<li><a href="scoreboard.html">Scoreboard</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -1,257 +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
|
|
||||||
|
|
||||||
let teamPoints=document.createElement("span")
|
|
||||||
teamPoints.classList.add("teampoints")
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
teamPoints.appendChild(bar)
|
|
||||||
ncat += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
row.appendChild(teamPoints)
|
|
||||||
|
|
||||||
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,95 @@
|
||||||
|
import * as moth from "./moth.mjs"
|
||||||
|
import * as common from "./common.mjs"
|
||||||
|
|
||||||
|
const server = new moth.Server(".")
|
||||||
|
const ReplayDuration = 0.3 * common.Second
|
||||||
|
const MaxFrameRate = 60
|
||||||
|
/** Don't let any team's score exceed this percentage width */
|
||||||
|
const MaxScoreWidth = 95
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a promise that resolves after timeout.
|
||||||
|
*
|
||||||
|
* @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 = await common.Config()
|
||||||
|
for (let e of document.querySelectorAll(".location")) {
|
||||||
|
e.textContent = common.BaseURL
|
||||||
|
e.classList.toggle("hidden", !config.URLInScoreboard)
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = await server.GetState()
|
||||||
|
let rankingsElement = document.querySelector("#rankings")
|
||||||
|
let logSize = state.PointsLog.length
|
||||||
|
|
||||||
|
// Figure out the timing so that we can replay the scoreboard in about
|
||||||
|
// ReplayDuration, but no more than 24 frames per second.
|
||||||
|
let frameModulo = 1
|
||||||
|
let delay = 0
|
||||||
|
while (delay < (common.Second / MaxFrameRate)) {
|
||||||
|
frameModulo += 1
|
||||||
|
delay = ReplayDuration / (logSize / frameModulo)
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame = 0
|
||||||
|
for (let scores of state.ScoresHistory()) {
|
||||||
|
frame += 1
|
||||||
|
if ((frame < state.PointsLog.length) && (frame % frameModulo)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
while (rankingsElement.firstChild) rankingsElement.firstChild.remove()
|
||||||
|
|
||||||
|
let sortedTeamIDs = [...scores.TeamIDs]
|
||||||
|
sortedTeamIDs.sort((a, b) => scores.CyFiScore(a) - scores.CyFiScore(b))
|
||||||
|
sortedTeamIDs.reverse()
|
||||||
|
|
||||||
|
let topScore = scores.CyFiScore(sortedTeamIDs[0])
|
||||||
|
for (let teamID of sortedTeamIDs) {
|
||||||
|
let teamName = state.TeamNames[teamID]
|
||||||
|
|
||||||
|
let row = rankingsElement.appendChild(document.createElement("div"))
|
||||||
|
|
||||||
|
let heading = row.appendChild(document.createElement("span"))
|
||||||
|
heading.textContent = teamName
|
||||||
|
heading.classList.add("teamname")
|
||||||
|
|
||||||
|
let categoryNumber = 0
|
||||||
|
for (let category of scores.Categories) {
|
||||||
|
let score = scores.CyFiCategoryScore(category, teamID)
|
||||||
|
if (!score) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let block = row.appendChild(document.createElement("span"))
|
||||||
|
let points = scores.GetPoints(category, teamID)
|
||||||
|
let width = MaxScoreWidth * score / topScore
|
||||||
|
|
||||||
|
block.textContent = category
|
||||||
|
block.title = `${points} points`
|
||||||
|
block.style.width = `${width}%`
|
||||||
|
block.classList.add(`cat${categoryNumber}`)
|
||||||
|
categoryNumber += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await sleep(delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
setInterval(update, common.Minute)
|
||||||
|
update()
|
||||||
|
}
|
||||||
|
|
||||||
|
common.WhenDOMLoaded(init)
|
|
@ -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)
|
Loading…
Reference in New Issue