// Package scoreboard keeps players and their scores, enforces the rules, and // saves everything to a JSON file. It knows nothing about the web. package scoreboard import ( "encoding/json" "errors" "fmt" "os" "slices" "strings" "sync" "unicode/utf8" ) const MaxNameLength = 30 const MaxChange = 1000 const MaxScore = 1000000 // ErrUnknownPlayer means that no player has the name asked for. var ErrUnknownPlayer = errors.New("There is no player with that name.") // ErrNotSaved means that the file could not be written. It is the server's // problem and not the visitor's, and the board stays as it was. var ErrNotSaved = errors.New("could not save") // Player is one row of the board. The fields are exported because encoding/json // and html/template can only see exported fields. type Player struct { Name string Score int } // Board holds the players. Make one with Open. Its methods may be called by many // requests at the same time. Rules that must stay true: names are unique and not // empty, and every score is from 0 to MaxScore. type Board struct { mu sync.Mutex filename string players []Player } // Open loads the board from a file. A file that does not exist yet gives an // empty board. A file that cannot be read or understood is an error. func Open(filename string) (*Board, error) { data, err := os.ReadFile(filename) if errors.Is(err, os.ErrNotExist) { return &Board{filename: filename, players: []Player{}}, nil } if err != nil { return nil, err } var players []Player err = json.Unmarshal(data, &players) if err != nil { return nil, fmt.Errorf("%s is not valid: %v", filename, err) } return &Board{filename: filename, players: players}, nil } // byScore puts the highest score first, and equal scores in order of name. func byScore(a Player, b Player) int { if a.Score != b.Score { return b.Score - a.Score } return strings.Compare(a.Name, b.Name) } // Players returns a sorted copy of the players. func (b *Board) Players() []Player { b.mu.Lock() defer b.mu.Unlock() sorted := slices.Clone(b.players) slices.SortFunc(sorted, byScore) return sorted } // AddPlayer adds a player with a score of 0. func (b *Board) AddPlayer(name string) error { name = strings.TrimSpace(name) if name == "" { return errors.New("Please enter a name.") } if utf8.RuneCountInString(name) > MaxNameLength { return fmt.Errorf("A name can have at most %d characters.", MaxNameLength) } b.mu.Lock() defer b.mu.Unlock() if b.find(name) >= 0 { return fmt.Errorf("%s is already on the board.", name) } updated := append(slices.Clone(b.players), Player{Name: name, Score: 0}) return b.keep(updated) } // AddPoints changes a player's score by points, which may be negative. func (b *Board) AddPoints(name string, points int) error { if points == 0 || points < -MaxChange || points > MaxChange { return fmt.Errorf("Points must be from %d to %d, and not 0.", -MaxChange, MaxChange) } b.mu.Lock() defer b.mu.Unlock() index := b.find(name) if index < 0 { return ErrUnknownPlayer } score := b.players[index].Score + points if score < 0 { return fmt.Errorf("%s has %d points, so cannot lose %d.", name, b.players[index].Score, -points) } if score > MaxScore { return fmt.Errorf("A score cannot go above %d.", MaxScore) } updated := slices.Clone(b.players) updated[index].Score = score return b.keep(updated) } // find returns the index of a player, or -1. The caller must hold the lock. func (b *Board) find(name string) int { for i, player := range b.players { if player.Name == name { return i } } return -1 } // keep saves the new slice, and uses it only if saving worked. The caller must // hold the lock. func (b *Board) keep(updated []Player) error { err := save(b.filename, updated) if err != nil { return fmt.Errorf("%w: %v", ErrNotSaved, err) } b.players = updated return nil } // save writes a temporary file, then renames it over the real one. func save(filename string, players []Player) error { data, err := json.MarshalIndent(players, "", " ") if err != nil { return err } temporary := filename + ".tmp" err = os.WriteFile(temporary, data, 0644) if err != nil { return err } return os.Rename(temporary, filename) }