moth

Monarch Of The Hill game server
git clone https://git.woozle.org/neale/moth.git

moth / cmd / mothd
Neale Pickett  ·  2023-09-29

state.go

  1package main
  2
  3import (
  4	"bufio"
  5	"encoding/csv"
  6	"errors"
  7	"fmt"
  8	"log"
  9	"math/rand"
 10	"os"
 11	"path/filepath"
 12	"strconv"
 13	"strings"
 14	"sync"
 15	"time"
 16
 17	"github.com/dirtbags/moth/v4/pkg/award"
 18	"github.com/spf13/afero"
 19)
 20
 21// DistinguishableChars are visually unambiguous glyphs.
 22// People with mediocre handwriting could write these down unambiguously,
 23// and they can be entered without holding down shift.
 24const DistinguishableChars = "34678abcdefhikmnpqrtwxy="
 25
 26// RFC3339Space is a time layout which replaces 'T' with a space.
 27// This is also a valid RFC3339 format.
 28const RFC3339Space = "2006-01-02 15:04:05Z07:00"
 29
 30// ErrAlreadyRegistered means a team cannot be registered because it was registered previously.
 31var ErrAlreadyRegistered = errors.New("team ID has already been registered")
 32
 33// State defines the current state of a MOTH instance.
 34// We use the filesystem for synchronization between threads.
 35// The only thing State methods need to know is the path to the state directory.
 36type State struct {
 37	afero.Fs
 38
 39	// Enabled tracks whether the current State system is processing updates
 40	enabled bool
 41
 42	enabledWhy      string
 43	refreshNow      chan bool
 44	eventStream     chan []string
 45	eventWriter     *csv.Writer
 46	eventWriterFile afero.File
 47
 48	// Caches, so we're not hammering NFS with metadata operations
 49	teamNamesLastChange time.Time
 50	teamNames           map[string]string
 51	pointsLog           award.List
 52	lock                sync.RWMutex
 53}
 54
 55// NewState returns a new State struct backed by the given Fs
 56func NewState(fs afero.Fs) *State {
 57	s := &State{
 58		Fs:          fs,
 59		enabled:     true,
 60		refreshNow:  make(chan bool, 5),
 61		eventStream: make(chan []string, 80),
 62
 63		teamNames: make(map[string]string),
 64	}
 65	if err := s.reopenEventLog(); err != nil {
 66		log.Fatal(err)
 67	}
 68	return s
 69}
 70
 71// updateEnabled checks a few things to see if this state directory is "enabled".
 72func (s *State) updateEnabled() {
 73	nextEnabled := true
 74	why := "state/hours.txt has no timestamps before now"
 75
 76	if untilFile, err := s.Open("hours.txt"); err == nil {
 77		defer untilFile.Close()
 78
 79		scanner := bufio.NewScanner(untilFile)
 80		for scanner.Scan() {
 81			line := scanner.Text()
 82			if len(line) < 1 {
 83				continue
 84			}
 85
 86			thisEnabled := true
 87			switch line[0] {
 88			case '+':
 89				thisEnabled = true
 90				line = line[1:]
 91			case '-':
 92				thisEnabled = false
 93				line = line[1:]
 94			case '#':
 95				continue
 96			default:
 97				log.Println("state/hours.txt has bad line:", line)
 98			}
 99			line, _, _ = strings.Cut(line, "#") // Remove inline comments
100			line = strings.TrimSpace(line)
101			until := time.Time{}
102			if len(line) == 0 {
103				// Let it stay as zero time, so it's always before now
104			} else if until, err = time.Parse(time.RFC3339, line); err == nil {
105				// Great, it was RFC 3339
106			} else if until, err = time.Parse(RFC3339Space, line); err == nil {
107				// Great, it was RFC 3339 with a space instead of a 'T'
108			} else {
109				log.Println("state/hours.txt has bad timestamp:", line)
110				continue
111			}
112			if until.Before(time.Now()) {
113				nextEnabled = thisEnabled
114				why = fmt.Sprint("state/hours.txt most recent timestamp:", line)
115			}
116		}
117	}
118
119	if (nextEnabled != s.enabled) || (why != s.enabledWhy) {
120		s.enabled = nextEnabled
121		s.enabledWhy = why
122		log.Printf("Setting enabled=%v: %s", s.enabled, s.enabledWhy)
123		if s.enabled {
124			s.LogEvent("enabled", "", "", 0, s.enabledWhy)
125		} else {
126			s.LogEvent("disabled", "", "", 0, s.enabledWhy)
127		}
128	}
129}
130
131// TeamName returns team name given a team ID.
132func (s *State) TeamName(teamID string) (string, error) {
133	s.lock.RLock()
134	name, ok := s.teamNames[teamID]
135	s.lock.RUnlock()
136	if !ok {
137		return "", fmt.Errorf("unregistered team ID: %s", teamID)
138	}
139	return name, nil
140}
141
142// SetTeamName writes out team name.
143// This can only be done once per team.
144func (s *State) SetTeamName(teamID, teamName string) error {
145	s.lock.RLock()
146	_, ok := s.teamNames[teamID]
147	s.lock.RUnlock()
148	if ok {
149		return ErrAlreadyRegistered
150	}
151
152	idsFile, err := s.Open("teamids.txt")
153	if err != nil {
154		return fmt.Errorf("team IDs file does not exist")
155	}
156	defer idsFile.Close()
157	found := false
158	scanner := bufio.NewScanner(idsFile)
159	for scanner.Scan() {
160		if scanner.Text() == teamID {
161			found = true
162			break
163		}
164	}
165	if !found {
166		return fmt.Errorf("team ID not found in list of valid team IDs")
167	}
168
169	teamFilename := filepath.Join("teams", teamID)
170	teamFile, err := s.Fs.OpenFile(teamFilename, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644)
171	if os.IsExist(err) {
172		return ErrAlreadyRegistered
173	} else if err != nil {
174		return err
175	}
176	defer teamFile.Close()
177	log.Printf("Setting team name [%s] in file %s", teamName, teamFilename)
178	fmt.Fprintln(teamFile, teamName)
179	teamFile.Close()
180
181	s.refreshNow <- true
182
183	return nil
184}
185
186// PointsLog retrieves the current points log.
187func (s *State) PointsLog() award.List {
188	s.lock.RLock()
189	ret := make(award.List, len(s.pointsLog))
190	copy(ret, s.pointsLog)
191	s.lock.RUnlock()
192	return ret
193}
194
195// Enabled returns true if the server is in "enabled" state
196func (s *State) Enabled() bool {
197	return s.enabled
198}
199
200// AwardPoints gives points to teamID in category.
201// This doesn't attempt to ensure the teamID has been registered.
202// It first checks to make sure these are not duplicate points.
203// This is not a perfect check, you can trigger a race condition here.
204// It's just a courtesy to the user.
205// The update task makes sure we never have duplicate points in the log.
206func (s *State) AwardPoints(teamID, category string, points int) error {
207	return s.awardPointsAtTime(time.Now().Unix(), teamID, category, points)
208}
209
210func (s *State) awardPointsAtTime(when int64, teamID string, category string, points int) error {
211	a := award.T{
212		When:     when,
213		TeamID:   teamID,
214		Category: category,
215		Points:   points,
216	}
217
218	for _, e := range s.PointsLog() {
219		if a.Equal(e) {
220			return fmt.Errorf("points already awarded to this team in this category")
221		}
222	}
223
224	//fn := fmt.Sprintf("%s-%s-%d", a.TeamID, a.Category, a.Points)
225	fn := a.Filename()
226	tmpfn := filepath.Join("points.tmp", fn)
227	newfn := filepath.Join("points.new", fn)
228
229	if err := afero.WriteFile(s, tmpfn, []byte(a.String()), 0644); err != nil {
230		return err
231	}
232
233	if err := s.Rename(tmpfn, newfn); err != nil {
234		return err
235	}
236
237	//  State should be updated immediately
238	s.refreshNow <- true
239
240	return nil
241}
242
243// collectPoints gathers up files in points.new/ and appends their contents to points.log,
244// removing each points.new/ file as it goes.
245func (s *State) collectPoints() {
246	files, err := afero.ReadDir(s, "points.new")
247	if err != nil {
248		log.Print(err)
249		return
250	}
251	for _, f := range files {
252		filename := filepath.Join("points.new", f.Name())
253		awardstr, err := afero.ReadFile(s, filename)
254		if err != nil {
255			log.Print("Opening new points: ", err)
256			continue
257		}
258		awd, err := award.Parse(string(awardstr))
259		if err != nil {
260			log.Print("Can't parse award file ", filename, ": ", err)
261			continue
262		}
263
264		duplicate := false
265		s.lock.RLock()
266		for _, e := range s.pointsLog {
267			if awd.Equal(e) {
268				duplicate = true
269				break
270			}
271		}
272		s.lock.RUnlock()
273
274		if duplicate {
275			log.Print("Skipping duplicate points: ", awd.String())
276		} else {
277			log.Print("Award: ", awd.String())
278
279			logf, err := s.OpenFile("points.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
280			if err != nil {
281				log.Print("Can't append to points log: ", err)
282				return
283			}
284			fmt.Fprintln(logf, awd.String())
285			logf.Close()
286
287			// Stick this on the cache too
288			s.lock.Lock()
289			s.pointsLog = append(s.pointsLog, awd)
290			s.lock.Unlock()
291		}
292
293		if err := s.Remove(filename); err != nil {
294			log.Print("Unable to remove new points file: ", err)
295		}
296	}
297}
298
299func (s *State) maybeInitialize() {
300	// Are we supposed to re-initialize?
301	if _, err := s.Stat("initialized"); !os.IsNotExist(err) {
302		return
303	}
304
305	now := time.Now().UTC().Format(time.RFC3339)
306	log.Print("initialized file missing, re-initializing")
307
308	// Remove any extant control and state files
309	s.Remove("enabled")
310	s.Remove("hours.txt")
311	s.Remove("points.log")
312	s.Remove("events.csv")
313	s.Remove("mothd.log")
314	s.RemoveAll("points.tmp")
315	s.RemoveAll("points.new")
316	s.RemoveAll("teams")
317
318	// Open log file
319	if err := s.reopenEventLog(); err != nil {
320		log.Fatal(err)
321	}
322	s.LogEvent("init", "", "", 0)
323
324	// Make sure various subdirectories exist
325	s.Mkdir("points.tmp", 0755)
326	s.Mkdir("points.new", 0755)
327	s.Mkdir("teams", 0755)
328
329	// Preseed available team ids if file doesn't exist
330	if f, err := s.OpenFile("teamids.txt", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil {
331		id := make([]byte, 8)
332		for i := 0; i < 100; i++ {
333			for i := range id {
334				char := rand.Intn(len(DistinguishableChars))
335				id[i] = DistinguishableChars[char]
336			}
337			fmt.Fprintln(f, string(id))
338		}
339		f.Close()
340	}
341
342	// Create some files
343	if f, err := s.Create("initialized"); err == nil {
344		fmt.Fprintln(f, "initialized: remove to re-initialize the contest.")
345		fmt.Fprintln(f)
346		fmt.Fprintln(f, "This instance was initialized at", now)
347		f.Close()
348	}
349
350	if f, err := s.Create("hours.txt"); err == nil {
351		fmt.Fprintln(f, "# hours.txt: when the contest is enabled")
352		fmt.Fprintln(f, "#")
353		fmt.Fprintln(f, "# Enable:  + [timestamp]")
354		fmt.Fprintln(f, "# Disable: - [timestamp]")
355		fmt.Fprintln(f, "#")
356		fmt.Fprintln(f, "# This file, and all files in this directory, are re-read periodically.")
357		fmt.Fprintln(f, "# Default is enabled.")
358		fmt.Fprintln(f, "# Rules with only '-' or '+' are also allowed.")
359		fmt.Fprintln(f, "# Rules apply from the top down.")
360		fmt.Fprintln(f, "# If you put something in out of order, it's going to be bonkers.")
361		fmt.Fprintln(f)
362		fmt.Fprintln(f, "- 1970-01-01T00:00:00Z")
363		fmt.Fprintln(f, "+", now)
364		fmt.Fprintln(f, "- 2519-10-31T00:00:00Z")
365		f.Close()
366	}
367
368	if f, err := s.Create("points.log"); err == nil {
369		f.Close()
370	}
371}
372
373// LogEvent writes to the event log
374func (s *State) LogEvent(event, teamID, cat string, points int, extra ...string) {
375	s.eventStream <- append(
376		[]string{
377			strconv.FormatInt(time.Now().Unix(), 10),
378			event,
379			teamID,
380			cat,
381			strconv.Itoa(points),
382		},
383		extra...,
384	)
385}
386
387func (s *State) reopenEventLog() error {
388	if s.eventWriter != nil {
389		s.eventWriter.Flush()
390	}
391	if s.eventWriterFile != nil {
392		if err := s.eventWriterFile.Close(); err != nil {
393			// We're going to soldier on if Close returns error
394			log.Print(err)
395		}
396	}
397	eventWriterFile, err := s.OpenFile("events.csv", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
398	if err != nil {
399		return err
400	}
401	s.eventWriterFile = eventWriterFile
402	s.eventWriter = csv.NewWriter(s.eventWriterFile)
403	return nil
404}
405
406func (s *State) updateCaches() {
407	s.lock.Lock()
408	defer s.lock.Unlock()
409
410	if f, err := s.Open("points.log"); err != nil {
411		log.Println(err)
412	} else {
413		defer f.Close()
414
415		pointsLog := make(award.List, 0, 200)
416		scanner := bufio.NewScanner(f)
417		for scanner.Scan() {
418			line := scanner.Text()
419			cur, err := award.Parse(line)
420			if err != nil {
421				log.Printf("Skipping malformed award line %s: %s", line, err)
422				continue
423			}
424			pointsLog = append(pointsLog, cur)
425		}
426		s.pointsLog = pointsLog
427	}
428
429	// Only do this if the teams directory has a newer mtime; directories with
430	// hundreds of team names can cause NFS I/O storms
431	{
432		_, ismmfs := s.Fs.(*afero.MemMapFs) // Tests run so quickly that the time check isn't precise enough
433		if fi, err := s.Fs.Stat("teams"); err != nil {
434			log.Printf("Getting modification time of teams directory: %v", err)
435		} else if ismmfs || s.teamNamesLastChange.Before(fi.ModTime()) {
436			s.teamNamesLastChange = fi.ModTime()
437
438			// The compiler recognizes this as an optimization case
439			for k := range s.teamNames {
440				delete(s.teamNames, k)
441			}
442
443			teamsFs := afero.NewBasePathFs(s.Fs, "teams")
444			if dirents, err := afero.ReadDir(teamsFs, "."); err != nil {
445				log.Printf("Reading team ids: %v", err)
446			} else {
447				for _, dirent := range dirents {
448					teamID := dirent.Name()
449					if teamNameBytes, err := afero.ReadFile(teamsFs, teamID); err != nil {
450						log.Printf("Reading team %s: %v", teamID, err)
451					} else {
452						teamName := strings.TrimSpace(string(teamNameBytes))
453						s.teamNames[teamID] = teamName
454					}
455				}
456			}
457		}
458	}
459}
460
461func (s *State) refresh() {
462	s.maybeInitialize()
463	s.updateEnabled()
464	if s.enabled {
465		s.collectPoints()
466	}
467	s.updateCaches()
468}
469
470// Maintain performs housekeeping on a State struct.
471func (s *State) Maintain(updateInterval time.Duration) {
472	ticker := time.NewTicker(updateInterval)
473	s.refresh()
474	for {
475		select {
476		case msg := <-s.eventStream:
477			s.eventWriter.Write(msg)
478			s.eventWriter.Flush()
479			s.eventWriterFile.Sync()
480		case <-ticker.C:
481			s.refresh()
482		case <-s.refreshNow:
483			s.refresh()
484		}
485	}
486}
487
488// DevelState is a StateProvider for use by development servers
489type DevelState struct {
490	StateProvider
491}
492
493// NewDevelState returns a new state object that can be used by the development server.
494//
495// The main thing this provides is the ability to register a team with any team ID.
496// If a team ID is provided that wasn't recognized by the underlying StateProvider,
497// it is associated with a team named "<devel:$ID>".
498//
499// This makes it possible to use the server without having to register a team.
500func NewDevelState(sp StateProvider) *DevelState {
501	return &DevelState{sp}
502}
503
504// TeamName returns a valid team name for any teamID
505//
506// If one's registered, it will use it.
507// Otherwise, it returns "<devel:$ID>"
508func (ds *DevelState) TeamName(teamID string) (string, error) {
509	if name, err := ds.StateProvider.TeamName(teamID); err == nil {
510		return name, nil
511	}
512	if teamID == "" {
513		return "", fmt.Errorf("empty team ID")
514	}
515	return fmt.Sprintf("«devel:%s»", teamID), nil
516}
517
518// SetTeamName associates a team name with any teamID
519//
520// If the underlying StateProvider returns any sort of error,
521// this returns ErrAlreadyRegistered,
522// so the user can join a pre-existing team for whatever ID the provide.
523func (ds *DevelState) SetTeamName(teamID, teamName string) error {
524	if err := ds.StateProvider.SetTeamName(teamID, teamName); err != nil {
525		return ErrAlreadyRegistered
526	}
527	return nil
528}