Merge branch 'main' of https://github.com/dirtbags/moth into main

This commit is contained in:
Donaldson 2020-10-14 13:33:20 -05:00
commit c54a6dbb1a
20 changed files with 321 additions and 436 deletions

View File

@ -4,7 +4,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v4.0.0] - Unreleased ## [v4.0.0-rc2] - Unreleased
### Fixed
- Multiple bugs preventing production server from working properly
- CI builds should be working now
- Team registration now correctly writes names to files
- Anonymized team names now only computed once per team
## [v4.0-rc1] - 2020-10-13
### Changed ### Changed
- Major rewrite/refactor of `mothd` - Major rewrite/refactor of `mothd`
- Clear separation of roles: State, Puzzles, and Theme - Clear separation of roles: State, Puzzles, and Theme
@ -37,14 +44,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security ### Security
## [Unreleased]
### Changed
- Endpoints `/points.json`, `/puzzles.json`, and `/messages.html` (optional theme file) combine into `/state`
- No more `__devel__` category for dev server: this is now `.config.devel` in the `/state` endpoint
- Development server no longer serves a static `/` with links: it now redirects you to a randomly-generated seed URL
- Default theme modifications to handle all this
- Default theme now automatically "logs you in" with Team ID if it's getting state from the devel server
## [v3.5.1] - 2020-03-16 ## [v3.5.1] - 2020-03-16
### Fixed ### Fixed
- Support insta-checking for legacy puzzles - Support insta-checking for legacy puzzles

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
@ -109,7 +110,15 @@ func (h *HTTPServer) StateHandler(mh MothRequestHandler, w http.ResponseWriter,
// RegisterHandler handles attempts to register a team // RegisterHandler handles attempts to register a team
func (h *HTTPServer) RegisterHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) { func (h *HTTPServer) RegisterHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) {
teamName := req.FormValue("name") teamName := req.FormValue("name")
if err := mh.Register(teamName); err != nil { teamName = strings.TrimSpace(teamName)
if teamName == "" {
jsend.Sendf(w, jsend.Fail, "empty name", "Team name may not be empty")
return
}
if err := mh.Register(teamName); err == ErrAlreadyRegistered {
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()) jsend.Sendf(w, jsend.Fail, "not registered", err.Error())
} else { } else {
jsend.Sendf(w, jsend.Success, "registered", "Team ID registered") jsend.Sendf(w, jsend.Success, "registered", "Team ID registered")
@ -171,11 +180,12 @@ func (h *HTTPServer) MothballerHandler(mh MothRequestHandler, w http.ResponseWri
// parts[0] == "mothballer" // parts[0] == "mothballer"
filename := parts[1] filename := parts[1]
cat := strings.TrimSuffix(filename, ".mb") cat := strings.TrimSuffix(filename, ".mb")
mothball, err := mh.Mothball(cat) mb := new(bytes.Buffer)
if err != nil { if err := mh.Mothball(cat, mb); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
http.ServeContent(w, req, filename, time.Now(), mothball) mbReader := bytes.NewReader(mb.Bytes())
http.ServeContent(w, req, filename, time.Now(), mbReader)
} }

View File

@ -50,8 +50,8 @@ func TestHttpd(t *testing.T) {
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 { if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
t.Error(r.Result()) t.Error(r.Result())
} else if r.Body.String() != `{"Config":{"Devel":false},"Messages":"messages.html","TeamNames":{"self":""},"PointsLog":[],"Puzzles":{"pategory":[1]}}` { } else if r.Body.String() != `{"Config":{"Devel":false},"Messages":"messages.html","TeamNames":{},"PointsLog":[],"Puzzles":{}}` {
t.Error("Unexpected state") t.Error("Unexpected state", r.Body.String())
} }
if r := hs.TestRequest("/register", map[string]string{"id": "bad team id", "name": "GoTeam"}); r.Result().StatusCode != 200 { if r := hs.TestRequest("/register", map[string]string{"id": "bad team id", "name": "GoTeam"}); r.Result().StatusCode != 200 {
@ -66,6 +66,12 @@ func TestHttpd(t *testing.T) {
t.Error("Register failed") 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"}}` {
t.Error("Register failed", r.Body.String())
}
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 { if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
t.Error(r.Result()) t.Error(r.Result())
} else if r.Body.String() != `{"Config":{"Devel":false},"Messages":"messages.html","TeamNames":{"self":"GoTeam"},"PointsLog":[],"Puzzles":{"pategory":[1]}}` { } else if r.Body.String() != `{"Config":{"Devel":false},"Messages":"messages.html","TeamNames":{"self":"GoTeam"},"PointsLog":[],"Puzzles":{"pategory":[1]}}` {
@ -108,6 +114,10 @@ func TestHttpd(t *testing.T) {
time.Sleep(TestMaintenanceInterval) time.Sleep(TestMaintenanceInterval)
if r := hs.TestRequest("/content/pategory/2/puzzle.json", nil); r.Result().StatusCode != 200 {
t.Error(r.Result())
}
state := StateExport{} state := StateExport{}
if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 { if r := hs.TestRequest("/state", nil); r.Result().StatusCode != 200 {
t.Error(r.Result()) t.Error(r.Result())

View File

@ -3,7 +3,6 @@ package main
import ( import (
"archive/zip" "archive/zip"
"bufio" "bufio"
"bytes"
"fmt" "fmt"
"io" "io"
"log" "log"
@ -52,7 +51,7 @@ func (m *Mothballs) Open(cat string, points int, filename string) (ReadSeekClose
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("content/%d/%s", points, filename)) f, err := zc.Open(fmt.Sprintf("%d/%s", points, filename))
if err != nil { if err != nil {
return nil, time.Time{}, err return nil, time.Time{}, err
} }
@ -174,8 +173,8 @@ func (m *Mothballs) refresh() {
} }
// Mothball just returns an error // Mothball just returns an error
func (m *Mothballs) Mothball(cat string) (*bytes.Reader, error) { func (m *Mothballs) Mothball(cat string, w io.Writer) error {
return nil, fmt.Errorf("Can't repackage a compiled mothball") return fmt.Errorf("Refusing to repackage a compiled mothball")
} }
// Maintain performs housekeeping for Mothballs. // Maintain performs housekeeping for Mothballs.

View File

@ -13,12 +13,12 @@ var testFiles = []struct {
}{ }{
{"puzzles.txt", "1\n3\n2\n"}, {"puzzles.txt", "1\n3\n2\n"},
{"answers.txt", "1 answer123\n1 answer456\n2 wat\n"}, {"answers.txt", "1 answer123\n1 answer456\n2 wat\n"},
{"content/1/puzzle.json", `{"name": "moo"}`}, {"1/puzzle.json", `{"name": "moo"}`},
{"content/1/moo.txt", `moo`}, {"1/moo.txt", `moo`},
{"content/2/puzzle.json", `{}`}, {"2/puzzle.json", `{}`},
{"content/2/moo.txt", `moo`}, {"2/moo.txt", `moo`},
{"content/3/puzzle.json", `{}`}, {"3/puzzle.json", `{}`},
{"content/3/moo.txt", `moo`}, {"3/moo.txt", `moo`},
} }
func (m *Mothballs) createMothball(cat string) { func (m *Mothballs) createMothball(cat string) {

View File

@ -1,7 +1,6 @@
package main package main
import ( import (
"bytes"
"fmt" "fmt"
"io" "io"
"strconv" "strconv"
@ -42,7 +41,7 @@ type PuzzleProvider interface {
Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error) Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error)
Inventory() []Category Inventory() []Category
CheckAnswer(cat string, points int, answer string) (bool, error) CheckAnswer(cat string, points int, answer string) (bool, error)
Mothball(cat string) (*bytes.Reader, error) Mothball(cat string, w io.Writer) error
Maintainer Maintainer
} }
@ -108,7 +107,7 @@ type MothRequestHandler struct {
// PuzzlesOpen opens a file associated with a puzzle. // PuzzlesOpen opens a file associated with a puzzle.
// BUG(neale): Multiple providers with the same category name are not detected or handled well. // BUG(neale): Multiple providers with the same category name are not detected or handled well.
func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (r ReadSeekCloser, ts time.Time, err error) { func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (r ReadSeekCloser, ts time.Time, err error) {
export := mh.ExportState() export := mh.exportStateIfRegistered(true)
found := false found := false
for _, p := range export.Puzzles[cat] { for _, p := range export.Puzzles[cat] {
if p == points { if p == points {
@ -116,7 +115,7 @@ func (mh *MothRequestHandler) PuzzlesOpen(cat string, points int, path string) (
} }
} }
if !found { if !found {
return nil, time.Time{}, fmt.Errorf("Category not found") return nil, time.Time{}, fmt.Errorf("Puzzle does not exist or is locked")
} }
// Try every provider until someone doesn't return an error // Try every provider until someone doesn't return an error
@ -173,27 +172,37 @@ func (mh *MothRequestHandler) Register(teamName string) error {
// the anonymized team name for this teamID has the special value "self". // the anonymized team name for this teamID has the special value "self".
// If not, the puzzles list is empty. // If not, the puzzles list is empty.
func (mh *MothRequestHandler) ExportState() *StateExport { func (mh *MothRequestHandler) ExportState() *StateExport {
return mh.exportStateIfRegistered(false)
}
func (mh *MothRequestHandler) exportStateIfRegistered(override bool) *StateExport {
export := StateExport{} export := StateExport{}
export.Config = mh.Config export.Config = mh.Config
teamName, _ := mh.State.TeamName(mh.teamID) teamName, err := mh.State.TeamName(mh.teamID)
registered := override || (err == nil)
export.Messages = mh.State.Messages() export.Messages = mh.State.Messages()
export.TeamNames = map[string]string{"self": teamName} export.TeamNames = make(map[string]string)
// Anonymize team IDs in points log, and write out team names // Anonymize team IDs in points log, and write out team names
pointsLog := mh.State.PointsLog() pointsLog := mh.State.PointsLog()
exportIDs := map[string]string{mh.teamID: "self"} exportIDs := make(map[string]string)
maxSolved := map[string]int{} maxSolved := make(map[string]int)
export.PointsLog = make(award.List, len(pointsLog)) export.PointsLog = make(award.List, len(pointsLog))
if registered {
export.TeamNames["self"] = teamName
exportIDs[mh.teamID] = "self"
}
for logno, awd := range pointsLog { for logno, awd := range pointsLog {
if id, ok := exportIDs[awd.TeamID]; ok { if id, ok := exportIDs[awd.TeamID]; ok {
awd.TeamID = id awd.TeamID = id
} else { } else {
exportID := strconv.Itoa(logno) exportID := strconv.Itoa(logno)
name, _ := mh.State.TeamName(awd.TeamID) name, _ := mh.State.TeamName(awd.TeamID)
exportIDs[awd.TeamID] = exportID
awd.TeamID = exportID awd.TeamID = exportID
exportIDs[awd.TeamID] = awd.TeamID
export.TeamNames[exportID] = name export.TeamNames[exportID] = name
} }
export.PointsLog[logno] = awd export.PointsLog[logno] = awd
@ -205,11 +214,10 @@ func (mh *MothRequestHandler) ExportState() *StateExport {
} }
export.Puzzles = make(map[string][]int) export.Puzzles = make(map[string][]int)
if _, ok := export.TeamNames["self"]; ok { if registered {
// We used to hand this out to everyone, // We used to hand this out to everyone,
// but then we got a bad reputation on some secretive blacklist, // but then we got a bad reputation on some secretive blacklist,
// and now the Navy can't register for events. // and now the Navy can't register for events.
for _, provider := range mh.PuzzleProviders { for _, provider := range mh.PuzzleProviders {
for _, category := range provider.Inventory() { for _, category := range provider.Inventory() {
// Append sentry (end of puzzles) // Append sentry (end of puzzles)
@ -233,14 +241,16 @@ func (mh *MothRequestHandler) ExportState() *StateExport {
} }
// Mothball generates a mothball for the given category. // Mothball generates a mothball for the given category.
func (mh *MothRequestHandler) Mothball(cat string) (r *bytes.Reader, err error) { func (mh *MothRequestHandler) Mothball(cat string, w io.Writer) error {
var err error
if !mh.Config.Devel { if !mh.Config.Devel {
return nil, fmt.Errorf("Cannot mothball in production mode") return fmt.Errorf("Cannot mothball in production mode")
} }
for _, provider := range mh.PuzzleProviders { for _, provider := range mh.PuzzleProviders {
if r, err = provider.Mothball(cat); err == nil { if err = provider.Mothball(cat, w); err == nil {
return r, nil return nil
} }
} }
return nil, err return err
} }

View File

@ -34,9 +34,28 @@ func TestServer(t *testing.T) {
server := NewTestServer() server := NewTestServer()
handler := server.NewHandler(participantID, teamID) handler := server.NewHandler(participantID, teamID)
anonHandler := server.NewHandler("badParticipantId", "badTeamId")
{
es := handler.ExportState()
if es.Config.Devel {
t.Error("Marked as development server", es.Config)
}
if len(es.Puzzles) != 0 {
t.Log("State", es)
t.Error("Unauthenticated state has non-empty puzzles list")
}
}
if err := handler.Register(teamName); err != nil { if err := handler.Register(teamName); err != nil {
t.Error(err) t.Error(err)
} }
if err := handler.Register(teamName); err == nil {
t.Error("Registering twice should have raised an error")
} else if err != ErrAlreadyRegistered {
t.Error("Wrong error for duplicate registration:", err)
}
if r, _, err := handler.ThemeOpen("/index.html"); err != nil { if r, _, err := handler.ThemeOpen("/index.html"); err != nil {
t.Error(err) t.Error(err)
} else if contents, err := ioutil.ReadAll(r); err != nil { } else if contents, err := ioutil.ReadAll(r); err != nil {
@ -45,24 +64,26 @@ func TestServer(t *testing.T) {
t.Error("index.html wrong contents", contents) t.Error("index.html wrong contents", contents)
} }
es := handler.ExportState() {
if es.Config.Devel { es := handler.ExportState()
t.Error("Marked as development server", es.Config) if es.Config.Devel {
} t.Error("Marked as development server", es.Config)
if len(es.Puzzles) != 1 { }
t.Error("Puzzle categories wrong length") if len(es.Puzzles) != 1 {
} t.Error("Puzzle categories wrong length")
if es.Messages != "messages.html" { }
t.Error("Messages has wrong contents") if es.Messages != "messages.html" {
} t.Error("Messages has wrong contents")
if len(es.PointsLog) != 0 { }
t.Error("Points log not empty") if len(es.PointsLog) != 0 {
} t.Error("Points log not empty")
if len(es.TeamNames) != 1 { }
t.Error("Wrong number of team names") if len(es.TeamNames) != 1 {
} t.Error("Wrong number of team names")
if es.TeamNames["self"] != teamName { }
t.Error("TeamNames['self'] wrong") if es.TeamNames["self"] != teamName {
t.Error("TeamNames['self'] wrong")
}
} }
if r, _, err := handler.PuzzlesOpen("pategory", 1, "moo.txt"); err != nil { if r, _, err := handler.PuzzlesOpen("pategory", 1, "moo.txt"); err != nil {
@ -77,12 +98,12 @@ func TestServer(t *testing.T) {
r.Close() r.Close()
} }
if r, _, err := handler.PuzzlesOpen("pategory", 2, "puzzles.json"); err == nil { if r, _, err := handler.PuzzlesOpen("pategory", 2, "puzzle.json"); err == nil {
t.Error("Opening locked puzzle shouldn't work") t.Error("Opening locked puzzle shouldn't work")
r.Close() r.Close()
} }
if r, _, err := handler.PuzzlesOpen("pategory", 20, "puzzles.json"); err == nil { if r, _, err := handler.PuzzlesOpen("pategory", 20, "puzzle.json"); err == nil {
t.Error("Opening non-existent puzzle shouldn't work") t.Error("Opening non-existent puzzle shouldn't work")
r.Close() r.Close()
} }
@ -93,9 +114,53 @@ func TestServer(t *testing.T) {
time.Sleep(TestMaintenanceInterval) time.Sleep(TestMaintenanceInterval)
es = handler.ExportState() {
if len(es.PointsLog) != 1 { es := handler.ExportState()
t.Error("I didn't get my points!") if len(es.PointsLog) != 1 {
t.Error("I didn't get my points!")
}
if len(es.Puzzles["pategory"]) != 2 {
t.Error("The next puzzle didn't unlock!")
} else if es.Puzzles["pategory"][1] != 2 {
t.Error("The 2-point puzzle should have unlocked!")
}
}
if r, _, err := handler.PuzzlesOpen("pategory", 2, "puzzle.json"); err != nil {
t.Error("Opening unlocked puzzle should work")
} else {
r.Close()
}
if r, _, err := anonHandler.PuzzlesOpen("pategory", 2, "puzzle.json"); err != nil {
t.Error("Opening unlocked puzzle anonymously should work")
} else {
r.Close()
}
if err := handler.CheckAnswer("pategory", 2, "wat"); err != nil {
t.Error("Right answer marked wrong:", err)
}
time.Sleep(TestMaintenanceInterval)
{
es := anonHandler.ExportState()
if len(es.TeamNames) != 1 {
t.Error("Anonymous TeamNames is wrong:", es.TeamNames)
}
if len(es.PointsLog) != 2 {
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])
}
}
{
es := handler.ExportState()
if len(es.TeamNames) != 1 {
t.Error("TeamNames is wrong:", es.TeamNames)
}
} }
// BUG(neale): We aren't currently testing the various ways to disable the server // BUG(neale): We aren't currently testing the various ways to disable the server

View File

@ -2,6 +2,7 @@ package main
import ( import (
"bufio" "bufio"
"errors"
"fmt" "fmt"
"log" "log"
"math/rand" "math/rand"
@ -23,6 +24,9 @@ const DistinguishableChars = "34678abcdefhikmnpqrtwxy="
// This is also a valid RFC3339 format. // This is also a valid RFC3339 format.
const RFC3339Space = "2006-01-02 15:04:05Z07:00" const RFC3339Space = "2006-01-02 15:04:05Z07:00"
// ErrAlreadyRegistered means a team cannot be registered because it was registered previously.
var ErrAlreadyRegistered = errors.New("Team ID has already been registered")
// State defines the current state of a MOTH instance. // State defines the current state of a MOTH instance.
// We use the filesystem for synchronization between threads. // We use the filesystem for synchronization between threads.
// The only thing State methods need to know is the path to the state directory. // The only thing State methods need to know is the path to the state directory.
@ -127,7 +131,7 @@ func (s *State) TeamName(teamID string) (string, error) {
} }
// SetTeamName writes out team name. // SetTeamName writes out team name.
// This can only be done once. // This can only be done once per team.
func (s *State) SetTeamName(teamID, teamName string) error { func (s *State) SetTeamName(teamID, teamName string) error {
idsFile, err := s.Open("teamids.txt") idsFile, err := s.Open("teamids.txt")
if err != nil { if err != nil {
@ -147,14 +151,16 @@ func (s *State) SetTeamName(teamID, teamName string) error {
} }
teamFilename := filepath.Join("teams", teamID) teamFilename := filepath.Join("teams", teamID)
teamFile, err := s.Fs.OpenFile(teamFilename, os.O_CREATE|os.O_EXCL, 0644) teamFile, err := s.Fs.OpenFile(teamFilename, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644)
if os.IsExist(err) { if os.IsExist(err) {
return fmt.Errorf("Team ID is already registered") return ErrAlreadyRegistered
} else if err != nil { } else if err != nil {
return err return err
} }
defer teamFile.Close() defer teamFile.Close()
log.Println("Setting team name to:", teamName, teamFilename, teamFile)
fmt.Fprintln(teamFile, teamName) fmt.Fprintln(teamFile, teamName)
teamFile.Close()
return nil return nil
} }

View File

@ -55,12 +55,18 @@ func TestState(t *testing.T) {
t.Errorf("Setting bad team ID didn't raise an error") t.Errorf("Setting bad team ID didn't raise an error")
} }
if err := s.SetTeamName(teamID, "My Team"); err != nil { teamName := "My Team"
t.Errorf("Setting team name: %v", err) if err := s.SetTeamName(teamID, teamName); err != nil {
t.Errorf("Setting team name: %w", err)
} }
if err := s.SetTeamName(teamID, "wat"); err == nil { if err := s.SetTeamName(teamID, "wat"); err == nil {
t.Errorf("Registering team a second time didn't fail") t.Errorf("Registering team a second time didn't fail")
} }
if name, err := s.TeamName(teamID); err != nil {
t.Error(err)
} else if name != teamName {
t.Error("Incorrect team name:", name)
}
category := "poot" category := "poot"
points := 3928 points := 3928
@ -83,6 +89,10 @@ func TestState(t *testing.T) {
t.Error("Duplicate points award 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)
}
pl = s.PointsLog() pl = s.PointsLog()
if len(pl) != 1 { if len(pl) != 1 {
t.Errorf("After awarding points, points log has length %d", len(pl)) t.Errorf("After awarding points, points log has length %d", len(pl))
@ -98,7 +108,7 @@ func TestState(t *testing.T) {
t.Error(err) t.Error(err)
} }
s.refresh() s.refresh()
if len(s.PointsLog()) != 1 { if len(s.PointsLog()) != 2 {
t.Error("Intentional parse error screws up all parsing") t.Error("Intentional parse error screws up all parsing")
} }

View File

@ -70,9 +70,9 @@ func (p TranspilerProvider) CheckAnswer(cat string, points int, answer string) (
} }
// Mothball packages up a category into a mothball. // Mothball packages up a category into a mothball.
func (p TranspilerProvider) Mothball(cat string) (*bytes.Reader, error) { func (p TranspilerProvider) Mothball(cat string, w io.Writer) error {
c := transpile.NewFsCategory(p.fs, cat) c := transpile.NewFsCategory(p.fs, cat)
return transpile.Mothball(c) return transpile.Mothball(c, w)
} }
// Maintain performs housekeeping. // Maintain performs housekeeping.

View File

@ -16,11 +16,13 @@ import (
// T represents the state of things // T represents the state of things
type T struct { type T struct {
Stdout io.Writer Stdout io.Writer
Stderr io.Writer Stderr io.Writer
Args []string Args []string
BaseFs afero.Fs BaseFs afero.Fs
fs afero.Fs fs afero.Fs
// Arguments
filename string filename string
answer string answer string
} }
@ -51,19 +53,21 @@ func (t *T) ParseArgs() (Command, error) {
} }
flags := flag.NewFlagSet(t.Args[1], flag.ContinueOnError) flags := flag.NewFlagSet(t.Args[1], flag.ContinueOnError)
flags.SetOutput(t.Stderr)
directory := flags.String("dir", "", "Work directory") directory := flags.String("dir", "", "Work directory")
switch t.Args[1] { switch t.Args[1] {
case "mothball": case "mothball":
cmd = t.DumpMothball cmd = t.DumpMothball
flags.StringVar(&t.filename, "out", "", "Path to create mothball (empty for stdout)")
case "inventory": case "inventory":
cmd = t.PrintInventory cmd = t.PrintInventory
case "open": case "open":
flags.StringVar(&t.filename, "file", "puzzle.json", "Filename to open")
cmd = t.DumpFile cmd = t.DumpFile
flags.StringVar(&t.filename, "file", "puzzle.json", "Filename to open")
case "answer": case "answer":
flags.StringVar(&t.answer, "answer", "", "Answer to check")
cmd = t.CheckAnswer cmd = t.CheckAnswer
flags.StringVar(&t.answer, "answer", "", "Answer to check")
case "help": case "help":
usage(t.Stderr) usage(t.Stderr)
return nothing, nil return nothing, nil
@ -73,12 +77,10 @@ func (t *T) ParseArgs() (Command, error) {
return nothing, fmt.Errorf("Invalid command") return nothing, fmt.Errorf("Invalid command")
} }
flags.SetOutput(t.Stderr)
if err := flags.Parse(t.Args[2:]); err != nil { if err := flags.Parse(t.Args[2:]); err != nil {
return nothing, err return nothing, err
} }
if *directory != "" { if *directory != "" {
log.Println(*directory)
t.fs = afero.NewBasePathFs(t.BaseFs, *directory) t.fs = afero.NewBasePathFs(t.BaseFs, *directory)
} else { } else {
t.fs = t.BaseFs t.fs = t.BaseFs
@ -140,14 +142,30 @@ func (t *T) DumpFile() error {
return nil return nil
} }
// DumpMothball writes a mothball to the writer. // DumpMothball writes a mothball to the writer, or an output file if specified.
func (t *T) DumpMothball() error { func (t *T) DumpMothball() error {
var w io.Writer
c := transpile.NewFsCategory(t.fs, "") c := transpile.NewFsCategory(t.fs, "")
mb, err := transpile.Mothball(c)
if err != nil { removeOnError := false
return err switch t.filename {
case "", "-":
w = t.Stdout
default:
removeOnError = true
log.Println("Writing mothball to", t.filename)
outf, err := t.BaseFs.Create(t.filename)
if err != nil {
return err
}
defer outf.Close()
w = outf
} }
if _, err := io.Copy(t.Stdout, mb); err != nil { if err := transpile.Mothball(c, w); err != nil {
if removeOnError {
t.BaseFs.Remove(t.filename)
}
return err return err
} }
return nil return nil
@ -165,8 +183,6 @@ func (t *T) CheckAnswer() error {
} }
func main() { func main() {
// XXX: Convert puzzle.py to standalone thingies
t := &T{ t := &T{
Stdout: os.Stdout, Stdout: os.Stdout,
Stderr: os.Stderr, Stderr: os.Stderr,

View File

@ -1,8 +1,10 @@
package main package main
import ( import (
"archive/zip"
"bytes" "bytes"
"encoding/json" "encoding/json"
"io/ioutil"
"testing" "testing"
"github.com/dirtbags/moth/pkg/transpile" "github.com/dirtbags/moth/pkg/transpile"
@ -83,16 +85,72 @@ func TestEverything(t *testing.T) {
if stdout.String() != "Moo." { if stdout.String() != "Moo." {
t.Error("Wrong file pulled", stdout.String()) t.Error("Wrong file pulled", stdout.String())
} }
}
func TestMothballs(t *testing.T) {
stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
tp := T{
Stdout: stdout,
Stderr: stderr,
BaseFs: newTestFs(),
}
stdout.Reset() stdout.Reset()
if err := tp.Run("mothball", "-dir=unbroken"); err != nil { if err := tp.Run("mothball", "-dir=unbroken", "-out=unbroken.mb"); err != nil {
t.Log(tp.fs) t.Error(err)
return
}
// afero.WriteFile(tp.BaseFs, "unbroken.mb", []byte("moo"), 0644)
fis, err := afero.ReadDir(tp.BaseFs, "/")
if err != nil {
t.Error(err) t.Error(err)
} }
if stdout.Len() < 200 { for _, fi := range fis {
t.Error("That's way too short to be a mothball") t.Log(fi.Name())
} }
if stdout.String()[:2] != "PK" {
t.Error("This mothball isn't a zip file!") mb, err := tp.BaseFs.Open("unbroken.mb")
if err != nil {
t.Error(err)
return
}
defer mb.Close()
info, err := mb.Stat()
if err != nil {
t.Error(err)
return
}
zmb, err := zip.NewReader(mb, info.Size())
if err != nil {
t.Error(err)
return
}
for _, zf := range zmb.File {
f, err := zf.Open()
if err != nil {
t.Error(err)
continue
}
defer f.Close()
buf, err := ioutil.ReadAll(f)
if err != nil {
t.Error(err)
continue
}
switch zf.Name {
case "answers.txt":
if len(buf) == 0 {
t.Error("answers.txt empty")
}
case "puzzles.txt":
if len(buf) == 0 {
t.Error("puzzles.txt empty")
}
}
} }
} }

View File

@ -70,7 +70,7 @@ Adjusting scores
------------------ ------------------
rm /srv/moth/state/enabled # Suspend scoring rm /srv/moth/state/enabled # Suspend scoring
nano /srv/moth/state/points.log nano /srv/moth/state/points.log # Replace nano with your preferred editor
touch /srv/moth/state/enabled # Resume scoring touch /srv/moth/state/enabled # Resume scoring
We don't warn participants before we do this: We don't warn participants before we do this:
@ -78,10 +78,8 @@ any points scored while scoring is suspended are queued up and processed as soon
It's very important to suspend scoring before mucking around with the points log. It's very important to suspend scoring before mucking around with the points log.
The maintenance loop assumes it is the only thing writing to this file, The maintenance loop assumes it is the only thing writing to this file,
and any edits you make could blow aware points scored. and any edits you make will remove points scored while you were editing.
No, I don't use nano.
None of us use nano.
Changing a team name Changing a team name

View File

@ -10,23 +10,16 @@ import (
) )
// Mothball packages a Category up for a production server run. // Mothball packages a Category up for a production server run.
func Mothball(c Category) (*bytes.Reader, error) { func Mothball(c Category, w io.Writer) error {
buf := new(bytes.Buffer) zf := zip.NewWriter(w)
zf := zip.NewWriter(buf)
inv, err := c.Inventory() inv, err := c.Inventory()
if err != nil { if err != nil {
return nil, err return err
} }
puzzlesTxt, err := zf.Create("puzzles.txt") puzzlesTxt := new(bytes.Buffer)
if err != nil { answersTxt := new(bytes.Buffer)
return nil, err
}
answersTxt, err := zf.Create("answers.txt")
if err != nil {
return nil, err
}
for _, points := range inv { for _, points := range inv {
fmt.Fprintln(puzzlesTxt, points) fmt.Fprintln(puzzlesTxt, points)
@ -34,11 +27,11 @@ func Mothball(c Category) (*bytes.Reader, error) {
puzzlePath := fmt.Sprintf("%d/puzzle.json", points) puzzlePath := fmt.Sprintf("%d/puzzle.json", points)
pw, err := zf.Create(puzzlePath) pw, err := zf.Create(puzzlePath)
if err != nil { if err != nil {
return nil, err return err
} }
puzzle, err := c.Puzzle(points) puzzle, err := c.Puzzle(points)
if err != nil { if err != nil {
return nil, fmt.Errorf("Puzzle %d: %s", points, err) return fmt.Errorf("Puzzle %d: %s", points, err)
} }
// Record answers in answers.txt // Record answers in answers.txt
@ -55,7 +48,7 @@ func Mothball(c Category) (*bytes.Reader, error) {
// Write out Puzzle object // Write out Puzzle object
penc := json.NewEncoder(pw) penc := json.NewEncoder(pw)
if err := penc.Encode(puzzle); err != nil { if err := penc.Encode(puzzle); err != nil {
return nil, fmt.Errorf("Puzzle %d: %s", points, err) return fmt.Errorf("Puzzle %d: %s", points, err)
} }
// Write out all attachments and scripts // Write out all attachments and scripts
@ -64,20 +57,33 @@ func Mothball(c Category) (*bytes.Reader, error) {
attPath := fmt.Sprintf("%d/%s", points, att) attPath := fmt.Sprintf("%d/%s", points, att)
aw, err := zf.Create(attPath) aw, err := zf.Create(attPath)
if err != nil { if err != nil {
return nil, err return err
} }
ar, err := c.Open(points, att) ar, err := c.Open(points, att)
if exerr, ok := err.(*exec.ExitError); ok { if exerr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("Puzzle %d: %s: %s: %s", points, att, err, string(exerr.Stderr)) return fmt.Errorf("Puzzle %d: %s: %s: %s", points, att, err, string(exerr.Stderr))
} else if err != nil { } else if err != nil {
return nil, fmt.Errorf("Puzzle %d: %s: %s", points, att, err) return fmt.Errorf("Puzzle %d: %s: %s", points, att, err)
} }
if _, err := io.Copy(aw, ar); err != nil { if _, err := io.Copy(aw, ar); err != nil {
return nil, fmt.Errorf("Puzzle %d: %s: %s", points, att, err) return fmt.Errorf("Puzzle %d: %s: %s", points, att, err)
} }
} }
} }
pf, err := zf.Create("puzzles.txt")
if err != nil {
return err
}
puzzlesTxt.WriteTo(pf)
af, err := zf.Create("answers.txt")
if err != nil {
return err
}
answersTxt.WriteTo(af)
zf.Close() zf.Close()
return bytes.NewReader(buf.Bytes()), nil return nil
} }

View File

@ -2,6 +2,7 @@ package transpile
import ( import (
"archive/zip" "archive/zip"
"bytes"
"io/ioutil" "io/ioutil"
"os" "os"
"path" "path"
@ -14,7 +15,8 @@ import (
func TestMothballsMemFs(t *testing.T) { func TestMothballsMemFs(t *testing.T) {
static := NewFsCategory(newTestFs(), "cat1") static := NewFsCategory(newTestFs(), "cat1")
if _, err := Mothball(static); err != nil { mb := new(bytes.Buffer)
if err := Mothball(static, mb); err != nil {
t.Error(err) t.Error(err)
} }
} }
@ -25,13 +27,15 @@ func TestMothballsOsFs(t *testing.T) {
fs := NewRecursiveBasePathFs(afero.NewOsFs(), "testdata") fs := NewRecursiveBasePathFs(afero.NewOsFs(), "testdata")
static := NewFsCategory(fs, "static") static := NewFsCategory(fs, "static")
mb, err := Mothball(static) mb := new(bytes.Buffer)
err := Mothball(static, mb)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
return return
} }
mbr, err := zip.NewReader(mb, int64(mb.Len())) mbReader := bytes.NewReader(mb.Bytes())
mbr, err := zip.NewReader(mbReader, int64(mb.Len()))
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
@ -43,7 +47,7 @@ func TestMothballsOsFs(t *testing.T) {
defer f.Close() defer f.Close()
if buf, err := ioutil.ReadAll(f); err != nil { if buf, err := ioutil.ReadAll(f); err != nil {
t.Error(err) t.Error(err)
} else if string(buf) != "" { } else if string(buf) != "1\n2\n3\n" {
t.Error("Bad puzzles.txt", string(buf)) t.Error("Bad puzzles.txt", string(buf))
} }
} }

View File

@ -59,7 +59,7 @@ function renderPuzzles(obj) {
pdiv.appendChild(l) pdiv.appendChild(l)
for (let puzzle of puzzles) { for (let puzzle of puzzles) {
let points = puzzle let points = puzzle
let id = puzzle let id = null
if (Array.isArray(puzzle)) { if (Array.isArray(puzzle)) {
points = puzzle[0] points = puzzle[0]
@ -80,7 +80,7 @@ function renderPuzzles(obj) {
let url = new URL("puzzle.html", window.location) let url = new URL("puzzle.html", window.location)
url.searchParams.set("cat", cat) url.searchParams.set("cat", cat)
url.searchParams.set("points", points) url.searchParams.set("points", points)
url.searchParams.set("pid", id) if (id) { url.searchParams.set("pid", id) }
a.href = url.toString() a.href = url.toString()
} }
} }
@ -97,6 +97,7 @@ function renderPuzzles(obj) {
} }
function renderState(obj) { function renderState(obj) {
window.state = obj
devel = obj.Config.Devel devel = obj.Config.Devel
if (devel) { if (devel) {
let params = new URLSearchParams(window.location.search) let params = new URLSearchParams(window.location.search)

View File

@ -1,255 +0,0 @@
{
"__comment__": [
"This file is to help debug themes.",
"MOTHd will ignore it."
],
"teams": {
"0": "4HED Followers",
"1": "Dirtbags",
"17": "Eyeball",
"2": "Soup Giver!!!!!!!!!",
"24": "Dumb freshmans 3",
"25": "Winner",
"2d": "Cool team name",
"2f": "Dumm freshmans #1",
"4": "K19 the Widow Maker",
"5": "2T2",
"6": "Apples",
"7": "Top Minds",
"8": "DIRTBAGS",
"b": "Antiderivative of Pizza"
},
"points": [
[1573007086,"0","codebreaking",1],
[1573007096,"1","codebreaking",1],
[1573007114,"2","codebreaking",1],
[1573007153,"0","codebreaking",2],
[1573007159,"4","codebreaking",1],
[1573007169,"5","codebreaking",1],
[1573007181,"6","sequence",1],
[1573007184,"7","codebreaking",1],
[1573007209,"8","codebreaking",1],
[1573007212,"2","codebreaking",2],
[1573007240,"1","sequence",1],
[1573007244,"b","codebreaking",1],
[1573007246,"1","nocode",1],
[1573007258,"5","nocode",1],
[1573007271,"5","nocode",2],
[1573007284,"1","steg",1],
[1573007295,"7","codebreaking",2],
[1573007298,"2","codebreaking",4],
[1573007305,"5","nocode",3],
[1573007316,"7","codebreaking",4],
[1573007321,"0","codebreaking",4],
[1573007328,"5","nocode",4],
[1573007331,"7","nocode",1],
[1573007336,"17","codebreaking",1],
[1573007340,"7","nocode",2],
[1573007367,"0","nocode",10],
[1573007369,"7","nocode",3],
[1573007371,"b","nocode",1],
[1573007379,"7","nocode",4],
[1573007388,"b","nocode",2],
[1573007391,"6","sequence",2],
[1573007397,"4","codebreaking",2],
[1573007407,"b","nocode",3],
[1573007411,"7","nocode",10],
[1573007413,"5","nocode",10],
[1573007429,"b","nocode",4],
[1573007442,"24","codebreaking",2],
[1573007451,"25","codebreaking",1],
[1573007456,"7","sequence",1],
[1573007460,"b","nocode",10],
[1573007467,"5","sequence",1],
[1573007471,"7","sequence",2],
[1573007478,"5","sequence",2],
[1573007479,"17","codebreaking",2],
[1573007490,"2","codebreaking",5],
[1573007509,"2d","codebreaking",1],
[1573007536,"8","codebreaking",2],
[1573007544,"2f","codebreaking",1],
[1573007546,"b","sequence",1],
[1573007574,"24","codebreaking",4],
[1573007581,"b","sequence",2],
[1573007591,"25","codebreaking",2],
[1573007603,"8","codebreaking",4],
[1573007614,"0","nocode",20],
[1573007639,"4","codebreaking",4],
[1573007678,"6","codebreaking",1],
[1573007692,"8","nocode",1],
[1573007695,"24","codebreaking",5],
[1573007705,"7","codebreaking",5],
[1573007707,"8","nocode",2],
[1573007713,"17","nocode",1],
[1573007727,"17","nocode",2],
[1573007735,"8","nocode",3],
[1573007737,"b","steg",1],
[1573007739,"25","codebreaking",4],
[1573007749,"8","nocode",4],
[1573007757,"17","codebreaking",4],
[1573007768,"8","nocode",10],
[1573007795,"0","sequence",1],
[1573007799,"8","sequence",1],
[1573007816,"0","sequence",2],
[1573007822,"8","sequence",2],
[1573007834,"24","codebreaking",6],
[1573007853,"2d","codebreaking",2],
[1573007905,"1","codebreaking",2],
[1573007941,"4","codebreaking",5],
[1573007956,"1","codebreaking",4],
[1573007974,"6","codebreaking",2],
[1573007998,"17","sequence",1],
[1573008022,"b","codebreaking",4],
[1573008055,"24","sequence",2],
[1573008063,"6","codebreaking",4],
[1573008066,"2d","codebreaking",4],
[1573008074,"24","sequence",1],
[1573008099,"17","nocode",4],
[1573008101,"0","codebreaking",7],
[1573008108,"2d","nocode",1],
[1573008135,"24","nocode",30],
[1573008146,"1","codebreaking",5],
[1573008162,"2d","nocode",2],
[1573008174,"b","codebreaking",2],
[1573008191,"2","codebreaking",6],
[1573008234,"6","codebreaking",5],
[1573008240,"2","nocode",10],
[1573008291,"5","steg",1],
[1573008310,"6","nocode",1],
[1573008323,"2d","nocode",3],
[1573008327,"6","nocode",2],
[1573008330,"25","codebreaking",5],
[1573008334,"2f","codebreaking",2],
[1573008348,"6","nocode",3],
[1573008356,"2d","nocode",4],
[1573008362,"b","codebreaking",5],
[1573008364,"6","nocode",4],
[1573008364,"17","codebreaking",5],
[1573008371,"24","nocode",4],
[1573008385,"24","nocode",3],
[1573008390,"6","nocode",10],
[1573008397,"24","nocode",2],
[1573008400,"25","nocode",1],
[1573008402,"2d","nocode",10],
[1573008408,"24","nocode",1],
[1573008419,"25","nocode",2],
[1573008429,"24","steg",1],
[1573008437,"25","nocode",3],
[1573008451,"25","nocode",4],
[1573008479,"25","nocode",10],
[1573008502,"2d","sequence",1],
[1573008506,"17","codebreaking",6],
[1573008537,"2d","sequence",2],
[1573008649,"17","codebreaking",7],
[1573008668,"2f","codebreaking",4],
[1573008716,"1","codebreaking",6],
[1573008768,"8","steg",1],
[1573008808,"7","nocode",50],
[1573008817,"24","steg",2],
[1573008832,"2f","codebreaking",5],
[1573008890,"17","steg",1],
[1573008902,"b","steg",2],
[1573008932,"7","steg",1],
[1573008944,"24","steg",3],
[1573008978,"2","steg",1],
[1573009006,"24","steg",4],
[1573009032,"6","steg",1],
[1573009038,"b","steg",3],
[1573009052,"2d","codebreaking",5],
[1573009098,"b","steg",4],
[1573009122,"8","steg",2],
[1573009125,"4","nocode",1],
[1573009160,"24","nocode",10],
[1573009161,"4","nocode",2],
[1573009179,"2","steg",2],
[1573009180,"1","steg",2],
[1573009194,"24","nocode",20],
[1573009203,"0","nocode",50],
[1573009212,"2f","codebreaking",6],
[1573009240,"2f","nocode",1],
[1573009250,"4","nocode",4],
[1573009255,"2f","nocode",2],
[1573009258,"2","steg",4],
[1573009282,"4","nocode",10],
[1573009299,"25","sequence",1],
[1573009305,"6","steg",4],
[1573009308,"17","steg",3],
[1573009310,"1","steg",3],
[1573009334,"7","steg",4],
[1573009345,"1","steg",4],
[1573009345,"7","steg",3],
[1573009354,"8","steg",4],
[1573009357,"25","sequence",2],
[1573009402,"6","steg",3],
[1573009402,"b","sequence",8],
[1573009413,"2f","nocode",3],
[1573009437,"17","steg",2],
[1573009455,"2f","nocode",10],
[1573009481,"b","sequence",16],
[1573009502,"b","sequence",19],
[1573009520,"b","sequence",25],
[1573009525,"17","steg",4],
[1573009559,"7","steg",2],
[1573009561,"b","sequence",35],
[1573009571,"0","sequence",35],
[1573009588,"25","steg",1],
[1573009602,"24","sequence",8],
[1573009607,"2","steg",5],
[1573009614,"1","steg",5],
[1573009617,"17","sequence",35],
[1573009620,"7","sequence",50],
[1573009621,"6","steg",5],
[1573009629,"5","steg",3],
[1573009632,"7","sequence",35],
[1573009644,"17","sequence",25],
[1573009670,"6","steg",6],
[1573009698,"8","steg",6],
[1573009700,"17","sequence",19],
[1573009703,"24","steg",6],
[1573009703,"4","sequence",1],
[1573009707,"0","sequence",50],
[1573009710,"25","steg",2],
[1573009729,"2f","sequence",1],
[1573009768,"1","steg",6],
[1573009814,"2","codebreaking",8],
[1573009842,"0","steg",1],
[1573009844,"2f","sequence",2],
[1573009882,"4","steg",1],
[1573009896,"25","steg",3],
[1573009931,"1","sequence",2],
[1573009937,"25","steg",4],
[1573010066,"7","steg",6],
[1573010101,"25","steg",5],
[1573010114,"5","steg",4],
[1573010137,"25","steg",6],
[1573010185,"4","sequence",2],
[1573010229,"17","nocode",80],
[1573010256,"24","sequence",35],
[1573010281,"6","codebreaking",7],
[1573010336,"25","codebreaking",6],
[1573010390,"7","codebreaking",7],
[1573010468,"2f","steg",1],
[1573010712,"0","steg",2],
[1573010739,"0","steg",3],
[1573010754,"0","steg",4],
[1573010778,"0","steg",5],
[1573010784,"7","nocode",90],
[1573010792,"0","steg",6],
[1573011760,"7","sequence",60],
[1573056120,"0","sequence",100],
[1573056324,"0","sequence",200],
[1573056791,"0","sequence",300],
[1573057092,"0","sequence",400],
[1573076767,"25","sequence",400],
[1573076809,"25","sequence",300],
[1573076838,"25","sequence",200],
[1573076936,"25","nocode",20],
[1573077275,"25","nocode",50],
[1573078364,"0","sequence",19],
[1573078432,"0","sequence",25],
[1573078487,"25","sequence",35],
[1573078501,"25","sequence",50],
[1573079359,"0","nocode",90],
[1573079714,"25","nocode",9]
]
}

View File

@ -132,7 +132,7 @@ async function loadPuzzle(categoryName, points, puzzleId) {
document.getElementById("authors").textContent = window.puzzle.Pre.Authors.join(", ") document.getElementById("authors").textContent = window.puzzle.Pre.Authors.join(", ")
// If answers are provided, this is the devel server // If answers are provided, this is the devel server
if (window.puzzle.Answers) { if (window.puzzle.Answers.length > 0) {
devel_addin(document.getElementById("devel")) devel_addin(document.getElementById("devel"))
} }
@ -204,8 +204,8 @@ function init() {
let points = params.get("points") let points = params.get("points")
let puzzleId = params.get("pid") let puzzleId = params.get("pid")
if (categoryName && points && puzzleId) { if (categoryName && points) {
loadPuzzle(categoryName, points, puzzleId) loadPuzzle(categoryName, points, puzzleId || points)
} }
let teamId = sessionStorage.getItem("id") let teamId = sessionStorage.getItem("id")

View File

@ -1,54 +0,0 @@
{
"__comment__": [
"This file is to help debug themes.",
"MOTHd will ignore it."
],
"codebreaking": [
[1,"37117e6b034696b86c6516477cc0bc60bc1e642e"],
[2,"546b586428979771b061608489327da4940086a7"],
[4,"6f2a33c93f56b4f29cc79e6576ba4d1000aa1756"],
[5,"c654fe263909b1940d7aad8c572363a0569c07c6"],
[6,"f30bd32bf940f2bb03506ec334d2d204efc4695b"],
[7,"128b119083b6ae70c380a8eb70ec6a518425e7af"],
[8,"edd4f57aeb565b3b053fa194f5e677cb77ef0285"],
[15,"9781863bca9f596972e2a10460932ec5ec6be3fe"]
],
"nocode": [
[1,"37117e6b034696b86c6516477cc0bc60bc1e642e"],
[2,"546b586428979771b061608489327da4940086a7"],
[3,"79c08697a1923da1118fd0c2e922b5d3899cabcc"],
[4,"6f2a33c93f56b4f29cc79e6576ba4d1000aa1756"],
[10,"bf4fae263bf6e4243b143f4ecd64e471f3ec75dd"],
[20,"9f374f6dac9f972fac4693099a7bfa7c535f7503"],
[30,"02de1196d43976b2d050c6c597f068623d2df201"],
[50,"9acb3af947cb4aa10a9c1221c04518f956cdc0d0"],
[80,"78f807ac44f3cbf537861e7cdf1ac53937e4ee47"],
[90,"6d537653aa599178c72528f7e1f2fbb36e6333f9"],
[100,"4f5982a3a7cc9b9af0320130132e8cab39a1fd2c"]
],
"sequence": [
[1,"37117e6b034696b86c6516477cc0bc60bc1e642e"],
[2,"546b586428979771b061608489327da4940086a7"],
[8,"edd4f57aeb565b3b053fa194f5e677cb77ef0285"],
[16,"a9ace4b773f045c422260edefaa8563dcd80ac59"],
[19,"f11ca0172451f37ba6f4d66ff9add80013480a49"],
[25,"0458533d28705548829e53d686215cc6fbeec8f5"],
[35,"91aac06bae090ae7d1699b5a78601ef8d29e9271"],
[50,"9acb3af947cb4aa10a9c1221c04518f956cdc0d0"],
[60,"bf84beed9e382268ab40d0113dfeb73c96aa919a"],
[100,"4f5982a3a7cc9b9af0320130132e8cab39a1fd2c"],
[200,"3b9b8993fe639cf0c19a58b39ebbf6077828887a"],
[300,"0f13c4d19bc5d2e10d43e8cd2e40f759e731cece"],
[400,"db7a59f313818fc9598969d2a0a04e21bd26697f"],
[500,"81c5389eb5406aa44053662f6482f246b8a12e0c"]
],
"steg": [
[1,"200e8cd902ba7304765c463f6ed1322bc25f3454"],
[2,"707328988c3986d450d8fe419eb49f078fb7998c"],
[3,"d0b336ad59cbcd4415ddf200c6c099db5c3fea1d"],
[4,"f071503b403ffee2b38e186e800bfd5dd28e8f0e"],
[5,"186f425fa5762ef37f874cc602fe0edc4325a5d2"],
[6,"c6527c3c30c4e6a33026192d358d83d259cd17a7"],
[10,"84973f77a1b14e4666f3d8a8bdeead7633c4ed56"]
]
}

View File

@ -13,6 +13,8 @@ function scoreboardInit() {
] ]
function update(state) { function update(state) {
window.state = state
for (let rotate of document.querySelectorAll(".rotate")) { for (let rotate of document.querySelectorAll(".rotate")) {
rotate.appendChild(rotate.firstElementChild) rotate.appendChild(rotate.firstElementChild)
} }