mirror of https://github.com/dirtbags/moth.git
Compare commits
No commits in common. "90716313530cb1636a91b928f9594b6ce5cff156" and "f7945fcf3b6cbaf8c3a733d0445c41b4be2b8af9" have entirely different histories.
9071631353
...
f7945fcf3b
|
@ -0,0 +1,70 @@
|
|||
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 }}
|
|
@ -1,49 +0,0 @@
|
|||
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,60 +4,7 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [v4.6.0] - unreleased
|
||||
### Changed
|
||||
- Answer hashes are now the first 4 characters of the hex-encoded SHA1 digest
|
||||
- Reworked the built-in theme
|
||||
- [moth.mjs](theme/moth.mjs) is now the standard MOTH library for ECMAScript
|
||||
- Devel mode no longer accepts an empty team ID
|
||||
|
||||
## [v4.4.9] - 2022-05-12
|
||||
### Changed
|
||||
- Added a performance optimization for events with a large number of teams
|
||||
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
|
||||
## [v4.2.1] - unreleased
|
||||
### Fixed
|
||||
- Transpiled KSAs no longer dropped
|
||||
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
Dirtbags Monarch Of The Hill Server
|
||||
=====================
|
||||
|
||||
[![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth)](https://goreportcard.com/report/github.com/dirtbags/moth)
|
||||
![Build badge](https://github.com/dirtbags/moth/workflows/Build/Test/Push/badge.svg)
|
||||
![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth)
|
||||
|
||||
Monarch Of The Hill (MOTH) is a puzzle server.
|
||||
We (the authors) have used it for instructional and contest events called
|
||||
|
@ -32,7 +33,7 @@ You can read more about why we made these decisions in [philosophy](docs/philoso
|
|||
Run in demonstration mode
|
||||
===========
|
||||
|
||||
docker run --rm -it -p 8080:8080 ghcr.io/dirtbags/moth-devel
|
||||
docker run --rm -it -p 8080:8080 dirtbags/moth-devel
|
||||
|
||||
Then open http://localhost:8080/ and check out the example puzzles.
|
||||
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
#! /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/
|
||||
RUN mkdir -p /target/state
|
||||
WORKDIR /src/
|
||||
RUN CGO_ENABLED=0 GOOS=linux go install -a -ldflags '-extldflags "-static"' ./...
|
||||
RUN CGO_ENABLED=0 GOOS=linux go install -i -a -ldflags '-extldflags "-static"' ./...
|
||||
# I can't use /target/bin: doing so would cause the devel server to overwrite Ubuntu's /bin
|
||||
|
||||
##########
|
||||
|
|
|
@ -8,7 +8,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dirtbags/moth/v4/pkg/jsend"
|
||||
"github.com/dirtbags/moth/pkg/jsend"
|
||||
)
|
||||
|
||||
// HTTPServer is a MOTH HTTP server
|
||||
|
@ -44,8 +44,9 @@ func (h *HTTPServer) HandleMothFunc(
|
|||
mothHandler func(MothRequestHandler, http.ResponseWriter, *http.Request),
|
||||
) {
|
||||
handler := func(w http.ResponseWriter, req *http.Request) {
|
||||
participantID := req.FormValue("pid")
|
||||
teamID := req.FormValue("id")
|
||||
mh := h.server.NewHandler(teamID)
|
||||
mh := h.server.NewHandler(participantID, teamID)
|
||||
mothHandler(mh, w, req)
|
||||
}
|
||||
h.HandleFunc(h.base+pattern, handler)
|
||||
|
@ -116,11 +117,11 @@ func (h *HTTPServer) RegisterHandler(mh MothRequestHandler, w http.ResponseWrite
|
|||
}
|
||||
|
||||
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 {
|
||||
jsend.Sendf(w, jsend.Fail, "not registered", err.Error())
|
||||
} else {
|
||||
jsend.Sendf(w, jsend.Success, "registered", "team ID registered")
|
||||
jsend.Sendf(w, jsend.Success, "registered", "Team ID registered")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,15 +7,21 @@ import (
|
|||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const TestParticipantID = "shipox"
|
||||
|
||||
func (hs *HTTPServer) TestRequest(path string, args map[string]string) *httptest.ResponseRecorder {
|
||||
vals := url.Values{}
|
||||
vals.Set("pid", TestParticipantID)
|
||||
vals.Set("id", TestTeamID)
|
||||
for k, v := range args {
|
||||
vals.Set(k, v)
|
||||
if args != nil {
|
||||
for k, v := range args {
|
||||
vals.Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
@ -29,8 +35,7 @@ func (hs *HTTPServer) TestRequest(path string, args map[string]string) *httptest
|
|||
}
|
||||
|
||||
func TestHttpd(t *testing.T) {
|
||||
server := NewTestServer()
|
||||
hs := NewHTTPServer("/", server.MothServer)
|
||||
hs := NewHTTPServer("/", NewTestServer())
|
||||
|
||||
if r := hs.TestRequest("/", nil); r.Result().StatusCode != 200 {
|
||||
t.Error(r.Result())
|
||||
|
@ -51,24 +56,22 @@ func TestHttpd(t *testing.T) {
|
|||
|
||||
if r := hs.TestRequest("/register", map[string]string{"id": "bad team id", "name": "GoTeam"}); r.Result().StatusCode != 200 {
|
||||
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")
|
||||
}
|
||||
|
||||
if r := hs.TestRequest("/register", map[string]string{"name": "GoTeam"}); r.Result().StatusCode != 200 {
|
||||
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")
|
||||
}
|
||||
|
||||
if r := hs.TestRequest("/register", map[string]string{"name": "GoTeam"}); r.Result().StatusCode != 200 {
|
||||
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())
|
||||
}
|
||||
|
||||
server.refresh()
|
||||
|
||||
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
|
||||
t.Error(r.Result())
|
||||
} else if r.Body.String() != `{"Config":{"Devel":false},"Messages":"messages.html","TeamNames":{"self":"GoTeam"},"PointsLog":[],"Puzzles":{"pategory":[1]}}` {
|
||||
|
@ -99,7 +102,7 @@ func TestHttpd(t *testing.T) {
|
|||
|
||||
if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "moo"}); r.Result().StatusCode != 200 {
|
||||
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())
|
||||
}
|
||||
|
||||
|
@ -109,7 +112,7 @@ func TestHttpd(t *testing.T) {
|
|||
t.Error("Unexpected body", r.Body.String())
|
||||
}
|
||||
|
||||
server.refresh()
|
||||
time.Sleep(TestMaintenanceInterval)
|
||||
|
||||
if r := hs.TestRequest("/content/pategory/2/puzzle.json", nil); r.Result().StatusCode != 200 {
|
||||
t.Error(r.Result())
|
||||
|
@ -121,14 +124,14 @@ func TestHttpd(t *testing.T) {
|
|||
} else if err := json.Unmarshal(r.Body.Bytes(), &state); err != nil {
|
||||
t.Error(err)
|
||||
} else if len(state.PointsLog) != 1 {
|
||||
t.Errorf("Points log wrong length. Wanted 1, got %v (length %d)", state.PointsLog, len(state.PointsLog))
|
||||
t.Error("Points log wrong length")
|
||||
} else if len(state.Puzzles["pategory"]) != 2 {
|
||||
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 {
|
||||
t.Error(r.Result())
|
||||
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"points already awarded to this team in this category"}}` {
|
||||
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"Error awarding points: Points already awarded to this team in this category"}}` {
|
||||
t.Error("Unexpected body", r.Body.String())
|
||||
}
|
||||
}
|
||||
|
@ -137,7 +140,7 @@ func TestDevelMemHttpd(t *testing.T) {
|
|||
srv := NewTestServer()
|
||||
|
||||
{
|
||||
hs := NewHTTPServer("/", srv.MothServer)
|
||||
hs := NewHTTPServer("/", srv)
|
||||
|
||||
if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 404 {
|
||||
t.Error("Should have gotten a 404 for mothballer in prod mode")
|
||||
|
@ -146,7 +149,7 @@ func TestDevelMemHttpd(t *testing.T) {
|
|||
|
||||
{
|
||||
srv.Config.Devel = true
|
||||
hs := NewHTTPServer("/", srv.MothServer)
|
||||
hs := NewHTTPServer("/", srv)
|
||||
|
||||
if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 500 {
|
||||
t.Log(r.Body.String())
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
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,7 +19,6 @@ import (
|
|||
type zipCategory struct {
|
||||
afero.Fs
|
||||
io.Closer
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
// Mothballs provides a collection of active mothball files (puzzle categories)
|
||||
|
@ -49,7 +48,7 @@ func (m *Mothballs) getCat(cat string) (zipCategory, bool) {
|
|||
func (m *Mothballs) Open(cat string, points int, filename string) (ReadSeekCloser, time.Time, error) {
|
||||
zc, ok := m.getCat(cat)
|
||||
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))
|
||||
|
@ -92,12 +91,12 @@ func (m *Mothballs) Inventory() []Category {
|
|||
func (m *Mothballs) CheckAnswer(cat string, points int, answer string) (bool, error) {
|
||||
zfs, ok := m.getCat(cat)
|
||||
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")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("no answers.txt file")
|
||||
return false, fmt.Errorf("No answers.txt file")
|
||||
}
|
||||
defer af.Close()
|
||||
|
||||
|
@ -133,18 +132,7 @@ func (m *Mothballs) refresh() {
|
|||
categoryName := strings.TrimSuffix(filename, ".mb")
|
||||
found[categoryName] = true
|
||||
|
||||
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 {
|
||||
if _, ok := m.categories[categoryName]; !ok {
|
||||
f, err := m.Fs.Open(filename)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
|
@ -168,7 +156,6 @@ func (m *Mothballs) refresh() {
|
|||
m.categories[categoryName] = zipCategory{
|
||||
Fs: zipfs.New(zrc),
|
||||
Closer: f,
|
||||
mtime: fi.ModTime(),
|
||||
}
|
||||
|
||||
log.Println("Adding category:", categoryName)
|
||||
|
@ -187,7 +174,7 @@ func (m *Mothballs) refresh() {
|
|||
|
||||
// Mothball just returns an 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.
|
||||
|
|
|
@ -3,27 +3,25 @@ package main
|
|||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
type testFileContents struct {
|
||||
var testFiles = []struct {
|
||||
Name, Body string
|
||||
}
|
||||
|
||||
var testFiles = []testFileContents{
|
||||
}{
|
||||
{"puzzles.txt", "1\n3\n2\n"},
|
||||
{"answers.txt", "1 answer123\n1 answer456\n2 wat\n"},
|
||||
{"1/puzzle.json", `{"name": "moo"}`},
|
||||
{"1/moo.txt", `moo`},
|
||||
{"2/puzzle.json", `{}`},
|
||||
{"2/moo.txt", `moo`},
|
||||
{"3/puzzle.json", `{}`},
|
||||
{"3/moo.txt", `moo`},
|
||||
}
|
||||
|
||||
func (m *Mothballs) createMothballWithFiles(cat string, contents []testFileContents) {
|
||||
func (m *Mothballs) createMothball(cat string) {
|
||||
f, _ := m.Create(fmt.Sprintf("%s.mb", cat))
|
||||
defer f.Close()
|
||||
|
||||
|
@ -34,19 +32,6 @@ func (m *Mothballs) createMothballWithFiles(cat string, contents []testFileConte
|
|||
of, _ := w.Create(file.Name)
|
||||
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 {
|
||||
|
@ -107,27 +92,10 @@ func TestMothballs(t *testing.T) {
|
|||
}
|
||||
if ok, err := m.CheckAnswer("nealegory", 1, "moo"); ok {
|
||||
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")
|
||||
}
|
||||
|
||||
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.Fs.Remove("pategory.mb")
|
||||
m.refresh()
|
||||
|
|
|
@ -15,7 +15,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dirtbags/moth/v4/pkg/transpile"
|
||||
"github.com/dirtbags/moth/pkg/transpile"
|
||||
)
|
||||
|
||||
// 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
|
||||
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
|
||||
|
|
|
@ -6,7 +6,7 @@ import (
|
|||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/dirtbags/moth/v4/pkg/award"
|
||||
"github.com/dirtbags/moth/pkg/award"
|
||||
)
|
||||
|
||||
// Category represents a puzzle category.
|
||||
|
@ -58,7 +58,7 @@ type StateProvider interface {
|
|||
TeamName(teamID string) (string, error)
|
||||
SetTeamName(teamID, teamName string) error
|
||||
AwardPoints(teamID string, cat string, points int) error
|
||||
LogEvent(event, teamID, cat string, points int, extra ...string)
|
||||
LogEvent(event, participantID, teamID, cat string, points int, extra ...string)
|
||||
Maintainer
|
||||
}
|
||||
|
||||
|
@ -68,9 +68,6 @@ type Maintainer interface {
|
|||
// It will only be called once, when execution begins.
|
||||
// It's okay to just exit if there's no maintenance to be done.
|
||||
Maintain(updateInterval time.Duration)
|
||||
|
||||
// refresh is a shortcut used internally for testing
|
||||
refresh()
|
||||
}
|
||||
|
||||
// MothServer gathers together the providers that make up a MOTH server.
|
||||
|
@ -92,17 +89,19 @@ func NewMothServer(config Configuration, theme ThemeProvider, state StateProvide
|
|||
}
|
||||
|
||||
// NewHandler returns a new http.RequestHandler for the provided teamID.
|
||||
func (s *MothServer) NewHandler(teamID string) MothRequestHandler {
|
||||
func (s *MothServer) NewHandler(participantID, teamID string) MothRequestHandler {
|
||||
return MothRequestHandler{
|
||||
MothServer: s,
|
||||
teamID: teamID,
|
||||
MothServer: s,
|
||||
participantID: participantID,
|
||||
teamID: teamID,
|
||||
}
|
||||
}
|
||||
|
||||
// MothRequestHandler provides http.RequestHandler for a MothServer.
|
||||
type MothRequestHandler struct {
|
||||
*MothServer
|
||||
teamID string
|
||||
participantID string
|
||||
teamID string
|
||||
}
|
||||
|
||||
// PuzzlesOpen opens a file associated with a puzzle.
|
||||
|
@ -116,7 +115,7 @@ func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (
|
|||
}
|
||||
}
|
||||
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
|
||||
|
@ -129,7 +128,7 @@ func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (
|
|||
|
||||
// Log puzzle.json loads
|
||||
if path == "puzzle.json" {
|
||||
mh.State.LogEvent("load", mh.teamID, cat, points)
|
||||
mh.State.LogEvent("load", mh.participantID, mh.teamID, cat, points)
|
||||
}
|
||||
|
||||
return
|
||||
|
@ -146,17 +145,17 @@ func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string)
|
|||
}
|
||||
}
|
||||
if !correct {
|
||||
mh.State.LogEvent("wrong", mh.teamID, cat, points)
|
||||
return fmt.Errorf("incorrect answer")
|
||||
mh.State.LogEvent("wrong", mh.participantID, mh.teamID, cat, points)
|
||||
return fmt.Errorf("Incorrect answer")
|
||||
}
|
||||
|
||||
mh.State.LogEvent("correct", mh.teamID, cat, points)
|
||||
mh.State.LogEvent("correct", mh.participantID, mh.teamID, cat, points)
|
||||
|
||||
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 {
|
||||
return err
|
||||
return fmt.Errorf("Error awarding points: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -169,10 +168,11 @@ func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time,
|
|||
|
||||
// Register associates a team name with a team ID.
|
||||
func (mh *MothRequestHandler) Register(teamName string) error {
|
||||
// BUG(neale): Register returns an error if a team is already registered; it may make more sense to return success
|
||||
if teamName == "" {
|
||||
return fmt.Errorf("empty team name")
|
||||
return fmt.Errorf("Empty team name")
|
||||
}
|
||||
mh.State.LogEvent("register", mh.teamID, "", 0)
|
||||
mh.State.LogEvent("register", mh.participantID, mh.teamID, "", 0)
|
||||
return mh.State.SetTeamName(mh.teamID, teamName)
|
||||
}
|
||||
|
||||
|
@ -184,15 +184,12 @@ func (mh *MothRequestHandler) ExportState() *StateExport {
|
|||
return mh.exportStateIfRegistered(false)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func (mh *MothRequestHandler) exportStateIfRegistered(override bool) *StateExport {
|
||||
export := StateExport{}
|
||||
export.Config = mh.Config
|
||||
|
||||
teamName, err := mh.State.TeamName(mh.teamID)
|
||||
registered := forceRegistered || mh.Config.Devel || (err == nil)
|
||||
registered := override || mh.Config.Devel || (err == nil)
|
||||
|
||||
export.Messages = mh.State.Messages()
|
||||
export.TeamNames = make(map[string]string)
|
||||
|
@ -257,7 +254,7 @@ func (mh *MothRequestHandler) Mothball(cat string, w io.Writer) error {
|
|||
var err error
|
||||
|
||||
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 {
|
||||
if err = provider.Mothball(cat, w); err == nil {
|
||||
|
|
|
@ -3,46 +3,34 @@ package main
|
|||
import (
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const TestMaintenanceInterval = time.Millisecond * 1
|
||||
const TestTeamID = "teamID"
|
||||
|
||||
type TestServer struct {
|
||||
*MothServer
|
||||
}
|
||||
|
||||
// NewTestServer creates a new MothServer with NewTestMothballs and some initial state.
|
||||
//
|
||||
// See function definition for details.
|
||||
func NewTestServer() TestServer {
|
||||
func NewTestServer() *MothServer {
|
||||
puzzles := NewTestMothballs()
|
||||
puzzles.refresh()
|
||||
go puzzles.Maintain(TestMaintenanceInterval)
|
||||
|
||||
state := NewTestState()
|
||||
afero.WriteFile(state, "teamids.txt", []byte("teamID\n"), 0644)
|
||||
afero.WriteFile(state, "messages.html", []byte("messages.html"), 0644)
|
||||
state.refresh()
|
||||
go state.Maintain(TestMaintenanceInterval)
|
||||
|
||||
theme := NewTestTheme()
|
||||
afero.WriteFile(theme.Fs, "/index.html", []byte("index.html"), 0644)
|
||||
go theme.Maintain(TestMaintenanceInterval)
|
||||
|
||||
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()
|
||||
return NewMothServer(Configuration{}, theme, state, puzzles)
|
||||
}
|
||||
|
||||
func TestDevelServer(t *testing.T) {
|
||||
server := NewTestServer()
|
||||
server.Config.Devel = true
|
||||
anonHandler := server.NewHandler("badTeamId")
|
||||
anonHandler := server.NewHandler("badParticipantId", "badTeamId")
|
||||
|
||||
{
|
||||
es := anonHandler.ExportState()
|
||||
|
@ -57,11 +45,12 @@ func TestDevelServer(t *testing.T) {
|
|||
|
||||
func TestProdServer(t *testing.T) {
|
||||
teamName := "OurTeam"
|
||||
participantID := "participantID"
|
||||
teamID := TestTeamID
|
||||
|
||||
server := NewTestServer()
|
||||
handler := server.NewHandler(teamID)
|
||||
anonHandler := server.NewHandler("badTeamId")
|
||||
handler := server.NewHandler(participantID, teamID)
|
||||
anonHandler := server.NewHandler("badParticipantId", "badTeamId")
|
||||
|
||||
{
|
||||
es := handler.ExportState()
|
||||
|
@ -91,15 +80,13 @@ func TestProdServer(t *testing.T) {
|
|||
t.Error("index.html wrong contents", contents)
|
||||
}
|
||||
|
||||
server.refresh()
|
||||
|
||||
{
|
||||
es := handler.ExportState()
|
||||
if es.Config.Devel {
|
||||
t.Error("Marked as development server", es.Config)
|
||||
}
|
||||
if len(es.Puzzles) != 1 {
|
||||
t.Error("Puzzle categories wrong length", len(es.Puzzles))
|
||||
t.Error("Puzzle categories wrong length")
|
||||
}
|
||||
if es.Messages != "messages.html" {
|
||||
t.Error("Messages has wrong contents")
|
||||
|
@ -144,7 +131,7 @@ func TestProdServer(t *testing.T) {
|
|||
t.Error("Right answer marked wrong", err)
|
||||
}
|
||||
|
||||
server.refresh()
|
||||
time.Sleep(TestMaintenanceInterval)
|
||||
|
||||
{
|
||||
es := handler.ExportState()
|
||||
|
@ -173,7 +160,7 @@ func TestProdServer(t *testing.T) {
|
|||
t.Error("Right answer marked wrong:", err)
|
||||
}
|
||||
|
||||
server.refresh()
|
||||
time.Sleep(TestMaintenanceInterval)
|
||||
|
||||
{
|
||||
es := anonHandler.ExportState()
|
||||
|
@ -181,8 +168,9 @@ func TestProdServer(t *testing.T) {
|
|||
t.Error("Anonymous TeamNames is wrong:", es.TeamNames)
|
||||
}
|
||||
if len(es.PointsLog) != 2 {
|
||||
t.Errorf("Points log wrong length: got %d, wanted 2", len(es.PointsLog))
|
||||
} else if es.PointsLog[1].TeamID != "0" {
|
||||
t.Error("Points log wrong length")
|
||||
}
|
||||
if es.PointsLog[1].TeamID != "0" {
|
||||
t.Error("Second point log didn't anonymize team ID correctly:", es.PointsLog[1])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,10 +11,9 @@ import (
|
|||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dirtbags/moth/v4/pkg/award"
|
||||
"github.com/dirtbags/moth/pkg/award"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
|
@ -28,7 +27,7 @@ const DistinguishableChars = "34678abcdefhikmnpqrtwxy="
|
|||
const RFC3339Space = "2006-01-02 15:04:05Z07:00"
|
||||
|
||||
// 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.
|
||||
// We use the filesystem for synchronization between threads.
|
||||
|
@ -39,18 +38,10 @@ type State struct {
|
|||
// Enabled tracks whether the current State system is processing updates
|
||||
Enabled bool
|
||||
|
||||
enabledWhy string
|
||||
refreshNow chan bool
|
||||
eventStream chan []string
|
||||
eventWriter *csv.Writer
|
||||
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
|
||||
|
@ -60,8 +51,6 @@ func NewState(fs afero.Fs) *State {
|
|||
Enabled: true,
|
||||
refreshNow: make(chan bool, 5),
|
||||
eventStream: make(chan []string, 80),
|
||||
|
||||
teamNames: make(map[string]string),
|
||||
}
|
||||
if err := s.reopenEventLog(); err != nil {
|
||||
log.Fatal(err)
|
||||
|
@ -72,10 +61,11 @@ func NewState(fs afero.Fs) *State {
|
|||
// updateEnabled checks a few things to see if this state directory is "enabled".
|
||||
func (s *State) updateEnabled() {
|
||||
nextEnabled := true
|
||||
why := "state/hours.txt has no timestamps before now"
|
||||
why := "`state/enabled` present, `state/hours.txt` missing"
|
||||
|
||||
if untilFile, err := s.Open("hours.txt"); err == nil {
|
||||
defer untilFile.Close()
|
||||
why = "`state/hours.txt` present"
|
||||
|
||||
scanner := bufio.NewScanner(untilFile)
|
||||
for scanner.Scan() {
|
||||
|
@ -95,64 +85,59 @@ func (s *State) updateEnabled() {
|
|||
case '#':
|
||||
continue
|
||||
default:
|
||||
log.Println("state/hours.txt has bad line:", line)
|
||||
log.Println("Misformatted line in hours.txt file")
|
||||
}
|
||||
line, _, _ = strings.Cut(line, "#") // Remove inline comments
|
||||
line = strings.TrimSpace(line)
|
||||
until := time.Time{}
|
||||
if len(line) == 0 {
|
||||
// Let it stay as zero time, so it's always before now
|
||||
} else if until, err = time.Parse(time.RFC3339, line); err == nil {
|
||||
// Great, it was RFC 3339
|
||||
} 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)
|
||||
until, err := time.Parse(time.RFC3339, line)
|
||||
if err != nil {
|
||||
until, err = time.Parse(RFC3339Space, line)
|
||||
}
|
||||
if err != nil {
|
||||
log.Println("Suspended: Unparseable until date:", line)
|
||||
continue
|
||||
}
|
||||
if until.Before(time.Now()) {
|
||||
nextEnabled = thisEnabled
|
||||
why = fmt.Sprint("state/hours.txt most recent timestamp:", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nextEnabled != s.Enabled) || (why != s.enabledWhy) {
|
||||
if _, err := s.Stat("enabled"); os.IsNotExist(err) {
|
||||
nextEnabled = false
|
||||
why = "`state/enabled` missing"
|
||||
}
|
||||
|
||||
if nextEnabled != s.Enabled {
|
||||
s.Enabled = nextEnabled
|
||||
s.enabledWhy = why
|
||||
log.Printf("Setting enabled=%v: %s", s.Enabled, s.enabledWhy)
|
||||
log.Printf("Setting enabled=%v: %s", s.Enabled, why)
|
||||
if s.Enabled {
|
||||
s.LogEvent("enabled", "", "", 0, s.enabledWhy)
|
||||
s.LogEvent("enabled", "", "", "", 0, why)
|
||||
} else {
|
||||
s.LogEvent("disabled", "", "", 0, s.enabledWhy)
|
||||
s.LogEvent("disabled", "", "", "", 0, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TeamName returns team name given a team ID.
|
||||
func (s *State) TeamName(teamID string) (string, error) {
|
||||
s.lock.RLock()
|
||||
name, ok := s.teamNames[teamID]
|
||||
s.lock.RUnlock()
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unregistered team ID: %s", teamID)
|
||||
teamFs := afero.NewBasePathFs(s.Fs, "teams")
|
||||
teamNameBytes, err := afero.ReadFile(teamFs, teamID)
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("Unregistered team ID: %s", teamID)
|
||||
} else if err != nil {
|
||||
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.
|
||||
// This can only be done once per team.
|
||||
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")
|
||||
if err != nil {
|
||||
return fmt.Errorf("team IDs file does not exist")
|
||||
return fmt.Errorf("Team IDs file does not exist")
|
||||
}
|
||||
defer idsFile.Close()
|
||||
found := false
|
||||
|
@ -164,7 +149,7 @@ func (s *State) SetTeamName(teamID, teamName string) error {
|
|||
}
|
||||
}
|
||||
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)
|
||||
|
@ -178,26 +163,36 @@ func (s *State) SetTeamName(teamID, teamName string) error {
|
|||
log.Printf("Setting team name [%s] in file %s", teamName, teamFilename)
|
||||
fmt.Fprintln(teamFile, teamName)
|
||||
teamFile.Close()
|
||||
|
||||
s.refreshNow <- true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PointsLog retrieves the current points log.
|
||||
func (s *State) PointsLog() award.List {
|
||||
s.lock.RLock()
|
||||
ret := make(award.List, len(s.pointsLog))
|
||||
copy(ret, s.pointsLog)
|
||||
s.lock.RUnlock()
|
||||
return ret
|
||||
f, err := s.Open("points.log")
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return nil
|
||||
}
|
||||
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.
|
||||
func (s *State) Messages() string {
|
||||
s.lock.RLock() // It's not clear to me that this actually needs to happen
|
||||
defer s.lock.RUnlock()
|
||||
return s.messages
|
||||
bMessages, _ := afero.ReadFile(s, "messages.html")
|
||||
return string(bMessages)
|
||||
}
|
||||
|
||||
// AwardPoints gives points to teamID in category.
|
||||
|
@ -207,12 +202,8 @@ func (s *State) Messages() string {
|
|||
// It's just a courtesy to the user.
|
||||
// The update task makes sure we never have duplicate points in the log.
|
||||
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{
|
||||
When: when,
|
||||
When: time.Now().Unix(),
|
||||
TeamID: teamID,
|
||||
Category: category,
|
||||
Points: points,
|
||||
|
@ -220,12 +211,11 @@ func (s *State) awardPointsAtTime(when int64, teamID string, category string, po
|
|||
|
||||
for _, e := range s.PointsLog() {
|
||||
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", a.TeamID, a.Category, a.Points)
|
||||
fn := a.Filename()
|
||||
fn := fmt.Sprintf("%s-%s-%d", teamID, category, points)
|
||||
tmpfn := filepath.Join("points.tmp", fn)
|
||||
newfn := filepath.Join("points.new", fn)
|
||||
|
||||
|
@ -265,14 +255,12 @@ func (s *State) collectPoints() {
|
|||
}
|
||||
|
||||
duplicate := false
|
||||
s.lock.RLock()
|
||||
for _, e := range s.pointsLog {
|
||||
for _, e := range s.PointsLog() {
|
||||
if awd.Equal(e) {
|
||||
duplicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
s.lock.RUnlock()
|
||||
|
||||
if duplicate {
|
||||
log.Print("Skipping duplicate points: ", awd.String())
|
||||
|
@ -286,11 +274,6 @@ func (s *State) collectPoints() {
|
|||
}
|
||||
fmt.Fprintln(logf, awd.String())
|
||||
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 {
|
||||
|
@ -312,7 +295,6 @@ func (s *State) maybeInitialize() {
|
|||
s.Remove("enabled")
|
||||
s.Remove("hours.txt")
|
||||
s.Remove("points.log")
|
||||
s.Remove("events.csv")
|
||||
s.Remove("messages.html")
|
||||
s.Remove("mothd.log")
|
||||
s.RemoveAll("points.tmp")
|
||||
|
@ -323,7 +305,7 @@ func (s *State) maybeInitialize() {
|
|||
if err := s.reopenEventLog(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
s.LogEvent("init", "", "", 0)
|
||||
s.LogEvent("init", "", "", "", 0)
|
||||
|
||||
// Make sure various subdirectories exist
|
||||
s.Mkdir("points.tmp", 0755)
|
||||
|
@ -351,19 +333,21 @@ func (s *State) maybeInitialize() {
|
|||
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 {
|
||||
fmt.Fprintln(f, "# hours.txt: when the contest is enabled")
|
||||
fmt.Fprintln(f, "#")
|
||||
fmt.Fprintln(f, "# Enable: + [timestamp]")
|
||||
fmt.Fprintln(f, "# Disable: - [timestamp]")
|
||||
fmt.Fprintln(f, "# Enable: + timestamp")
|
||||
fmt.Fprintln(f, "# Disable: - timestamp")
|
||||
fmt.Fprintln(f, "#")
|
||||
fmt.Fprintln(f, "# This file, and all files in this directory, are re-read periodically.")
|
||||
fmt.Fprintln(f, "# Default is enabled.")
|
||||
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, "# You can have multiple start/stop times.")
|
||||
fmt.Fprintln(f, "# Whatever time is the most recent, wins.")
|
||||
fmt.Fprintln(f, "# Times in the future are ignored.")
|
||||
fmt.Fprintln(f)
|
||||
fmt.Fprintln(f, "- 1970-01-01T00:00:00Z")
|
||||
fmt.Fprintln(f, "+", now)
|
||||
fmt.Fprintln(f, "- 2519-10-31T00:00:00Z")
|
||||
f.Close()
|
||||
|
@ -379,12 +363,20 @@ func (s *State) maybeInitialize() {
|
|||
}
|
||||
}
|
||||
|
||||
func logstr(s string) string {
|
||||
if s == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// LogEvent writes to the event log
|
||||
func (s *State) LogEvent(event, teamID, cat string, points int, extra ...string) {
|
||||
func (s *State) LogEvent(event, participantID, teamID, cat string, points int, extra ...string) {
|
||||
s.eventStream <- append(
|
||||
[]string{
|
||||
strconv.FormatInt(time.Now().Unix(), 10),
|
||||
event,
|
||||
participantID,
|
||||
teamID,
|
||||
cat,
|
||||
strconv.Itoa(points),
|
||||
|
@ -412,72 +404,12 @@ func (s *State) reopenEventLog() error {
|
|||
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() {
|
||||
s.maybeInitialize()
|
||||
s.updateEnabled()
|
||||
if s.Enabled {
|
||||
s.collectPoints()
|
||||
}
|
||||
s.updateCaches()
|
||||
}
|
||||
|
||||
// Maintain performs housekeeping on a State struct.
|
||||
|
@ -522,9 +454,6 @@ func (ds *DevelState) TeamName(teamID string) (string, error) {
|
|||
if name, err := ds.StateProvider.TeamName(teamID); err == nil {
|
||||
return name, nil
|
||||
}
|
||||
if teamID == "" {
|
||||
return "", fmt.Errorf("Empty team ID")
|
||||
}
|
||||
return fmt.Sprintf("«devel:%s»", teamID), nil
|
||||
}
|
||||
|
||||
|
|
|
@ -17,16 +17,8 @@ func NewTestState() *State {
|
|||
return s
|
||||
}
|
||||
|
||||
func slurp(c chan bool) {
|
||||
for range c {
|
||||
// Nothing
|
||||
}
|
||||
}
|
||||
|
||||
func TestState(t *testing.T) {
|
||||
s := NewTestState()
|
||||
defer close(s.refreshNow)
|
||||
go slurp(s.refreshNow)
|
||||
|
||||
mustExist := func(path string) {
|
||||
_, err := s.Fs.Stat(path)
|
||||
|
@ -41,6 +33,7 @@ func TestState(t *testing.T) {
|
|||
}
|
||||
|
||||
mustExist("initialized")
|
||||
mustExist("enabled")
|
||||
mustExist("hours.txt")
|
||||
|
||||
teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt")
|
||||
|
@ -64,12 +57,11 @@ func TestState(t *testing.T) {
|
|||
|
||||
teamName := "My Team"
|
||||
if err := s.SetTeamName(teamID, teamName); err != nil {
|
||||
t.Errorf("Setting team name: %v", err)
|
||||
t.Errorf("Setting team name: %w", err)
|
||||
}
|
||||
if err := s.SetTeamName(teamID, "wat"); err == nil {
|
||||
t.Errorf("Registering team a second time didn't fail")
|
||||
}
|
||||
s.refresh()
|
||||
if name, err := s.TeamName(teamID); err != nil {
|
||||
t.Error(err)
|
||||
} else if name != teamName {
|
||||
|
@ -81,6 +73,9 @@ func TestState(t *testing.T) {
|
|||
if err := s.AwardPoints(teamID, category, points); err != nil {
|
||||
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
|
||||
if f, err := s.Create("points.new/moo"); err != nil {
|
||||
t.Error("Creating duplicate points file:", err)
|
||||
|
@ -88,34 +83,24 @@ func TestState(t *testing.T) {
|
|||
fmt.Fprintln(f, time.Now().Unix()+1, teamID, category, points)
|
||||
f.Close()
|
||||
}
|
||||
|
||||
s.AwardPoints(teamID, category, points)
|
||||
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 {
|
||||
t.Error("Duplicate points award after refresh didn't fail")
|
||||
t.Error("Duplicate points award didn't fail")
|
||||
}
|
||||
|
||||
if err := s.AwardPoints(teamID, category, points+1); err != nil {
|
||||
t.Error("Awarding more points:", err)
|
||||
}
|
||||
|
||||
s.refresh()
|
||||
if len(s.PointsLog()) != 2 {
|
||||
t.Errorf("There should be two awards")
|
||||
pl = s.PointsLog()
|
||||
if len(pl) != 1 {
|
||||
t.Errorf("After awarding 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)
|
||||
}
|
||||
|
||||
afero.WriteFile(s, "points.log", []byte("intentional parse error\n"), 0644)
|
||||
s.refresh()
|
||||
if len(s.PointsLog()) != 0 {
|
||||
t.Errorf("Intentional parse error breaks pointslog")
|
||||
}
|
||||
|
@ -123,8 +108,7 @@ func TestState(t *testing.T) {
|
|||
t.Error(err)
|
||||
}
|
||||
s.refresh()
|
||||
if len(s.PointsLog()) != 1 {
|
||||
t.Log(s.PointsLog())
|
||||
if len(s.PointsLog()) != 2 {
|
||||
t.Error("Intentional parse error screws up all parsing")
|
||||
}
|
||||
|
||||
|
@ -138,45 +122,18 @@ 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) {
|
||||
s := NewTestState()
|
||||
s.LogEvent("moo", "", "", 0)
|
||||
s.LogEvent("moo 2", "", "", 0)
|
||||
s.LogEvent("moo", "", "", "", 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)
|
||||
}
|
||||
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" {
|
||||
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "moo::::0" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -194,37 +151,19 @@ func TestStateDisabled(t *testing.T) {
|
|||
t.Error(err)
|
||||
}
|
||||
defer hoursFile.Close()
|
||||
s.refresh()
|
||||
if !s.Enabled {
|
||||
t.Error("Empty hours.txt not enabled")
|
||||
}
|
||||
|
||||
fmt.Fprintln(hoursFile, "- 1970-01-01T01:01:01Z")
|
||||
hoursFile.Sync()
|
||||
s.refresh()
|
||||
if s.Enabled {
|
||||
t.Error("1970-01-01")
|
||||
t.Error("Disabling 1970-01-01")
|
||||
}
|
||||
|
||||
fmt.Fprintln(hoursFile, "+ 1970-01-02 01:01:01+05:00")
|
||||
fmt.Fprintln(hoursFile, "+ 1970-01-01 01:01:01+05:00")
|
||||
hoursFile.Sync()
|
||||
s.refresh()
|
||||
if !s.Enabled {
|
||||
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 +")
|
||||
t.Error("Enabling 1970-01-02")
|
||||
}
|
||||
|
||||
fmt.Fprintln(hoursFile, "")
|
||||
|
@ -232,7 +171,7 @@ func TestStateDisabled(t *testing.T) {
|
|||
hoursFile.Sync()
|
||||
s.refresh()
|
||||
if !s.Enabled {
|
||||
t.Error("Comment")
|
||||
t.Error("Comments")
|
||||
}
|
||||
|
||||
fmt.Fprintln(hoursFile, "intentional parse error")
|
||||
|
@ -246,7 +185,7 @@ func TestStateDisabled(t *testing.T) {
|
|||
hoursFile.Sync()
|
||||
s.refresh()
|
||||
if s.Enabled {
|
||||
t.Error("1980-01-01")
|
||||
t.Error("Disabling 1980-01-01")
|
||||
}
|
||||
|
||||
if err := s.Remove("hours.txt"); err != nil {
|
||||
|
@ -257,6 +196,14 @@ func TestStateDisabled(t *testing.T) {
|
|||
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.refresh()
|
||||
if !s.Enabled {
|
||||
|
@ -286,7 +233,7 @@ func TestStateMaintainer(t *testing.T) {
|
|||
t.Error("Team ID too short:", teamID)
|
||||
}
|
||||
|
||||
s.LogEvent("Hello!", "", "", 0)
|
||||
s.LogEvent("Hello!", "", "", "", 0)
|
||||
|
||||
if len(s.PointsLog()) != 0 {
|
||||
t.Error("Points log is not empty")
|
||||
|
@ -311,11 +258,11 @@ func TestStateMaintainer(t *testing.T) {
|
|||
eventLog, err := afero.ReadFile(s.Fs, "events.csv")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
} else if events := strings.Split(string(eventLog), "\n"); len(events) != 4 {
|
||||
} else if events := strings.Split(string(eventLog), "\n"); len(events) != 3 {
|
||||
t.Log("Events:", events)
|
||||
t.Error("Wrong event log length:", len(events))
|
||||
} else if events[3] != "" {
|
||||
t.Error("Event log didn't end with newline", events)
|
||||
} else if events[2] != "" {
|
||||
t.Error("Event log didn't end with newline")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -38,7 +38,3 @@ func (t *Theme) Open(name string) (ReadSeekCloser, time.Time, error) {
|
|||
func (t *Theme) Maintain(i time.Duration) {
|
||||
// No periodic tasks for a theme
|
||||
}
|
||||
|
||||
func (t *Theme) refresh() {
|
||||
// Nothing to do for a theme
|
||||
}
|
||||
|
|
|
@ -32,11 +32,6 @@ func TestTheme(t *testing.T) {
|
|||
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 {
|
||||
f.Close()
|
||||
t.Error("Opening non-existent file didn't return an error")
|
||||
|
|
|
@ -7,7 +7,7 @@ import (
|
|||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/dirtbags/moth/v4/pkg/transpile"
|
||||
"github.com/dirtbags/moth/pkg/transpile"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
|
@ -79,7 +79,3 @@ func (p TranspilerProvider) Mothball(cat string, w io.Writer) error {
|
|||
func (p TranspilerProvider) Maintain(updateInterval time.Duration) {
|
||||
// Nothing to do here.
|
||||
}
|
||||
|
||||
func (p TranspilerProvider) refresh() {
|
||||
// Nothing to do for a theme
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import (
|
|||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/dirtbags/moth/v4/pkg/transpile"
|
||||
"github.com/dirtbags/moth/pkg/transpile"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
@ -81,7 +81,7 @@ func (t *T) ParseArgs() (Command, error) {
|
|||
default:
|
||||
fmt.Fprintln(t.Stderr, "ERROR:", t.Args[1], "is not a valid command")
|
||||
usage(t.Stderr)
|
||||
return nothing, fmt.Errorf("invalid command")
|
||||
return nothing, fmt.Errorf("Invalid command")
|
||||
}
|
||||
|
||||
if err := flags.Parse(t.Args[2:]); err != nil {
|
||||
|
|
|
@ -4,13 +4,11 @@ import (
|
|||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dirtbags/moth/v4/pkg/transpile"
|
||||
"github.com/dirtbags/moth/pkg/transpile"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
|
@ -204,32 +202,3 @@ func TestFilesystem(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
79
docs/FAQ.md
79
docs/FAQ.md
|
@ -1,79 +0,0 @@
|
|||
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
|
||||
-------------------
|
||||
|
||||
echo '-###' >> /srv/moth/state/hours.txt # Suspend scoring
|
||||
sed -i '/###/d' /srv/moth/state/hours.txt # Resume scoring
|
||||
rm /srv/moth/state/enabled # Pause scoring
|
||||
touch /srv/moth/state/enabled # Resume scoring
|
||||
|
||||
When scoring is paused,
|
||||
participants can still submit answers,
|
||||
|
@ -54,13 +54,12 @@ and the system will tell them whether the answer is correct.
|
|||
As soon as you unpause,
|
||||
all correctly-submitted answers will be scored.
|
||||
|
||||
|
||||
Adjusting scores
|
||||
------------------
|
||||
|
||||
echo '-###' >> /srv/moth/state/hours.txt # Suspend scoring
|
||||
rm /srv/moth/state/enabled # Suspend scoring
|
||||
nano /srv/moth/state/points.log # Replace nano with your preferred editor
|
||||
sed -i '/###/d' /srv/moth/state/hours.txt # Resume scoring
|
||||
touch /srv/moth/state/enabled # Resume scoring
|
||||
|
||||
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.
|
||||
|
|
|
@ -25,12 +25,12 @@ so you can watch the access log and any error messages.
|
|||
|
||||
### Podman
|
||||
|
||||
podman run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro ghcr.io/dirtbags/moth-devel
|
||||
podman run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro dirtbags/moth-devel
|
||||
|
||||
|
||||
### Docker
|
||||
|
||||
docker run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro ghcr.io/dirtbags/moth-devel
|
||||
docker run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro dirtbags/moth-devel
|
||||
|
||||
### Native
|
||||
|
||||
|
|
|
@ -33,11 +33,11 @@ We're going to assume you put everything in `/srv/moth`, like we suggested.
|
|||
|
||||
### Podman
|
||||
|
||||
podman run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state ghcr.io/dirtbags/moth
|
||||
podman run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state dirtbags/moth
|
||||
|
||||
### Docker
|
||||
|
||||
docker run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state ghcr.io/dirtbags/moth
|
||||
docker run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state dirtbags/moth
|
||||
|
||||
### Native
|
||||
|
||||
|
|
31
docs/logs.md
31
docs/logs.md
|
@ -41,18 +41,16 @@ Each line has four fields:
|
|||
1602702913 2255 sequence 16
|
||||
```
|
||||
|
||||
`events.csv` format
|
||||
`events.log` format
|
||||
----------------------
|
||||
|
||||
The events log is a comma-separated variable (CSV) file.
|
||||
It ought to import into any spreadsheet program painlessly.
|
||||
|
||||
The events log is a space-separated file.
|
||||
Each line has six fields minimum:
|
||||
|
||||
| `timestamp` | `event` | `teamID` | `category` | `points` | `extra`... |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| int | string | string | string | int | string... |
|
||||
| Unix epoch | Event type | Team's unique ID | Name of category, if any | Points awarded, if any | Additional fields, if any |
|
||||
| `timestamp` | `event` | `participantID` | `teamID` | `category` | `points` | `extra`... |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| int | string | 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 |
|
||||
|
||||
Fields after `points` contain extra fields associated with the event.
|
||||
|
||||
|
@ -63,7 +61,6 @@ These may change in the future.
|
|||
* init: startup of server
|
||||
* disabled: points accumulation disabled
|
||||
* enabled: points accumulation re-enabled
|
||||
* register: team registration
|
||||
* load: puzzle load
|
||||
* wrong: wrong answer submitted
|
||||
* correct: correct answer submitted
|
||||
|
@ -71,14 +68,14 @@ These may change in the future.
|
|||
### Example
|
||||
|
||||
```
|
||||
1602716345,init,-,-,-,-,0
|
||||
1602716349,load,2255,player5,sequence,1
|
||||
1602716450,load,4824,player3,sequence,1
|
||||
1602716359,correct,2255,player5,sequence,1
|
||||
1602716423,wrong,4824,player3,sequence,1
|
||||
1602716428,correct,4824,player3,sequence,1
|
||||
1602716530,correct,4824,player3,sequence,1
|
||||
1602716546,abduction,4824,player3,-,0,alien,FM1490
|
||||
1602716345 init - - - - 0
|
||||
1602716349 load 2255 player5 sequence 1
|
||||
1602716450 load 4824 player3 sequence 1
|
||||
1602716359 correct 2255 player5 sequence 1
|
||||
1602716423 wrong 4824 player3 sequence 1
|
||||
1602716428 correct 4824 player3 sequence 1
|
||||
1602716530 correct 4824 player3 sequence 1
|
||||
1602716546 abduction 4824 player3 - 0 alien FM1490
|
||||
```
|
||||
|
||||
The final entry is a made-up "alien abduction" entry,
|
||||
|
|
|
@ -33,13 +33,24 @@ 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.
|
||||
|
||||
|
||||
`hours.txt`
|
||||
`disabled`
|
||||
----------
|
||||
|
||||
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`
|
||||
-------
|
||||
|
||||
A list of start and stop hours.
|
||||
If all the hours are in the future, the event defaults to running.
|
||||
"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.
|
||||
Put an RFC3337 date/time stamp in here to have the server pause itself at a given time.
|
||||
Remember that time zones exist!
|
||||
I recommend always using Zulu time.
|
||||
|
||||
This file does not normally exist.
|
||||
|
||||
|
||||
`teamids.txt`
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
Scoring
|
||||
=======
|
||||
|
||||
MOTH does not carry any notion of who is winning: we consider this a user
|
||||
interface issue. The server merely provides a timestamped log of point awards.
|
||||
|
||||
The bundled scoreboard provides one way to interpret the scores: this is the
|
||||
main algorithm we use at Cyber Fire events. We use other views of the scoreboard
|
||||
in other contexts, though! Here are some ideas:
|
||||
|
||||
|
||||
Percentage of Each Category
|
||||
---------------------
|
||||
|
||||
This is implemented in the scoreboard distributed with MOTH, and is how our
|
||||
primary score calculation at Cyber Fire.
|
||||
|
||||
For each category:
|
||||
|
||||
* Divide the team's score in this category by the highest score in this category
|
||||
* Add that to the team's overall score
|
||||
|
||||
This means the highest theoretical score in any event is the number of open
|
||||
categories.
|
||||
|
||||
This algorithm means that point values only matter relative to other point
|
||||
values within that category. A category with 5 total points is worth the same as
|
||||
a category with 5000 total points, and a 2 point puzzle in the first category is
|
||||
worth as much as a 2000 point puzzle in the second.
|
||||
|
||||
One interesting effect here is that a team solving a previously-unsolved puzzle
|
||||
will reduce everybody else's ranking in that category, because it increases the
|
||||
divisor for calculating that category's score.
|
||||
|
||||
Cyber Fire used to not display overall score: we would only show each team's
|
||||
relative ranking per category. We may go back to this at some point!
|
||||
|
||||
|
||||
Category Completion
|
||||
----------------
|
||||
|
||||
Cyber Fire also has a scoreboard called the "class" scoreboard, which lists each
|
||||
team, and which puzzles they have completed. This provides instructors with a
|
||||
graphical overview of how people are progressing through content. We can provide
|
||||
assistance to the general group when we see that a large number of teams are
|
||||
stuck on a particular puzzle, and we can provide individual assistance if we see
|
||||
that someone isn't keeping up with the class.
|
||||
|
||||
|
||||
Monarch Of The Hill
|
||||
----------------
|
||||
|
||||
You could also implement a "winner takes all" approach: any team with the
|
||||
maximum number of points in a category gets 1 point, and all other teams get 0.
|
||||
|
||||
|
||||
Time Bonuses
|
||||
-----------
|
||||
|
||||
If you wanted to provide extra points to whichever team solves a puzzle first,
|
||||
this is possible with the log. You could either boost a puzzle's point value or
|
||||
decay it; either by timestamp, or by how many teams had solved it prior.
|
||||
|
||||
|
||||
Bonkers Scoring
|
||||
-------------
|
||||
|
||||
Other zany options exist:
|
||||
|
||||
* The first team to solve a puzzle with point value divisible by 7 gets double
|
||||
points.
|
||||
* [Tokens](tokens.md) with negative point values could be introduced, allowing
|
||||
teams to manipulate other teams' scores, if they know the team ID.
|
8
go.mod
8
go.mod
|
@ -1,12 +1,12 @@
|
|||
module github.com/dirtbags/moth/v4
|
||||
module github.com/dirtbags/moth
|
||||
|
||||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/spf13/afero v1.8.2
|
||||
github.com/yuin/goldmark v1.4.13
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
github.com/spf13/afero v1.5.1
|
||||
github.com/yuin/goldmark v1.3.1
|
||||
golang.org/x/text v0.3.5 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
|
447
go.sum
447
go.sum
|
@ -1,454 +1,45 @@
|
|||
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/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/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/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
||||
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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo=
|
||||
github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
|
||||
github.com/spf13/afero v1.4.0 h1:jsLTaI1zwYO3vjrzHalkVcIHXTNmdQFepW4OI8H3+x8=
|
||||
github.com/spf13/afero v1.4.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
|
||||
github.com/spf13/afero v1.5.1 h1:VHu76Lk0LSP1x254maIu2bplkWpfBWI+B+6fdoZprcg=
|
||||
github.com/spf13/afero v1.5.1/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
|
||||
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.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 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
|
||||
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=
|
||||
github.com/yuin/goldmark v1.3.1 h1:eVwehsLsZlCJCwXyGLgg+Q4iFWE/eTIMG0e8waCmm/I=
|
||||
github.com/yuin/goldmark v1.3.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
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/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
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-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-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.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 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
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/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
|
||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
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/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.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/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,7 +5,6 @@ import (
|
|||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
@ -50,7 +49,7 @@ func Parse(s string) (T, error) {
|
|||
if err != nil {
|
||||
return ret, err
|
||||
} 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
|
||||
|
@ -61,17 +60,6 @@ func (a T) String() string {
|
|||
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.
|
||||
func (a T) MarshalJSON() ([]byte, error) {
|
||||
ao := []interface{}{
|
||||
|
|
|
@ -4,7 +4,7 @@ import (
|
|||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
@ -37,45 +37,23 @@ type PuzzleDebug struct {
|
|||
Summary string
|
||||
}
|
||||
|
||||
// Puzzle contains everything about a puzzle that a client will see.
|
||||
// Puzzle contains everything about a puzzle that a client would see.
|
||||
type Puzzle struct {
|
||||
// Debug contains debugging information, omitted in mothballs
|
||||
Debug PuzzleDebug
|
||||
|
||||
// Authors names all authors of this puzzle
|
||||
Authors []string
|
||||
|
||||
// Attachments is a list of filenames used by this puzzle
|
||||
Attachments []string
|
||||
|
||||
// Scripts is a list of EMCAScript files needed by the client for this puzzle
|
||||
Scripts []string
|
||||
|
||||
// Body is the HTML rendering of this puzzle
|
||||
Body string
|
||||
|
||||
// AnswerPattern contains the pattern (regular expression?) used to match valid answers
|
||||
Debug PuzzleDebug
|
||||
Authors []string
|
||||
Attachments []string
|
||||
Scripts []string
|
||||
Body string
|
||||
AnswerPattern string
|
||||
|
||||
// AnswerHashes contains hashes of all answers for this puzzle
|
||||
AnswerHashes []string
|
||||
|
||||
// Objective is the learning objective for this puzzle
|
||||
Objective string
|
||||
|
||||
// KSAs lists all KSAs achieved upon successfull completion of this puzzle
|
||||
KSAs []string
|
||||
|
||||
// Success lists the criteria for successfully understanding this puzzle
|
||||
Success struct {
|
||||
// Acceptable describes the minimum work required to be considered successfully understanding this puzzle's concepts
|
||||
AnswerHashes []string
|
||||
Objective string
|
||||
KSAs []string
|
||||
Success struct {
|
||||
Acceptable string
|
||||
|
||||
// Mastery describes the work required to be considered mastering this puzzle's conceptss
|
||||
Mastery string
|
||||
Mastery string
|
||||
}
|
||||
|
||||
// Answers lists all acceptable answers, omitted in mothballs
|
||||
// Answers will be empty in a mothball
|
||||
Answers []string
|
||||
}
|
||||
|
||||
|
@ -85,9 +63,9 @@ func (puzzle *Puzzle) computeAnswerHashes() {
|
|||
}
|
||||
puzzle.AnswerHashes = make([]string, len(puzzle.Answers))
|
||||
for i, answer := range puzzle.Answers {
|
||||
sum := sha1.Sum([]byte(answer))
|
||||
sum := sha256.Sum256([]byte(answer))
|
||||
hexsum := fmt.Sprintf("%x", sum)
|
||||
puzzle.AnswerHashes[i] = hexsum[:4]
|
||||
puzzle.AnswerHashes[i] = hexsum
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -23,12 +23,6 @@ func TestPuzzle(t *testing.T) {
|
|||
if (len(p.Answers) == 0) || (p.Answers[0] != "YAML answer") {
|
||||
t.Error("Answers are wrong", p.Answers)
|
||||
}
|
||||
if len(p.Answers) != len(p.AnswerHashes) {
|
||||
t.Error("Answer hashes length does not match answers length")
|
||||
}
|
||||
if len(p.AnswerHashes[0]) != 4 {
|
||||
t.Error("Answer hash is wrong length")
|
||||
}
|
||||
if (len(p.Authors) != 3) || (p.Authors[1] != "Buster") {
|
||||
t.Error("Authors are wrong", p.Authors)
|
||||
}
|
||||
|
|
|
@ -1,175 +0,0 @@
|
|||
function randint(max) {
|
||||
return Math.floor(Math.random() * max)
|
||||
}
|
||||
|
||||
const Millisecond = 1
|
||||
const Second = Millisecond * 1000
|
||||
const FrameRate = 24 / Second // Fast enough for this tomfoolery
|
||||
|
||||
class Point {
|
||||
constructor(x, y) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
}
|
||||
|
||||
/**
|
||||
* Add n to this.
|
||||
*
|
||||
* @param {Point} n What to add to this
|
||||
* @returns {Point}
|
||||
*/
|
||||
Add(n) {
|
||||
return new Point(this.x + n.x, this.y + n.y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract n from this.
|
||||
*
|
||||
* @param {Point} n
|
||||
* @returns {Point}
|
||||
*/
|
||||
Subtract(n) {
|
||||
return new Point(this.x - n.x, this.y - n.y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add velocity, then bounce point off box defined by points at min and max
|
||||
* @param {Point} velocity
|
||||
* @param {Point} min
|
||||
* @param {Point} max
|
||||
* @returns {Point}
|
||||
*/
|
||||
Bounce(velocity, min, max) {
|
||||
let p = this.Add(velocity)
|
||||
if (p.x < min.x) {
|
||||
p.x += (min.x - p.x) * 2
|
||||
velocity.x *= -1
|
||||
}
|
||||
if (p.x > max.x) {
|
||||
p.x += (max.x - p.x) * 2
|
||||
velocity.x *= -1
|
||||
}
|
||||
if (p.y < min.y) {
|
||||
p.y += (min.y - p.y) * 2
|
||||
velocity.y *= -1
|
||||
}
|
||||
if (p.y > max.y) {
|
||||
p.y += (max.y - p.y) * 2
|
||||
velocity.y *= -1
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Point} p
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
Equal(p) {
|
||||
return (this.x == p.x) && (this.y == p.y)
|
||||
}
|
||||
}
|
||||
|
||||
class QixLine {
|
||||
/**
|
||||
* @param {Number} hue
|
||||
* @param {Point} a
|
||||
* @param {Point} b
|
||||
*/
|
||||
constructor(hue, a, b) {
|
||||
this.hue = hue
|
||||
this.a = a
|
||||
this.b = b
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a line dancing around the screen,
|
||||
* like the video game "qix"
|
||||
*/
|
||||
class QixBackground {
|
||||
constructor(ctx, frameRate = 6/Second) {
|
||||
this.ctx = ctx
|
||||
this.min = new Point(0, 0)
|
||||
this.max = new Point(this.ctx.canvas.width, this.ctx.canvas.height)
|
||||
this.box = this.max.Subtract(this.min)
|
||||
|
||||
this.lines = [
|
||||
new QixLine(
|
||||
Math.random(),
|
||||
new Point(randint(this.box.x), randint(this.box.y)),
|
||||
new Point(randint(this.box.x), randint(this.box.y)),
|
||||
)
|
||||
]
|
||||
while (this.lines.length < 18) {
|
||||
this.lines.push(this.lines[0])
|
||||
}
|
||||
this.velocity = new QixLine(
|
||||
0.001,
|
||||
new Point(1 + randint(this.box.x / 100), 1 + randint(this.box.y / 100)),
|
||||
new Point(1 + randint(this.box.x / 100), 1 + randint(this.box.y / 100)),
|
||||
)
|
||||
|
||||
this.frameInterval = Millisecond / frameRate
|
||||
this.nextFrame = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Maybe draw a frame
|
||||
*/
|
||||
Animate() {
|
||||
let now = performance.now()
|
||||
if (now < this.nextFrame) {
|
||||
// Not today, satan
|
||||
return
|
||||
}
|
||||
this.nextFrame = now + this.frameInterval
|
||||
|
||||
this.lines.shift()
|
||||
let lastLine = this.lines[this.lines.length - 1]
|
||||
let nextLine = new QixLine(
|
||||
(lastLine.hue + this.velocity.hue) % 1.0,
|
||||
lastLine.a.Bounce(this.velocity.a, this.min, this.max),
|
||||
lastLine.b.Bounce(this.velocity.b, this.min, this.max),
|
||||
)
|
||||
|
||||
this.lines.push(nextLine)
|
||||
|
||||
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
|
||||
for (let line of this.lines) {
|
||||
this.ctx.save()
|
||||
this.ctx.strokeStyle = `hwb(${line.hue}turn 0% 0%)`
|
||||
this.ctx.beginPath()
|
||||
this.ctx.moveTo(line.a.x, line.a.y)
|
||||
this.ctx.lineTo(line.b.x, line.b.y)
|
||||
this.ctx.stroke()
|
||||
this.ctx.restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
// Don't like the background animation? You can disable it by setting a
|
||||
// property in localStorage and reloading.
|
||||
if (localStorage.disableBackgroundAnimation) {
|
||||
return
|
||||
}
|
||||
|
||||
let canvas = document.createElement("canvas")
|
||||
canvas.width = 640
|
||||
canvas.height = 640
|
||||
canvas.classList.add("wallpaper")
|
||||
document.body.insertBefore(canvas, document.body.firstChild)
|
||||
|
||||
let ctx = canvas.getContext("2d")
|
||||
|
||||
let qix = new QixBackground(ctx)
|
||||
// window.requestAnimationFrame is overkill for something this silly
|
||||
setInterval(() => qix.Animate(), Millisecond/FrameRate)
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init)
|
||||
} else {
|
||||
init()
|
||||
}
|
329
theme/basic.css
329
theme/basic.css
|
@ -1,132 +1,227 @@
|
|||
/* Color palette: http://paletton.com/#uid=33x0u0klrl-4ON9dhtKtAdqMQ4T */
|
||||
|
||||
/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
background: #010e19 url("bg.png") center fixed;
|
||||
background-size: cover;
|
||||
background-blend-mode: soft-light;
|
||||
background-color: #010e19;
|
||||
color: #edd488;
|
||||
}
|
||||
canvas.wallpaper {
|
||||
position: fixed;
|
||||
display: block;
|
||||
z-index: -1000;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
opacity: 0.2;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
main {
|
||||
max-width: 40em;
|
||||
margin: 1em auto;
|
||||
padding: 1px 3px;
|
||||
border-radius: 5px;
|
||||
background: #000d;
|
||||
background: #282a33;
|
||||
color: #f6efdc;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #cb2408cc;
|
||||
body.wide {
|
||||
max-width: 100%;
|
||||
}
|
||||
a:any-link {
|
||||
color: #8b969a;
|
||||
}
|
||||
h1 {
|
||||
background: #cb240844;
|
||||
padding: 3px;
|
||||
background: #5e576b;
|
||||
color: #9e98a8;
|
||||
}
|
||||
.Fail, .Error, #messages {
|
||||
background: #3a3119;
|
||||
color: #ffcc98;
|
||||
}
|
||||
.Fail:before {
|
||||
content: "Fail: ";
|
||||
}
|
||||
.Error:before {
|
||||
content: "Error: ";
|
||||
}
|
||||
p {
|
||||
margin: 1em 0em;
|
||||
}
|
||||
a:any-link {
|
||||
color: #b9cbd8;
|
||||
}
|
||||
form, pre {
|
||||
margin: 1em;
|
||||
overflow-x: auto;
|
||||
}
|
||||
input, select {
|
||||
padding: 0.6em;
|
||||
margin: 0.2em;
|
||||
max-width: 30em;
|
||||
}
|
||||
input {
|
||||
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 {
|
||||
border: solid black 2px;
|
||||
}
|
||||
nav ul, .category ul {
|
||||
margin: 0;
|
||||
padding: 0.2em 1em;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
padding: 1em;
|
||||
}
|
||||
nav li, .category li {
|
||||
display: inline;
|
||||
margin: 1em;
|
||||
}
|
||||
.mothball {
|
||||
float: right;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
background: #ccc;
|
||||
padding: 4px 8px;
|
||||
margin: 5px;
|
||||
iframe#body {
|
||||
border: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/** Puzzle content */
|
||||
#puzzle {
|
||||
border-bottom: solid;
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
#puzzle img {
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
input:invalid {
|
||||
background-color: #800;
|
||||
color: white;
|
||||
border-color: red;
|
||||
}
|
||||
.answer_ok {
|
||||
cursor: help;
|
||||
#messages {
|
||||
min-height: 3em;
|
||||
border: solid black 2px;
|
||||
}
|
||||
#rankings {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/** Development mode information */
|
||||
.debug {
|
||||
overflow: auto;
|
||||
padding: 1em;
|
||||
border-radius: 10px;
|
||||
margin: 2em auto;
|
||||
background: #cccc;
|
||||
#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;}
|
||||
.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;}
|
||||
|
||||
|
||||
/* 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;
|
||||
overflow: scroll;
|
||||
}
|
||||
.debug dt {
|
||||
font-weight: bold;
|
||||
#devel .string {
|
||||
color: #9c27b0;
|
||||
}
|
||||
#devel .body {
|
||||
background-color: #ffc107;
|
||||
}
|
||||
.kvpair {
|
||||
border: solid black 2px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: block;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
margin: 1px;
|
||||
border-radius: 50%;
|
||||
border: 5px solid #fff;
|
||||
border-color: #fff transparent #fff transparent;
|
||||
animation: rotate 1.2s linear infinite;
|
||||
}
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/** Draggable items, from the draggable plugin */
|
||||
li[draggable]::before {
|
||||
content: "↕";
|
||||
padding: 0.5em;
|
||||
|
@ -144,48 +239,6 @@ li[draggable] {
|
|||
border: 1px white dashed;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/** 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;
|
||||
}
|
||||
#cacheButton.disabled {
|
||||
display: none;
|
||||
}
|
BIN
theme/bg.png
BIN
theme/bg.png
Binary file not shown.
Before Width: | Height: | Size: 180 KiB |
|
@ -1,84 +0,0 @@
|
|||
/**
|
||||
* Common functionality
|
||||
*/
|
||||
const Millisecond = 1
|
||||
const Second = Millisecond * 1000
|
||||
const Minute = Second * 60
|
||||
|
||||
/** URL to the top of this MOTH server */
|
||||
const BaseURL = new URL(".", location)
|
||||
|
||||
/**
|
||||
* Display a transient message to the user.
|
||||
*
|
||||
* @param {String} message Message to display
|
||||
* @param {Number} timeout How long before removing this message
|
||||
*/
|
||||
function Toast(message, timeout=5*Second) {
|
||||
console.info(message)
|
||||
for (let toasts of document.querySelectorAll(".toasts")) {
|
||||
let p = toasts.appendChild(document.createElement("p"))
|
||||
p.classList.add("toast")
|
||||
p.textContent = message
|
||||
setTimeout(() => p.remove(), timeout)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a function when the DOM has been loaded.
|
||||
*
|
||||
* @param {function():void} cb Callback function
|
||||
*/
|
||||
function WhenDOMLoaded(cb) {
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", cb)
|
||||
} else {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interprets a String as a Boolean.
|
||||
*
|
||||
* Values like "no" or "disabled" to mean false here.
|
||||
*
|
||||
* @param {String} s
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function Truthy(s) {
|
||||
switch (s.toLowerCase()) {
|
||||
case "disabled":
|
||||
case "no":
|
||||
case "off":
|
||||
case "false":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fetch the configuration object for this theme.
|
||||
*
|
||||
* @returns {Promise.<Object>}
|
||||
*/
|
||||
async function Config() {
|
||||
let resp = await fetch(
|
||||
new URL("config.json", BaseURL),
|
||||
{
|
||||
cache: "no-cache"
|
||||
},
|
||||
)
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export {
|
||||
Millisecond,
|
||||
Second,
|
||||
Minute,
|
||||
BaseURL,
|
||||
Toast,
|
||||
WhenDOMLoaded,
|
||||
Truthy,
|
||||
Config,
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"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,44 +1,38 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html>
|
||||
<head>
|
||||
<title>MOTH</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<link rel="icon" href="luna-moth.svg">
|
||||
<link rel="stylesheet" href="basic.css">
|
||||
<script src="index.mjs" type="module" async></script>
|
||||
<script src="background.mjs" type="module" async></script>
|
||||
<script src="moth.js"></script>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="title">MOTH</h1>
|
||||
<main>
|
||||
<div class="messages notification">
|
||||
<h1 id="title">MOTH</h1>
|
||||
<section>
|
||||
<div id="messages">
|
||||
<div id="notices"></div>
|
||||
</div>
|
||||
|
||||
<form class="login">
|
||||
<form id="login">
|
||||
<!--
|
||||
<span id="pid">
|
||||
Participant ID: <input name="pid"> (optional) <br>
|
||||
</span>
|
||||
-->
|
||||
Team ID: <input name="id"> <br>
|
||||
Team name: <input name="name"> <br>
|
||||
<input type="submit" value="Sign In">
|
||||
</form>
|
||||
|
||||
<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>
|
||||
<div id="puzzles"></div>
|
||||
|
||||
</section>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="scoreboard.html" target="_blank">Scoreboard</a></li>
|
||||
<li><button class="logout">Sign Out</button></li>
|
||||
<li><a href="scoreboard.html">Scoreboard</a></li>
|
||||
<li><a href="logout.html">Sign Out</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</body>
|
||||
|
|
174
theme/index.mjs
174
theme/index.mjs
|
@ -1,174 +0,0 @@
|
|||
/**
|
||||
* Functionality for index.html (Login / Puzzles list)
|
||||
*/
|
||||
import * as moth from "./moth.mjs"
|
||||
import * as common from "./common.mjs"
|
||||
|
||||
class App {
|
||||
constructor(basePath=".") {
|
||||
this.config = {}
|
||||
|
||||
this.server = new moth.Server(basePath)
|
||||
|
||||
for (let form of document.querySelectorAll("form.login")) {
|
||||
form.addEventListener("submit", event => this.handleLoginSubmit(event))
|
||||
}
|
||||
for (let e of document.querySelectorAll(".logout")) {
|
||||
e.addEventListener("click", () => this.Logout())
|
||||
}
|
||||
|
||||
setInterval(() => this.UpdateState(), common.Minute/3)
|
||||
setInterval(() => this.UpdateConfig(), common.Minute* 5)
|
||||
|
||||
this.UpdateConfig()
|
||||
.finally(() => this.UpdateState())
|
||||
}
|
||||
|
||||
handleLoginSubmit(event) {
|
||||
event.preventDefault()
|
||||
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)
|
|
@ -0,0 +1,23 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>MOTH</title>
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<link rel="stylesheet" href="basic.css">
|
||||
<script>
|
||||
sessionStorage.removeItem("id")
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="title">MOTH</h1>
|
||||
<section>
|
||||
<p>Okay, you've been logged out.</p>
|
||||
</section>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="index.html">Sign In</a></li>
|
||||
<li><a href="scoreboard.html">Scoreboard</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "Monarch of the Hill",
|
||||
"short_name": "MOTH",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#282a33",
|
||||
"theme_color": "#ECB",
|
||||
"description": "The MOTH CTF engine"
|
||||
}
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,203 @@
|
|||
// 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()
|
||||
}
|
||||
|
681
theme/moth.mjs
681
theme/moth.mjs
|
@ -1,681 +0,0 @@
|
|||
/**
|
||||
* Hash/digest functions
|
||||
*/
|
||||
class Hash {
|
||||
/**
|
||||
* Dan Bernstein hash
|
||||
*
|
||||
* Used until MOTH v3.5
|
||||
*
|
||||
* @param {string} buf Input
|
||||
* @returns {number}
|
||||
*/
|
||||
static djb2(buf) {
|
||||
let h = 5381
|
||||
for (let c of (new TextEncoder()).encode(buf)) { // Encode as UTF-8 and read in each byte
|
||||
// JavaScript converts everything to a signed 32-bit integer when you do bitwise operations.
|
||||
// So we have to do "unsigned right shift" by zero to get it back to unsigned.
|
||||
h = ((h * 33) + c) >>> 0
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
/**
|
||||
* Dan Bernstein hash with xor
|
||||
*
|
||||
* @param {string} buf Input
|
||||
* @returns {number}
|
||||
*/
|
||||
static djb2xor(buf) {
|
||||
let h = 5381
|
||||
for (let c of (new TextEncoder()).encode(buf)) {
|
||||
h = ((h * 33) ^ c) >>> 0
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA 256
|
||||
*
|
||||
* Used until MOTH v4.5
|
||||
*
|
||||
* @param {string} buf Input
|
||||
* @returns {Promise.<string>} hex-encoded digest
|
||||
*/
|
||||
static async sha256(buf) {
|
||||
const msgUint8 = new TextEncoder().encode(buf)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return this.hexlify(hashArray);
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA 1, but only the first 4 hexits (2 octets).
|
||||
*
|
||||
* Git uses this technique with 7 hexits (default) as a "short identifier".
|
||||
*
|
||||
* @param {string} buf Input
|
||||
*/
|
||||
static async sha1_slice(buf, end=4) {
|
||||
const msgUint8 = new TextEncoder().encode(buf)
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-1", msgUint8)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hexits = this.hexlify(hashArray)
|
||||
return hexits.slice(0, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hex-encode a byte array
|
||||
*
|
||||
* @param {number[]} buf Byte array
|
||||
* @returns {string}
|
||||
*/
|
||||
static hexlify(buf) {
|
||||
return buf.map(b => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply every hash to the input buffer.
|
||||
*
|
||||
* @param {string} buf Input
|
||||
* @returns {Promise.<string[]>}
|
||||
*/
|
||||
static async All(buf) {
|
||||
return [
|
||||
String(this.djb2(buf)),
|
||||
await this.sha256(buf),
|
||||
await this.sha1_slice(buf),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A point award.
|
||||
*/
|
||||
class Award {
|
||||
constructor(when, teamid, category, points) {
|
||||
/** Unix epoch timestamp for this award
|
||||
* @type {number}
|
||||
*/
|
||||
this.When = when
|
||||
/** Team ID this award belongs to
|
||||
* @type {string}
|
||||
*/
|
||||
this.TeamID = teamid
|
||||
/** Puzzle category for this award
|
||||
* @type {string}
|
||||
*/
|
||||
this.Category = category
|
||||
/** Points value of this award
|
||||
* @type {number}
|
||||
*/
|
||||
this.Points = points
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A puzzle.
|
||||
*
|
||||
* A new Puzzle only knows its category and point value.
|
||||
* If you want to populate it with meta-information, you must call Populate().
|
||||
*
|
||||
* Parameters created by Populate are described in the server source code:
|
||||
* {@link https://pkg.go.dev/github.com/dirtbags/moth/v4/pkg/transpile#Puzzle}
|
||||
*
|
||||
*/
|
||||
class Puzzle {
|
||||
/**
|
||||
* @param {Server} server
|
||||
* @param {string} category
|
||||
* @param {number} points
|
||||
*/
|
||||
constructor (server, category, points) {
|
||||
if (points < 1) {
|
||||
throw(`Invalid points value: ${points}`)
|
||||
}
|
||||
|
||||
/** Server where this puzzle lives
|
||||
* @type {Server}
|
||||
*/
|
||||
this.server = server
|
||||
|
||||
/** Category this puzzle belongs to */
|
||||
this.Category = String(category)
|
||||
|
||||
/** Point value of this puzzle */
|
||||
this.Points = Number(points)
|
||||
|
||||
/** Error returned trying to retrieve this puzzle */
|
||||
this.Error = {
|
||||
/** Status code provided by server */
|
||||
Status: 0,
|
||||
/** Status text provided by server */
|
||||
StatusText: "",
|
||||
/** Full text of server error */
|
||||
Body: "",
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate this Puzzle object with meta-information from the server.
|
||||
*/
|
||||
async Populate() {
|
||||
let resp = await this.Get("puzzle.json")
|
||||
if (!resp.ok) {
|
||||
let body = await resp.text()
|
||||
this.Error = {
|
||||
Status: resp.status,
|
||||
StatusText: resp.statusText,
|
||||
Body: body,
|
||||
}
|
||||
throw(this.Error)
|
||||
}
|
||||
let obj = await resp.json()
|
||||
Object.assign(this, obj)
|
||||
|
||||
// Make sure lists are lists
|
||||
this.AnswerHashes ||= []
|
||||
this.Answers ||= []
|
||||
this.Attachments ||= []
|
||||
this.Authors ||= []
|
||||
this.Debug.Errors ||= []
|
||||
this.Debug.Hints ||= []
|
||||
this.Debug.Log ||= []
|
||||
this.KSAs ||= []
|
||||
this.Scripts ||= []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a resource associated with this puzzle.
|
||||
*
|
||||
* @param {string} filename Attachment/Script to retrieve
|
||||
* @returns {Promise.<Response>}
|
||||
*/
|
||||
Get(filename) {
|
||||
return this.server.GetContent(this.Category, this.Points, filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is possibly correct.
|
||||
*
|
||||
* The server sends a list of answer hashes with each puzzle: this method
|
||||
* checks to see if any of those hashes match a hash of the string.
|
||||
*
|
||||
* The MOTH development team likes obscure hash functions with a lot of
|
||||
* collisions, which means that a given input may match another possible
|
||||
* string's hash. We do this so that if you run a brute force attack against
|
||||
* the list of hashes, you have to write your own brute force program, and
|
||||
* you still have to pick through a lot of potentially correct answers when
|
||||
* it's done.
|
||||
*
|
||||
* @param {string} str User-submitted possible answer
|
||||
* @returns {Promise.<boolean>}
|
||||
*/
|
||||
async IsPossiblyCorrect(str) {
|
||||
let userAnswerHashes = await Hash.All(str)
|
||||
|
||||
for (let pah of this.AnswerHashes) {
|
||||
for (let uah of userAnswerHashes) {
|
||||
if (pah == uah) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a proposed answer for points.
|
||||
*
|
||||
* The returned promise will fail if anything goes wrong, including the
|
||||
* proposed answer being rejected.
|
||||
*
|
||||
* @param {string} proposed Answer to submit
|
||||
* @returns {Promise.<string>} Success message
|
||||
*/
|
||||
SubmitAnswer(proposed) {
|
||||
return this.server.SubmitAnswer(this.Category, this.Points, proposed)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A snapshot of scores.
|
||||
*/
|
||||
class Scores {
|
||||
constructor() {
|
||||
/**
|
||||
* Timestamp of this score snapshot
|
||||
* @type number
|
||||
*/
|
||||
this.Timestamp = 0
|
||||
|
||||
/**
|
||||
* All categories present in this snapshot.
|
||||
*
|
||||
* ECMAScript sets preserve order, so iterating over this will yield
|
||||
* categories as they were added to the points log.
|
||||
*
|
||||
* @type {Set.<string>}
|
||||
*/
|
||||
this.Categories = new Set()
|
||||
|
||||
/**
|
||||
* All team IDs present in this snapshot
|
||||
* @type {Set.<string>}
|
||||
*/
|
||||
this.TeamIDs = new Set()
|
||||
|
||||
/**
|
||||
* Highest score in each category
|
||||
* @type {Object.<string,number>}
|
||||
*/
|
||||
this.MaxPoints = {}
|
||||
|
||||
this.categoryTeamPoints = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a sorted list of category names
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
SortedCategories() {
|
||||
let categories = [...this.Categories]
|
||||
categories.sort((a,b) => a.localeCompare(b, "en", {sensitivity: "base"}))
|
||||
return categories
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an award to a team's score.
|
||||
*
|
||||
* Updates this.Timestamp to the award's timestamp.
|
||||
*
|
||||
* @param {Award} award
|
||||
*/
|
||||
Add(award) {
|
||||
this.Timestamp = award.Timestamp
|
||||
this.Categories.add(award.Category)
|
||||
this.TeamIDs.add(award.TeamID)
|
||||
|
||||
let teamPoints = (this.categoryTeamPoints[award.Category] ??= {})
|
||||
let points = (teamPoints[award.TeamID] || 0) + award.Points
|
||||
teamPoints[award.TeamID] = points
|
||||
|
||||
let max = this.MaxPoints[award.Category] || 0
|
||||
this.MaxPoints[award.Category] = Math.max(max, points)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a team's score within a category.
|
||||
*
|
||||
* @param {string} category
|
||||
* @param {string} teamID
|
||||
* @returns {number}
|
||||
*/
|
||||
GetPoints(category, teamID) {
|
||||
let teamPoints = this.categoryTeamPoints[category] || {}
|
||||
return teamPoints[teamID] || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a team's score in a category, using the Cyber Fire algorithm.
|
||||
*
|
||||
*@param {string} category
|
||||
* @param {string} teamID
|
||||
*/
|
||||
CyFiCategoryScore(category, teamID) {
|
||||
return this.GetPoints(category, teamID) / this.MaxPoints[category]
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a team's overall score, using the Cyber Fire algorithm.
|
||||
*
|
||||
*@param {string} category
|
||||
* @param {string} teamID
|
||||
* @returns {number}
|
||||
*/
|
||||
CyFiScore(teamID) {
|
||||
let score = 0
|
||||
for (let category of this.Categories) {
|
||||
score += this.CyFiCategoryScore(category, teamID)
|
||||
}
|
||||
return score
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MOTH instance state.
|
||||
*/
|
||||
class State {
|
||||
/**
|
||||
* @param {Server} server Server where we got this
|
||||
* @param {Object} obj Raw state data
|
||||
*/
|
||||
constructor(server, obj) {
|
||||
for (let key of ["Config", "TeamNames", "PointsLog"]) {
|
||||
if (!obj[key]) {
|
||||
throw(`Missing state property: ${key}`)
|
||||
}
|
||||
}
|
||||
this.server = server
|
||||
|
||||
/** Configuration */
|
||||
this.Config = {
|
||||
/** Is the server in development mode?
|
||||
* @type {boolean}
|
||||
*/
|
||||
Devel: obj.Config.Devel,
|
||||
}
|
||||
|
||||
/** Global messages, in HTML
|
||||
* @type {string}
|
||||
*/
|
||||
this.Messages = obj.Messages
|
||||
|
||||
/** Map from Team ID to Team Name
|
||||
* @type {Object.<string,string>}
|
||||
*/
|
||||
this.TeamNames = obj.TeamNames
|
||||
|
||||
/** Map from category name to puzzle point values
|
||||
* @type {Object.<string,number>}
|
||||
*/
|
||||
this.PointsByCategory = obj.Puzzles
|
||||
|
||||
/** Log of points awarded
|
||||
* @type {Award[]}
|
||||
*/
|
||||
this.PointsLog = obj.PointsLog.map(entry => new Award(entry[0], entry[1], entry[2], entry[3]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a sorted list of open category names
|
||||
*
|
||||
* @returns {string[]} List of categories
|
||||
*/
|
||||
Categories() {
|
||||
let ret = []
|
||||
for (let category in this.PointsByCategory) {
|
||||
ret.push(category)
|
||||
}
|
||||
ret.sort()
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a category contains unsolved puzzles.
|
||||
*
|
||||
* The server adds a puzzle with 0 points in every "solved" category,
|
||||
* so this just checks whether there is a 0-point puzzle in the category's point list.
|
||||
*
|
||||
* @param {string} category
|
||||
* @returns {boolean}
|
||||
*/
|
||||
ContainsUnsolved(category) {
|
||||
return !this.PointsByCategory[category].includes(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the server in development mode?
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
DevelopmentMode() {
|
||||
return this.Config && this.Config.Devel
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all open puzzles.
|
||||
*
|
||||
* The returned list will be sorted by (category, points).
|
||||
* If not categories are given, all puzzles will be returned.
|
||||
*
|
||||
* @param {string} categories Limit results to these categories
|
||||
* @returns {Puzzle[]}
|
||||
*/
|
||||
Puzzles(...categories) {
|
||||
if (categories.length == 0) {
|
||||
categories = this.Categories()
|
||||
}
|
||||
let ret = []
|
||||
for (let category of categories) {
|
||||
for (let points of this.PointsByCategory[category]) {
|
||||
if (0 == points) {
|
||||
// This means all potential puzzles in the category are open
|
||||
continue
|
||||
}
|
||||
let p = new Puzzle(this.server, category, points)
|
||||
ret.push(p)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this puzzle been solved by this team?
|
||||
*
|
||||
* @param {Puzzle} puzzle
|
||||
* @param {string} teamID Team to check, default the logged-in team
|
||||
* @returns {boolean}
|
||||
*/
|
||||
IsSolved(puzzle, teamID="self") {
|
||||
for (let award of this.PointsLog) {
|
||||
if (
|
||||
(award.Category == puzzle.Category)
|
||||
&& (award.Points == puzzle.Points)
|
||||
&& (award.TeamID == teamID)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay scores.
|
||||
*
|
||||
* MOTH has no notion of who is "winning", we consider this a user interface
|
||||
* decision. There are lots of interesting options: see
|
||||
* [scoring]{@link ../docs/scoring.md} for more.
|
||||
*
|
||||
* @yields {Scores} Snapshot at a point in time
|
||||
*/
|
||||
* ScoresHistory() {
|
||||
let scores = new Scores()
|
||||
for (let award of this.PointsLog) {
|
||||
scores.Add(award)
|
||||
yield scores
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the current scores.
|
||||
*
|
||||
* @returns {Scores}
|
||||
*/
|
||||
CurrentScores() {
|
||||
let scores
|
||||
for (scores of this.ScoreHistory());
|
||||
return scores
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A MOTH Server interface.
|
||||
*
|
||||
* This uses localStorage to remember Team ID,
|
||||
* and will send a Team ID with every request, if it can find one.
|
||||
*/
|
||||
class Server {
|
||||
/**
|
||||
* @param {string | URL} baseUrl Base URL to server, for constructing API URLs
|
||||
*/
|
||||
constructor(baseUrl) {
|
||||
if (!baseUrl) {
|
||||
throw("Must provide baseURL")
|
||||
}
|
||||
this.baseUrl = new URL(baseUrl, location)
|
||||
this.teamIDKey = this.baseUrl.toString() + " teamID"
|
||||
this.TeamID = localStorage[this.teamIDKey]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a MOTH resource.
|
||||
*
|
||||
* If anything other than a 2xx code is returned,
|
||||
* this function throws an error.
|
||||
*
|
||||
* This always sends teamID.
|
||||
* If args is set, POST will be used instead of GET
|
||||
*
|
||||
* @param {string} path Path to API endpoint
|
||||
* @param {Object.<string,string>} args Key/Values to send in POST data
|
||||
* @returns {Promise.<Response>} Response
|
||||
*/
|
||||
fetch(path, args={}) {
|
||||
let body = new URLSearchParams(args)
|
||||
if (this.TeamID && !body.has("id")) {
|
||||
body.set("id", this.TeamID)
|
||||
}
|
||||
|
||||
let url = new URL(path, this.baseUrl)
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
body,
|
||||
cache: "no-cache",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to a JSend API endpoint.
|
||||
*
|
||||
* @param {string} path Path to API endpoint
|
||||
* @param {Object.<string,string>} args Key/Values to send in POST
|
||||
* @returns {Promise.<Object>} JSend Data
|
||||
*/
|
||||
async call(path, args={}) {
|
||||
let resp = await this.fetch(path, args)
|
||||
let obj = await resp.json()
|
||||
switch (obj.status) {
|
||||
case "success":
|
||||
return obj.data
|
||||
case "fail":
|
||||
throw new Error(obj.data.description || obj.data.short || obj.data)
|
||||
case "error":
|
||||
throw new Error(obj.message)
|
||||
default:
|
||||
throw new Error(`Unknown JSend status: ${obj.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a new URL for the given resource.
|
||||
*
|
||||
* The returned URL instance will be absolute, and immune to changes to the
|
||||
* page that would affect relative URLs.
|
||||
*
|
||||
* @returns {URL}
|
||||
*/
|
||||
URL(url) {
|
||||
return new URL(url, this.baseUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Are we logged in to the server?
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
LoggedIn() {
|
||||
return this.TeamID ? true : false
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget about any previous Team ID.
|
||||
*
|
||||
* This is equivalent to logging out.
|
||||
*/
|
||||
Reset() {
|
||||
localStorage.removeItem(this.teamIDKey)
|
||||
this.TeamID = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current contest state.
|
||||
*
|
||||
* @returns {Promise.<State>}
|
||||
*/
|
||||
async GetState() {
|
||||
let resp = await this.fetch("/state")
|
||||
let obj = await resp.json()
|
||||
return new State(this, obj)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in to a team.
|
||||
*
|
||||
* This calls the server's registration endpoint; if the call succeds, or
|
||||
* fails with "team already exists", the login is returned as successful.
|
||||
*
|
||||
* @param {string} teamID
|
||||
* @param {string} teamName
|
||||
* @returns {Promise.<string>} Success message from server
|
||||
*/
|
||||
async Login(teamID, teamName) {
|
||||
let data = await this.call("/register", {id: teamID, name: teamName})
|
||||
this.TeamID = teamID
|
||||
this.TeamName = teamName
|
||||
localStorage[this.teamIDKey] = teamID
|
||||
return data.description || data.short
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a proposed answer for points.
|
||||
*
|
||||
* The returned promise will fail if anything goes wrong, including the
|
||||
* proposed answer being rejected.
|
||||
*
|
||||
* @param {string} category Category of puzzle
|
||||
* @param {number} points Point value of puzzle
|
||||
* @param {string} proposed Answer to submit
|
||||
* @returns {Promise.<string>} Success message
|
||||
*/
|
||||
async SubmitAnswer(category, points, proposed) {
|
||||
let data = await this.call("/answer", {
|
||||
cat: category,
|
||||
points,
|
||||
answer: proposed,
|
||||
})
|
||||
return data.description || data.short
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a file associated with a puzzle.
|
||||
*
|
||||
* @param {string} category Category of puzzle
|
||||
* @param {number} points Point value of puzzle
|
||||
* @param {string} filename
|
||||
* @returns {Promise.<Response>}
|
||||
*/
|
||||
GetContent(category, points, filename) {
|
||||
return this.fetch(`/content/${category}/${points}/${filename}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a Puzzle object.
|
||||
*
|
||||
* New Puzzle objects only know their category and point value.
|
||||
* See docstrings on the Puzzle object for more information.
|
||||
*
|
||||
* @param {string} category
|
||||
* @param {number} points
|
||||
* @returns {Puzzle}
|
||||
*/
|
||||
GetPuzzle(category, points) {
|
||||
return new Puzzle(this, category, points)
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
Hash,
|
||||
Server,
|
||||
}
|
|
@ -1,34 +1,37 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html>
|
||||
<head>
|
||||
<title>Puzzle</title>
|
||||
<link rel="stylesheet" href="basic.css">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta charset="utf-8">
|
||||
<link rel="icon" href="luna-moth.svg">
|
||||
<link rel="stylesheet" href="basic.css">
|
||||
<script src="background.mjs" type="module" async></script>
|
||||
<script src="puzzle.mjs" type="module" async></script>
|
||||
<script src="puzzle.js"></script>
|
||||
<script>
|
||||
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="title">[loading]</h1>
|
||||
<main>
|
||||
<section id="puzzle">
|
||||
<p class="notification">
|
||||
Starting script...
|
||||
</p>
|
||||
</section>
|
||||
<section class="meta"></section>
|
||||
<ul id="files"></ul>
|
||||
<p>Puzzle by <span id="authors">[loading]</span></p>
|
||||
</section>
|
||||
<form class="answer">
|
||||
<label for="answer">Answer:</label>
|
||||
<input type="text" name="answer" id="answer"> <span class="answer_ok"></span>
|
||||
<br>
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
</main>
|
||||
<div class="debug" class="notification"></div>
|
||||
<div class="toasts"></div>
|
||||
<h1>Puzzle</h1>
|
||||
<section>
|
||||
<div id="puzzle"><span class="spinner"></span></div>
|
||||
<ul id="files"></ul>
|
||||
<p>Puzzle by <span id="authors"></span></p>
|
||||
</section>
|
||||
<div id="messages"></div>
|
||||
<form>
|
||||
<input type="hidden" name="cat">
|
||||
<input type="hidden" name="points">
|
||||
<input type="hidden" name="xAnswer">
|
||||
Team ID: <input type="text" name="id"> <br>
|
||||
Answer: <input type="text" name="answer" id="answer"> <span id="answer_ok"></span><br>
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
<div id="devel"></div>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="index.html">Puzzles</a></li>
|
||||
<li><a href="scoreboard.html">Scoreboard</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -0,0 +1,225 @@
|
|||
// jshint asi:true
|
||||
|
||||
// prettify adds classes to various types, returning an HTML string.
|
||||
function prettify(key, val) {
|
||||
switch (key) {
|
||||
case "Body":
|
||||
return '[HTML]'
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// devel_addin drops a bunch of development extensions into element e.
|
||||
// It will only modify stuff inside e.
|
||||
function devel_addin(e) {
|
||||
let h = e.appendChild(document.createElement("h2"))
|
||||
h.textContent = "Developer Output"
|
||||
|
||||
let log = window.puzzle.Debug.Log || []
|
||||
if (log.length > 0) {
|
||||
e.appendChild(document.createElement("h3")).textContent = "Log"
|
||||
let le = e.appendChild(document.createElement("ul"))
|
||||
for (entry of log) {
|
||||
le.appendChild(document.createElement("li")).textContent = entry
|
||||
}
|
||||
}
|
||||
|
||||
e.appendChild(document.createElement("h3")).textContent = "Puzzle object"
|
||||
|
||||
let hobj = JSON.stringify(window.puzzle, prettify, 2)
|
||||
let d = e.appendChild(document.createElement("pre"))
|
||||
d.classList.add("object")
|
||||
d.innerHTML = hobj
|
||||
|
||||
e.appendChild(document.createElement("p")).textContent = "This debugging information will not be available to participants."
|
||||
}
|
||||
|
||||
// Hash routine used in v3.4 and earlier
|
||||
function djb2hash(buf) {
|
||||
let h = 5381
|
||||
for (let c of (new TextEncoder()).encode(buf)) { // Encode as UTF-8 and read in each byte
|
||||
// JavaScript converts everything to a signed 32-bit integer when you do bitwise operations.
|
||||
// So we have to do "unsigned right shift" by zero to get it back to unsigned.
|
||||
h = (((h * 33) + c) & 0xffffffff) >>> 0
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// The routine used to hash answers in compiled puzzle packages
|
||||
async function sha256Hash(message) {
|
||||
const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); // hash the message
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string
|
||||
return hashHex;
|
||||
}
|
||||
|
||||
// Is the provided answer possibly correct?
|
||||
async function checkAnswer(answer) {
|
||||
let answerHashes = []
|
||||
answerHashes.push(djb2hash(answer))
|
||||
answerHashes.push(await sha256Hash(answer))
|
||||
|
||||
for (let hash of answerHashes) {
|
||||
for (let correctHash of window.puzzle.AnswerHashes) {
|
||||
if (hash == correctHash) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Pop up a message
|
||||
function toast(message, timeout=5000) {
|
||||
let p = document.createElement("p")
|
||||
|
||||
p.innerText = message
|
||||
document.getElementById("messages").appendChild(p)
|
||||
setTimeout(
|
||||
e => { p.remove() },
|
||||
timeout
|
||||
)
|
||||
}
|
||||
|
||||
// When the user submits an answer
|
||||
function submit(e) {
|
||||
e.preventDefault()
|
||||
let data = new FormData(e.target)
|
||||
|
||||
window.data = data
|
||||
fetch("answer", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
})
|
||||
.then(resp => {
|
||||
if (resp.ok) {
|
||||
resp.json()
|
||||
.then(obj => {
|
||||
toast(obj.data.description)
|
||||
})
|
||||
} else {
|
||||
toast("Error submitting your answer. Try again in a few seconds.")
|
||||
console.log(resp)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
toast("Error submitting your answer. Try again in a few seconds.")
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
|
||||
async function loadPuzzle(categoryName, points, puzzleId) {
|
||||
let puzzle = document.getElementById("puzzle")
|
||||
let base = "content/" + categoryName + "/" + puzzleId + "/"
|
||||
|
||||
let resp = await fetch(base + "puzzle.json")
|
||||
if (! resp.ok) {
|
||||
console.log(resp)
|
||||
let err = await resp.text()
|
||||
Array.from(puzzle.childNodes).map(e => e.remove())
|
||||
p = puzzle.appendChild(document.createElement("p"))
|
||||
p.classList.add("Error")
|
||||
p.textContent = err
|
||||
return
|
||||
}
|
||||
|
||||
// Make the whole puzzle available
|
||||
window.puzzle = await resp.json()
|
||||
|
||||
// Populate authors
|
||||
document.getElementById("authors").textContent = window.puzzle.Authors.join(", ")
|
||||
|
||||
// If answers are provided, this is the devel server
|
||||
if (window.puzzle.Answers.length > 0) {
|
||||
devel_addin(document.getElementById("devel"))
|
||||
}
|
||||
|
||||
// Load scripts
|
||||
for (let script of (window.puzzle.Scripts || [])) {
|
||||
let st = document.createElement("script")
|
||||
document.head.appendChild(st)
|
||||
st.src = base + script
|
||||
}
|
||||
|
||||
// List associated files
|
||||
for (let fn of (window.puzzle.Attachments || [])) {
|
||||
let li = document.createElement("li")
|
||||
let a = document.createElement("a")
|
||||
a.href = base + fn
|
||||
a.innerText = fn
|
||||
li.appendChild(a)
|
||||
document.getElementById("files").appendChild(li)
|
||||
}
|
||||
|
||||
// Prefix `base` to relative URLs in the puzzle body
|
||||
let doc = new DOMParser().parseFromString(window.puzzle.Body, "text/html")
|
||||
for (let se of doc.querySelectorAll("[src],[href]")) {
|
||||
se.outerHTML = se.outerHTML.replace(/(src|href)="([^/]+)"/i, "$1=\"" + base + "$2\"")
|
||||
}
|
||||
|
||||
// If a validation pattern was provided, set that
|
||||
if (window.puzzle.AnswerPattern) {
|
||||
document.querySelector("#answer").pattern = window.puzzle.AnswerPattern
|
||||
}
|
||||
|
||||
// Replace puzzle children with what's in `doc`
|
||||
Array.from(puzzle.childNodes).map(e => e.remove())
|
||||
Array.from(doc.body.childNodes).map(e => puzzle.appendChild(e))
|
||||
|
||||
document.title = categoryName + " " + points
|
||||
document.querySelector("body > h1").innerText = document.title
|
||||
document.querySelector("input[name=cat]").value = categoryName
|
||||
document.querySelector("input[name=points]").value = points
|
||||
}
|
||||
|
||||
// Check to see if the answer might be correct
|
||||
// This might be better done with the "constraint validation API"
|
||||
// https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Form_validation#Validating_forms_using_JavaScript
|
||||
function answerCheck(e) {
|
||||
let answer = e.target.value
|
||||
let ok = document.querySelector("#answer_ok")
|
||||
|
||||
// You have to provide someplace to put the check
|
||||
if (! ok) {
|
||||
return
|
||||
}
|
||||
|
||||
checkAnswer(answer)
|
||||
.then (correct => {
|
||||
if (correct) {
|
||||
ok.textContent = "⭕"
|
||||
ok.title = "Possibly correct"
|
||||
} else {
|
||||
ok.textContent = "❌"
|
||||
ok.title = "Definitely not correct"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function init() {
|
||||
let params = new URLSearchParams(window.location.search)
|
||||
let categoryName = params.get("cat")
|
||||
let points = params.get("points")
|
||||
let puzzleId = params.get("pid")
|
||||
|
||||
if (categoryName && points) {
|
||||
loadPuzzle(categoryName, points, puzzleId || points)
|
||||
}
|
||||
|
||||
let teamId = sessionStorage.getItem("id")
|
||||
if (teamId) {
|
||||
document.querySelector("input[name=id]").value = teamId
|
||||
}
|
||||
|
||||
if (document.querySelector("#answer")) {
|
||||
document.querySelector("#answer").addEventListener("input", answerCheck)
|
||||
}
|
||||
document.querySelector("form").addEventListener("submit", submit)
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init)
|
||||
} else {
|
||||
init()
|
||||
}
|
239
theme/puzzle.mjs
239
theme/puzzle.mjs
|
@ -1,239 +0,0 @@
|
|||
/**
|
||||
* Functionality for puzzle.html (Puzzle display / answer form)
|
||||
*/
|
||||
import * as moth from "./moth.mjs"
|
||||
import * as common from "./common.mjs"
|
||||
|
||||
const server = new moth.Server(".")
|
||||
|
||||
/**
|
||||
* Handle a submit event on a form.
|
||||
*
|
||||
* Called when the user submits the form,
|
||||
* either by clicking a "submit" button,
|
||||
* or by some other means provided by the browser,
|
||||
* like hitting the Enter key.
|
||||
*
|
||||
* @param {Event} event
|
||||
*/
|
||||
async function formSubmitHandler(event) {
|
||||
event.preventDefault()
|
||||
let data = new FormData(event.target)
|
||||
let proposed = data.get("answer")
|
||||
let message
|
||||
|
||||
console.groupCollapsed("Submit answer")
|
||||
console.info(`Proposed answer: ${proposed}`)
|
||||
try {
|
||||
message = await window.app.puzzle.SubmitAnswer(proposed)
|
||||
common.Toast(message)
|
||||
}
|
||||
catch (err) {
|
||||
common.Toast(err)
|
||||
}
|
||||
console.groupEnd("Submit answer")
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an input event on the answer field.
|
||||
*
|
||||
* @param {Event} event
|
||||
*/
|
||||
async function answerInputHandler(event) {
|
||||
let answer = event.target.value
|
||||
let correct = await window.app.puzzle.IsPossiblyCorrect(answer)
|
||||
for (let ok of event.target.parentElement.querySelectorAll(".answer_ok")) {
|
||||
if (correct) {
|
||||
ok.textContent = "⭕"
|
||||
ok.title = "Possibly correct"
|
||||
} else {
|
||||
ok.textContent = "❌"
|
||||
ok.title = "Definitely not correct"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the puzzle content element, possibly with everything cleared out of it.
|
||||
*
|
||||
* @param {boolean} clear Should the element be cleared of children? Default true.
|
||||
* @returns {Element}
|
||||
*/
|
||||
function puzzleElement(clear=true) {
|
||||
let e = document.querySelector("#puzzle")
|
||||
if (clear) {
|
||||
while (e.firstChild) e.firstChild.remove()
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
/**
|
||||
* Display an error in the puzzle area, and also send it to the console.
|
||||
*
|
||||
* Errors are rendered in the puzzle area, so the user can see a bit more about
|
||||
* what the problem is.
|
||||
*
|
||||
* @param {string} error
|
||||
*/
|
||||
function error(error) {
|
||||
console.error(error)
|
||||
let e = puzzleElement().appendChild(document.createElement("pre"))
|
||||
e.classList.add("error")
|
||||
e.textContent = error.Body || error
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the answer and invoke input handlers.
|
||||
*
|
||||
* Makes sure the Circle Of Success gets updated.
|
||||
*
|
||||
* @param {string} s
|
||||
*/
|
||||
function SetAnswer(s) {
|
||||
let e = document.querySelector("#answer")
|
||||
e.value = s
|
||||
e.dispatchEvent(new Event("input"))
|
||||
}
|
||||
|
||||
function writeObject(e, obj) {
|
||||
let keys = Object.keys(obj)
|
||||
keys.sort()
|
||||
for (let key of keys) {
|
||||
let val = obj[key]
|
||||
if ((key === "Body") || (!val) || (val.length === 0)) {
|
||||
continue
|
||||
}
|
||||
|
||||
let d = e.appendChild(document.createElement("dt"))
|
||||
d.textContent = key
|
||||
|
||||
let t = e.appendChild(document.createElement("dd"))
|
||||
if (Array.isArray(val)) {
|
||||
let vi = t.appendChild(document.createElement("ul"))
|
||||
vi.multiple = true
|
||||
for (let a of val) {
|
||||
let opt = vi.appendChild(document.createElement("li"))
|
||||
opt.textContent = a
|
||||
}
|
||||
} else if (typeof(val) === "object") {
|
||||
writeObject(t, val)
|
||||
} else {
|
||||
t.textContent = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the given puzzle.
|
||||
*
|
||||
* @param {string} category
|
||||
* @param {number} points
|
||||
*/
|
||||
async function loadPuzzle(category, points) {
|
||||
console.groupCollapsed("Loading puzzle:", category, points)
|
||||
let contentBase = new URL(`content/${category}/${points}/`, common.BaseURL)
|
||||
|
||||
// Tell user we're loading
|
||||
puzzleElement().appendChild(document.createElement("progress"))
|
||||
for (let qs of ["#authors", "#title", "title"]) {
|
||||
for (let e of document.querySelectorAll(qs)) {
|
||||
e.textContent = "[loading]"
|
||||
}
|
||||
}
|
||||
|
||||
let puzzle = server.GetPuzzle(category, points)
|
||||
|
||||
console.time("Populate")
|
||||
try {
|
||||
await puzzle.Populate()
|
||||
}
|
||||
catch {
|
||||
let error = puzzleElement().appendChild(document.createElement("pre"))
|
||||
error.classList.add("notification", "error")
|
||||
error.textContent = puzzle.Error.Body
|
||||
return
|
||||
}
|
||||
finally {
|
||||
console.timeEnd("Populate")
|
||||
}
|
||||
|
||||
console.info(`Setting base tag to ${contentBase}`)
|
||||
let baseElement = document.head.appendChild(document.createElement("base"))
|
||||
baseElement.href = contentBase
|
||||
|
||||
console.info("Tweaking HTML...")
|
||||
let title = `${category} ${points}`
|
||||
document.querySelector("title").textContent = title
|
||||
document.querySelector("#title").textContent = title
|
||||
document.querySelector("#authors").textContent = puzzle.Authors.join(", ")
|
||||
if (puzzle.AnswerPattern) {
|
||||
document.querySelector("#answer").pattern = puzzle.AnswerPattern
|
||||
}
|
||||
puzzleElement().innerHTML = puzzle.Body
|
||||
|
||||
console.info("Adding attached scripts...")
|
||||
for (let script of (puzzle.Scripts || [])) {
|
||||
let st = document.createElement("script")
|
||||
document.head.appendChild(st)
|
||||
st.src = new URL(script, contentBase)
|
||||
}
|
||||
|
||||
console.info("Listing attached files...")
|
||||
for (let fn of (puzzle.Attachments || [])) {
|
||||
let li = document.createElement("li")
|
||||
let a = document.createElement("a")
|
||||
a.href = new URL(fn, contentBase)
|
||||
a.innerText = fn
|
||||
li.appendChild(a)
|
||||
document.getElementById("files").appendChild(li)
|
||||
}
|
||||
|
||||
|
||||
console.info("Filling debug information...")
|
||||
for (let e of document.querySelectorAll(".debug")) {
|
||||
if (puzzle.Answers.length > 0) {
|
||||
writeObject(e, puzzle)
|
||||
} else {
|
||||
e.classList.add("hidden")
|
||||
}
|
||||
}
|
||||
|
||||
window.app.puzzle = puzzle
|
||||
console.info("window.app.puzzle =", window.app.puzzle)
|
||||
|
||||
console.groupEnd()
|
||||
|
||||
return puzzle
|
||||
}
|
||||
|
||||
async function init() {
|
||||
window.app = {}
|
||||
window.setanswer = (str => SetAnswer(str))
|
||||
|
||||
for (let form of document.querySelectorAll("form.answer")) {
|
||||
form.addEventListener("submit", formSubmitHandler)
|
||||
for (let e of form.querySelectorAll("[name=answer]")) {
|
||||
e.addEventListener("input", answerInputHandler)
|
||||
}
|
||||
}
|
||||
// There isn't a more graceful way to "unload" scripts attached to the current puzzle
|
||||
window.addEventListener("hashchange", () => location.reload())
|
||||
|
||||
// Make all links absolute, because we're going to be changing the base URL
|
||||
for (let e of document.querySelectorAll("[href]")) {
|
||||
e.href = new URL(e.href, common.BaseURL)
|
||||
}
|
||||
|
||||
let hashpart = location.hash.split("#")[1] || ""
|
||||
let catpoints = hashpart.split(":")
|
||||
let category = catpoints[0]
|
||||
let points = Number(catpoints[1])
|
||||
if (!category && !points) {
|
||||
error(`Doesn't look like a puzzle reference: ${hashpart}`)
|
||||
return
|
||||
}
|
||||
|
||||
window.app.puzzle = await loadPuzzle(category, points)
|
||||
}
|
||||
|
||||
common.WhenDOMLoaded(init)
|
File diff suppressed because it is too large
Load Diff
|
@ -1,55 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>KSA Report</title>
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta charset="utf-8">
|
||||
<script src="ksa.mjs" type="module" async></script>
|
||||
<script src="../background.mjs" type="module" async></script>
|
||||
<link rel="stylesheet" href="../basic.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>KSA Report</h1>
|
||||
<main>
|
||||
<p>
|
||||
This report shows all KSAs covered by this server so far.
|
||||
This is not a report on your progress, but rather
|
||||
what you would have covered if you had worked every exercise available.
|
||||
</p>
|
||||
|
||||
<div class="notification">
|
||||
<p class="doing"></p>
|
||||
<progress class="doing"></progress>
|
||||
</div>
|
||||
|
||||
<h2>All KSAs across all content</h2>
|
||||
<ul class="allKSAs"></ul>
|
||||
|
||||
<h2>All KSAs by Category</h2>
|
||||
<div class="KSAsByCategory">
|
||||
</div>
|
||||
|
||||
<h2>KSAs by Puzzle</h2>
|
||||
<table class="puzzles">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Points</th>
|
||||
<th>KSAs</th>
|
||||
<th>Errors</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template id="puzzlerow">
|
||||
<tr>
|
||||
<td class="category"></td>
|
||||
<td class="points"></td>
|
||||
<td class="ksas"></td>
|
||||
<td><pre class="error"></pre></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
|
@ -1,140 +0,0 @@
|
|||
import * as moth from "../moth.mjs"
|
||||
import * as common from "../common.mjs"
|
||||
|
||||
const server = new moth.Server("../")
|
||||
|
||||
/**
|
||||
* Update "doing" indicators
|
||||
*
|
||||
* @param {String | null} what Text to display, or null to not update text
|
||||
* @param {Number | null} finished Percentage complete to display, or null to not update progress
|
||||
*/
|
||||
function doing(what, finished = null) {
|
||||
for (let e of document.querySelectorAll(".doing")) {
|
||||
e.classList.remove("hidden")
|
||||
if (what) {
|
||||
e.textContent = what
|
||||
}
|
||||
if (finished) {
|
||||
e.value = finished
|
||||
} else {
|
||||
e.removeAttribute("value")
|
||||
}
|
||||
}
|
||||
}
|
||||
function done() {
|
||||
for (let e of document.querySelectorAll(".doing")) {
|
||||
e.classList.add("hidden")
|
||||
}
|
||||
}
|
||||
|
||||
async function GetNice() {
|
||||
let NiceElementsByIdentifier = {}
|
||||
let resp = await fetch("NICEFramework2017.json")
|
||||
let obj = await resp.json()
|
||||
for (let e of obj.elements) {
|
||||
NiceElementsByIdentifier[e.element_identifier] = e
|
||||
}
|
||||
return NiceElementsByIdentifier
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a puzzle, and fill its KSAs and rows.
|
||||
*
|
||||
* This is done once per puzzle, in an asynchronous function, allowing the
|
||||
* application to perform multiple blocking operations simultaneously.
|
||||
*/
|
||||
async function FetchAndFill(puzzle, KSAs, rows) {
|
||||
try {
|
||||
await puzzle.Populate()
|
||||
}
|
||||
catch (error) {
|
||||
// Keep on going with whatever Populate was able to fill
|
||||
}
|
||||
for (let KSA of (puzzle.KSAs || [])) {
|
||||
KSAs.add(KSA)
|
||||
}
|
||||
|
||||
for (let row of rows) {
|
||||
row.querySelector(".category").textContent = puzzle.Category
|
||||
row.querySelector(".points").textContent = puzzle.Points
|
||||
row.querySelector(".ksas").textContent = (puzzle.KSAs || []).join(" ")
|
||||
row.querySelector(".error").textContent = puzzle.Error.Body
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
doing("Fetching NICE framework data")
|
||||
let nicePromise = GetNice()
|
||||
|
||||
doing("Retrieving server state")
|
||||
let state = await server.GetState()
|
||||
|
||||
doing("Retrieving all puzzles")
|
||||
let KSAsByCategory = {}
|
||||
let puzzlerowTemplate = document.querySelector("template#puzzlerow")
|
||||
let puzzles = state.Puzzles()
|
||||
let promises = []
|
||||
for (let category of state.Categories()) {
|
||||
KSAsByCategory[category] = new Set()
|
||||
}
|
||||
let pending = puzzles.length
|
||||
for (let puzzle of puzzles) {
|
||||
// Make space in the table, so everything fills in sorted order
|
||||
let rows = []
|
||||
for (let tbody of document.querySelectorAll("tbody")) {
|
||||
let row = puzzlerowTemplate.content.cloneNode(true).firstElementChild
|
||||
tbody.appendChild(row)
|
||||
rows.push(row)
|
||||
}
|
||||
|
||||
// Queue up a fetch, and update progress bar
|
||||
let promise = FetchAndFill(puzzle, KSAsByCategory[puzzle.Category], rows)
|
||||
promises.push(promise)
|
||||
promise.then(() => doing(null, 1 - (--pending / puzzles.length)))
|
||||
|
||||
if (promises.length > 50) {
|
||||
// Chrome runs out of resources if you queue up too many of these at once
|
||||
await Promise.all(promises)
|
||||
promises = []
|
||||
}
|
||||
}
|
||||
await Promise.all(promises)
|
||||
|
||||
doing("Retrieving NICE identifiers")
|
||||
let NiceElementsByIdentifier = await nicePromise
|
||||
|
||||
|
||||
doing("Filling KSAs By Category")
|
||||
let allKSAs = new Set()
|
||||
for (let div of document.querySelectorAll(".KSAsByCategory")) {
|
||||
for (let category of state.Categories()) {
|
||||
doing(`Filling KSAs for category: ${category}`)
|
||||
let KSAs = [...KSAsByCategory[category]]
|
||||
KSAs.sort()
|
||||
|
||||
div.appendChild(document.createElement("h3")).textContent = category
|
||||
let ul = div.appendChild(document.createElement("ul"))
|
||||
for (let k of KSAs) {
|
||||
let ksa = k.split(/\s+/)[0]
|
||||
let ne = NiceElementsByIdentifier[ksa] || { text: "???" }
|
||||
let text = `${ksa}: ${ne.text}`
|
||||
ul.appendChild(document.createElement("li")).textContent = text
|
||||
allKSAs.add(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doing("Filling KSAs")
|
||||
for (let e of document.querySelectorAll(".allKSAs")) {
|
||||
let KSAs = [...allKSAs]
|
||||
KSAs.sort()
|
||||
for (let text of KSAs) {
|
||||
e.appendChild(document.createElement("li")).textContent = text
|
||||
}
|
||||
}
|
||||
|
||||
done()
|
||||
}
|
||||
|
||||
common.WhenDOMLoaded(init)
|
|
@ -1,79 +0,0 @@
|
|||
/* GHC displays: 1024x1820 */
|
||||
@media screen and (max-aspect-ratio: 4/5) and (min-height: 1600px) {
|
||||
html {
|
||||
font-size: 20pt;
|
||||
}
|
||||
}
|
||||
|
||||
.location {
|
||||
color: #acf;
|
||||
background-color: #0008;
|
||||
position: fixed;
|
||||
right: 30vw;
|
||||
bottom: 0;
|
||||
padding: 1em;
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight:bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/** Scoreboard */
|
||||
#rankings {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background-color: #000c;
|
||||
}
|
||||
#rankings div {
|
||||
height: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
#rankings div:nth-child(6n){
|
||||
background-color: #ccc3;
|
||||
}
|
||||
#rankings div:nth-child(6n+3) {
|
||||
background-color: #0f03;
|
||||
}
|
||||
|
||||
#rankings span {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
}
|
||||
#rankings span.category {
|
||||
font-size: 80%;
|
||||
}
|
||||
#rankings span.teamname {
|
||||
height: auto;
|
||||
font-size: inherit;
|
||||
color: white;
|
||||
background-color: #000e;
|
||||
border-radius: 3px;
|
||||
position: absolute;
|
||||
right: 0.2em;
|
||||
}
|
||||
#rankings span.teamname:hover,
|
||||
#rankings span.category:hover {
|
||||
width: inherit;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 450px) {
|
||||
#rankings span.teamname {
|
||||
max-width: 6em;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
span.teampoints {
|
||||
max-width: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
#rankings div * {white-space: nowrap;}
|
||||
.cat0, .cat8, .cat16 {background-color: #a6cee3; color: black;}
|
||||
.cat1, .cat9, .cat17 {background-color: #1f78b4; color: white;}
|
||||
.cat2, .cat10, .cat18 {background-color: #b2df8a; color: black;}
|
||||
.cat3, .cat11, .cat19 {background-color: #33a02c; color: white;}
|
||||
.cat4, .cat12, .cat20 {background-color: #fb9a99; color: black;}
|
||||
.cat5, .cat13, .cat21 {background-color: #e31a1c; color: white;}
|
||||
.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;}
|
||||
.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;}
|
|
@ -3,15 +3,22 @@
|
|||
<head>
|
||||
<title>Scoreboard</title>
|
||||
<link rel="stylesheet" href="basic.css">
|
||||
<link rel="stylesheet" href="scoreboard.css">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<script src="https://cdn.jsdelivr.net/npm/luxon@1.26.0"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.0.2"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@0.2.1"></script>
|
||||
<script type="module" src="scoreboard.mjs"></script>
|
||||
<script src="moment.min.js" async></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" async></script>
|
||||
<script src="scoreboard.js" async></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="rankings"></div>
|
||||
<div class="location"></div>
|
||||
<body class="wide">
|
||||
<h4 id="location"></h4>
|
||||
<section class="rotate">
|
||||
<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>
|
||||
</html>
|
||||
|
|
|
@ -0,0 +1,257 @@
|
|||
// 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()
|
||||
}
|
|
@ -1,98 +0,0 @@
|
|||
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 teamname = row.appendChild(document.createElement("span"))
|
||||
teamname.textContent = teamName
|
||||
teamname.classList.add("teamname")
|
||||
|
||||
let categoryNumber = 0
|
||||
let teampoints = row.appendChild(document.createElement("span"))
|
||||
teampoints.classList.add("teampoints")
|
||||
for (let category of scores.Categories) {
|
||||
let score = scores.CyFiCategoryScore(category, teamID)
|
||||
if (!score) {
|
||||
continue
|
||||
}
|
||||
|
||||
let block = teampoints.appendChild(document.createElement("span"))
|
||||
let points = scores.GetPoints(category, teamID)
|
||||
let width = MaxScoreWidth * score / topScore
|
||||
|
||||
block.textContent = category
|
||||
block.title = `${points} points`
|
||||
block.style.width = `${width}%`
|
||||
block.classList.add("category", `cat${categoryNumber}`)
|
||||
|
||||
categoryNumber += 1
|
||||
}
|
||||
}
|
||||
await sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
setInterval(update, common.Minute)
|
||||
update()
|
||||
}
|
||||
|
||||
common.WhenDOMLoaded(init)
|
|
@ -1,29 +1,45 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html>
|
||||
<head>
|
||||
<title>Redeem Token</title>
|
||||
<link rel="stylesheet" href="basic.css">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<script src="token.mjs" type="module" async></script>
|
||||
<script src="puzzle.js"></script>
|
||||
<script>
|
||||
function tokenInput(e) {
|
||||
let vals = e.target.value.split(":")
|
||||
document.querySelector("input[name=cat]").value = vals[0]
|
||||
document.querySelector("input[name=points]").value = vals[1]
|
||||
document.querySelector("input[name=answer]").value = vals[2]
|
||||
}
|
||||
|
||||
function tokenInit() {
|
||||
document.querySelector("input[name=token]").addEventListener("input", tokenInput)
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", tokenInit)
|
||||
} else {
|
||||
tokenInit()
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Redeem Token</h1>
|
||||
<main>
|
||||
<p>
|
||||
Have you found a token?
|
||||
</p>
|
||||
<p></p>
|
||||
Tokens look like
|
||||
<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>
|
||||
<div id="messages"></div>
|
||||
<form id="tokenForm">
|
||||
<input type="hidden" name="cat">
|
||||
<input type="hidden" name="points">
|
||||
<input type="hidden" name="answer">
|
||||
Team ID: <input type="text" name="id"> <br>
|
||||
Token: <input type="text" name="token"> <br>
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
<div class="toasts"></div>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="puzzle-list.html">Puzzles</a></li>
|
||||
<li><a href="scoreboard.html">Scoreboard</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,48 +0,0 @@
|
|||
/**
|
||||
* Functionality for token.html
|
||||
*/
|
||||
import * as moth from "./moth.mjs"
|
||||
import * as common from "./common.mjs"
|
||||
|
||||
const server = new moth.Server(".")
|
||||
|
||||
/**
|
||||
* Handle a submit event on a form.
|
||||
*
|
||||
* @param {SubmitEvent} event
|
||||
*/
|
||||
async function formSubmitHandler(event) {
|
||||
event.preventDefault()
|
||||
|
||||
let formData = new FormData(event.target)
|
||||
let token = formData.get("token")
|
||||
let vals = token.split(":")
|
||||
let category = vals[0]
|
||||
let points = Number(vals[1])
|
||||
let proposed = vals[2]
|
||||
if (!category || !points || !proposed) {
|
||||
console.info("Not a token:", vals)
|
||||
common.Toast("This is not a properly-formed token")
|
||||
return
|
||||
}
|
||||
try {
|
||||
let message = await server.SubmitAnswer(category, points, proposed)
|
||||
common.Toast(message)
|
||||
}
|
||||
catch (error) {
|
||||
if (error.message == "incorrect answer") {
|
||||
common.Toast("Unknown token")
|
||||
} else {
|
||||
console.error(error)
|
||||
common.Toast(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
for (let form of document.querySelectorAll("form.token")) {
|
||||
form.addEventListener("submit", formSubmitHandler)
|
||||
}
|
||||
}
|
||||
|
||||
common.WhenDOMLoaded(init)
|
Loading…
Reference in New Issue