no more state/disabled, better logs for hours.txt

This commit is contained in:
Neale Pickett 2022-11-29 15:33:21 -07:00
parent ec2a547637
commit bfcf325e3b
6 changed files with 165 additions and 65 deletions

View File

@ -43,6 +43,7 @@ type State struct {
// Enabled tracks whether the current State system is processing updates // Enabled tracks whether the current State system is processing updates
Enabled bool Enabled bool
enabledWhy string
refreshNow chan bool refreshNow chan bool
eventStream chan []string eventStream chan []string
eventWriter *csv.Writer eventWriter *csv.Writer
@ -75,11 +76,10 @@ func NewState(fs afero.Fs) *State {
// updateEnabled checks a few things to see if this state directory is "enabled". // updateEnabled checks a few things to see if this state directory is "enabled".
func (s *State) updateEnabled() { func (s *State) updateEnabled() {
nextEnabled := true nextEnabled := true
why := "`state/enabled` present, `state/hours.txt` missing" why := "state/hours.txt has no timestamps before now"
if untilFile, err := s.Open("hours.txt"); err == nil { if untilFile, err := s.Open("hours.txt"); err == nil {
defer untilFile.Close() defer untilFile.Close()
why = "`state/hours.txt` present"
scanner := bufio.NewScanner(untilFile) scanner := bufio.NewScanner(untilFile)
for scanner.Scan() { for scanner.Scan() {
@ -99,35 +99,36 @@ func (s *State) updateEnabled() {
case '#': case '#':
continue continue
default: default:
log.Println("Misformatted line in hours.txt file") log.Println("state/hours.txt has bad line:", line)
} }
line, _, _ = strings.Cut(line, "#") // Remove inline comments
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
until, err := time.Parse(time.RFC3339, line) until := time.Time{}
if err != nil { if len(line) == 0 {
until, err = time.Parse(RFC3339Space, line) // Let it stay as zero time, so it's always before now
} } else if until, err = time.Parse(time.RFC3339, line); err == nil {
if err != nil { // Great, it was RFC 3339
log.Println("Suspended: Unparseable until date:", line) } else if until, err = time.Parse(RFC3339Space, line); err == nil {
// Great, it was RFC 3339 with a space instead of a 'T'
} else {
log.Println("state/hours.txt has bad timestamp:", line)
continue continue
} }
if until.Before(time.Now()) { if until.Before(time.Now()) {
nextEnabled = thisEnabled nextEnabled = thisEnabled
why = fmt.Sprint("state/hours.txt most recent timestamp:", line)
} }
} }
} }
if _, err := s.Stat("enabled"); os.IsNotExist(err) { if (nextEnabled != s.Enabled) || (why != s.enabledWhy) {
nextEnabled = false
why = "`state/enabled` missing"
}
if nextEnabled != s.Enabled {
s.Enabled = nextEnabled s.Enabled = nextEnabled
log.Printf("Setting enabled=%v: %s", s.Enabled, why) s.enabledWhy = why
log.Printf("Setting enabled=%v: %s", s.Enabled, s.enabledWhy)
if s.Enabled { if s.Enabled {
s.LogEvent("enabled", "", "", 0, why) s.LogEvent("enabled", "", "", 0, s.enabledWhy)
} else { } else {
s.LogEvent("disabled", "", "", 0, why) s.LogEvent("disabled", "", "", 0, s.enabledWhy)
} }
} }
} }
@ -361,21 +362,19 @@ func (s *State) maybeInitialize() {
f.Close() f.Close()
} }
if f, err := s.Create("enabled"); err == nil {
fmt.Fprintln(f, "enabled: remove or rename to suspend the contest.")
f.Close()
}
if f, err := s.Create("hours.txt"); err == nil { if f, err := s.Create("hours.txt"); err == nil {
fmt.Fprintln(f, "# hours.txt: when the contest is enabled") fmt.Fprintln(f, "# hours.txt: when the contest is enabled")
fmt.Fprintln(f, "#") fmt.Fprintln(f, "#")
fmt.Fprintln(f, "# Enable: + timestamp") fmt.Fprintln(f, "# Enable: + [timestamp]")
fmt.Fprintln(f, "# Disable: - timestamp") fmt.Fprintln(f, "# Disable: - [timestamp]")
fmt.Fprintln(f, "#") fmt.Fprintln(f, "#")
fmt.Fprintln(f, "# You can have multiple start/stop times.") fmt.Fprintln(f, "# This file, and all files in this directory, are re-read periodically.")
fmt.Fprintln(f, "# Whatever time is the most recent, wins.") fmt.Fprintln(f, "# Default is enabled.")
fmt.Fprintln(f, "# Times in the future are ignored.") fmt.Fprintln(f, "# Rules with only '-' or '+' are also allowed.")
fmt.Fprintln(f, "# Rules apply from the top down.")
fmt.Fprintln(f, "# If you put something in out of order, it's going to be bonkers.")
fmt.Fprintln(f) fmt.Fprintln(f)
fmt.Fprintln(f, "- 1970-01-01T00:00:00Z")
fmt.Fprintln(f, "+", now) fmt.Fprintln(f, "+", now)
fmt.Fprintln(f, "- 2519-10-31T00:00:00Z") fmt.Fprintln(f, "- 2519-10-31T00:00:00Z")
f.Close() f.Close()

View File

@ -41,7 +41,6 @@ func TestState(t *testing.T) {
} }
mustExist("initialized") mustExist("initialized")
mustExist("enabled")
mustExist("hours.txt") mustExist("hours.txt")
teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt") teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt")
@ -175,6 +174,9 @@ func TestStateEvents(t *testing.T) {
if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "init:::0" { if msg := <-s.eventStream; strings.Join(msg[1:], ":") != "init:::0" {
t.Error("Wrong message from event stream:", msg) t.Error("Wrong message from event stream:", msg)
} }
if msg := <-s.eventStream; !strings.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) t.Error("Wrong message from event stream:", msg)
} }
@ -196,19 +198,37 @@ func TestStateDisabled(t *testing.T) {
t.Error(err) t.Error(err)
} }
defer hoursFile.Close() defer hoursFile.Close()
s.refresh()
if !s.Enabled {
t.Error("Empty hours.txt not enabled")
}
fmt.Fprintln(hoursFile, "- 1970-01-01T01:01:01Z") fmt.Fprintln(hoursFile, "- 1970-01-01T01:01:01Z")
hoursFile.Sync() hoursFile.Sync()
s.refresh() s.refresh()
if s.Enabled { if s.Enabled {
t.Error("Disabling 1970-01-01") t.Error("1970-01-01")
} }
fmt.Fprintln(hoursFile, "+ 1970-01-01 01:01:01+05:00") fmt.Fprintln(hoursFile, "+ 1970-01-02 01:01:01+05:00")
hoursFile.Sync() hoursFile.Sync()
s.refresh() s.refresh()
if !s.Enabled { if !s.Enabled {
t.Error("Enabling 1970-01-02") t.Error("1970-01-02")
}
fmt.Fprintln(hoursFile, "-")
hoursFile.Sync()
s.refresh()
if s.Enabled {
t.Error("bare -")
}
fmt.Fprintln(hoursFile, "+")
hoursFile.Sync()
s.refresh()
if !s.Enabled {
t.Error("bare +")
} }
fmt.Fprintln(hoursFile, "") fmt.Fprintln(hoursFile, "")
@ -216,7 +236,7 @@ func TestStateDisabled(t *testing.T) {
hoursFile.Sync() hoursFile.Sync()
s.refresh() s.refresh()
if !s.Enabled { if !s.Enabled {
t.Error("Comments") t.Error("Comment")
} }
fmt.Fprintln(hoursFile, "intentional parse error") fmt.Fprintln(hoursFile, "intentional parse error")
@ -230,7 +250,7 @@ func TestStateDisabled(t *testing.T) {
hoursFile.Sync() hoursFile.Sync()
s.refresh() s.refresh()
if s.Enabled { if s.Enabled {
t.Error("Disabling 1980-01-01") t.Error("1980-01-01")
} }
if err := s.Remove("hours.txt"); err != nil { if err := s.Remove("hours.txt"); err != nil {
@ -241,14 +261,6 @@ func TestStateDisabled(t *testing.T) {
t.Error("Removing `hours.txt` disabled event") t.Error("Removing `hours.txt` disabled event")
} }
if err := s.Remove("enabled"); err != nil {
t.Error(err)
}
s.refresh()
if s.Enabled {
t.Error("Removing `enabled` didn't disable")
}
s.Remove("initialized") s.Remove("initialized")
s.refresh() s.refresh()
if !s.Enabled { if !s.Enabled {
@ -303,11 +315,11 @@ func TestStateMaintainer(t *testing.T) {
eventLog, err := afero.ReadFile(s.Fs, "events.csv") eventLog, err := afero.ReadFile(s.Fs, "events.csv")
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} else if events := strings.Split(string(eventLog), "\n"); len(events) != 3 { } else if events := strings.Split(string(eventLog), "\n"); len(events) != 4 {
t.Log("Events:", events) t.Log("Events:", events)
t.Error("Wrong event log length:", len(events)) t.Error("Wrong event log length:", len(events))
} else if events[2] != "" { } else if events[3] != "" {
t.Error("Event log didn't end with newline") t.Error("Event log didn't end with newline", events)
} }
} }

79
docs/FAQ.md Normal file
View File

@ -0,0 +1,79 @@
Frequently Asked Questions
=================
I should probably move this somewhere else,
since most of it is about
Main Application Questions
=====================
## Can we add some instructions to the user interface? It's confusing.
The lack of instruction was a deliberate design decision made about 9 years ago
when we found in A/B testing that college students are a lot more motivated by
vague instruction and mystery than precise instruction. We've since found that
people who are inclined to "play" our events are similarly motivated by
weirdness and mystery: they enjoy fiddling around with things until they've
worked it out experimentally.
Oddly, the group who seems to be the most perturbed by the vagueness is
professionals. This may be because many of these folks spend long amounts of
time trying to make things accessible and precise, and this looks like a train
wreck from that perspective.
Another way to think about it: this is supposed to be a game, like Super Mario
Brothers. We were very careful about designing the puzzles so that you could
learn by playing. The whimsical design is meant to make it feel like trying
things out will not result in a catastrophic failure anywhere, and we've found
that most people figure it out very quickly without any instruction at all,
despite feeling a little confused or disoriented at first.
## Why can't I choose my team from a list when I log in?
We actually started this way, but we quickly learned that there were exploitable
attack avenues available when any participant can join any team. One individual
in 2010, having a bad day, decided to enter every answer they had, for every
team in the contest, as a way of sabotaging the event. It worked: everyone's
motivation to try and solve puzzles tanked, and people were angry that they'd
been working on content only to find that they already had the points.
## Why won't you add this helpful text to the login page?
It has been our experience that the more words we have on that page, the less
likely any of them will be read. We strive now to have no instruction at all,
and to design the interface in a way that it's obvious what you have to do.
## Why aren't we providing a link to the scoreboard?
It's because the scoreboard looks horrible on a mobile phone:
it was designed for a projector.
Once we have a scoreboard that is readable on mobile,
I'll add that link.
## Why can't we show a list of teams to log in to?
At a previous event,
we had a participant log in as other teams and solve every puzzle,
because they were upset about something.
This ruined the event for everyone,
because it took away the challenge of scoring points.
Scoreboard Questions
=================
## Why are there no links or title on the scoreboard?
The scoreboard is supposed to be projected at events, to participants. The current scoreboard isn't something we intend participants to pull up on their mobile devices or laptops.
Think of the scoreboard as sort of like the menu screens at Burger King.
## Will you change the scoreboard color scheme?
The scoreboard colors and layout were carefully chosen to be distinguishable for
all forms of color blindness, and even accessible by users with total blindness
using screen readers. This is why we decided to put the category name inside the
bar and just deal with it being a little weird.
I'm open to suggestions, but they need to work for all users.

View File

@ -45,8 +45,8 @@ Scores
Pausing/resuming scoring Pausing/resuming scoring
------------------- -------------------
rm /srv/moth/state/enabled # Pause scoring echo '-###' >> /srv/moth/state/hours.txt # Suspend scoring
touch /srv/moth/state/enabled # Resume scoring sed -i '/###/d' /srv/moth/state/hours.txt # Resume scoring
When scoring is paused, When scoring is paused,
participants can still submit answers, participants can still submit answers,
@ -54,12 +54,13 @@ and the system will tell them whether the answer is correct.
As soon as you unpause, As soon as you unpause,
all correctly-submitted answers will be scored. all correctly-submitted answers will be scored.
Adjusting scores Adjusting scores
------------------ ------------------
rm /srv/moth/state/enabled # Suspend scoring echo '-###' >> /srv/moth/state/hours.txt # Suspend scoring
nano /srv/moth/state/points.log # Replace nano with your preferred editor nano /srv/moth/state/points.log # Replace nano with your preferred editor
touch /srv/moth/state/enabled # Resume scoring sed -i '/###/d' /srv/moth/state/hours.txt # Resume scoring
We don't warn participants before we do this: We don't warn participants before we do this:
any points scored while scoring is suspended are queued up and processed as soon as scoreing is resumed. any points scored while scoring is suspended are queued up and processed as soon as scoreing is resumed.

View File

@ -33,24 +33,13 @@ The state directory is also used to communicate actions to mothd.
Remove this file to reset the state. This will blow away team assignments and the points log. Remove this file to reset the state. This will blow away team assignments and the points log.
`disabled` `hours.txt`
----------
Create this file to pause collection of points and other maintenance.
Contestants can still submit answers,
but they won't show up on the scoreboard until you remove this file.
This file does not normally exist.
`until`
------- -------
Put an RFC3337 date/time stamp in here to have the server pause itself at a given time. A list of start and stop hours.
Remember that time zones exist! If all the hours are in the future, the event defaults to running.
I recommend always using Zulu time. "Stop" here just pertains to scoreboard updates and puzzle unlocking.
People can still submit answers and their awards are queued up for the next start.
This file does not normally exist.
`teamids.txt` `teamids.txt`

20
pids.txt Normal file
View File

@ -0,0 +1,20 @@
I know you've done some work on this already but I have to get this idea down someplace.
I would like to, as much as possible, not have the same data in two different places. So here are my proposals for doing this in the filesystem backend.
We could implement all of these at the same time, although I don't know why we'd want to
### The obvious way
* [ ] Make the `teams` directory full of directories again and not files
* [ ] Move team names to `teams/$id/name`
* [ ] Define a new file called `teams/$id/map` which contains an ID that this maps to. It will only be followed once.
* [ ] When writing anything with team id, resolve any maps first
### The clever way
* [ ] Allow `teams/$id` to be a symbolic link to another team file
* [ ] When writing anything with team id, resolve any symlinks first
### The easy way
* [ ] Line 2 of a team file, optionally, contains a team id to map to
* [ ] When writing anything with a team id, resolve any maps