package main import ( "cmp" "encoding/json" "flag" "log" "math" "net/http" "os" "regexp" "slices" "strings" "sync" "time" ) const ( numScores = 5 maxNameLength = 9 dailyDiscount = 0.99 ) type scoreStore struct { m map[string]float64 *sync.RWMutex } type score struct { Player string `json:"player"` Score int `json:"score"` } func (ds *scoreStore) leaderboardSize() int { l := len(ds.m) if l >= numScores { return numScores } else { return l } } func (ds *scoreStore) tops() []score { ds.Lock() defer ds.Unlock() // select top scores scores := make([]score, 0, len(ds.m)) for p, s := range ds.m { scores = append(scores, score{Player: p, Score: int(math.Round(s))}) } slices.SortFunc(scores, func(a, b score) int { return -cmp.Compare(a.Score, b.Score) }) scores = scores[:ds.leaderboardSize()] return scores } var nonSanitizedRe = regexp.MustCompile(`[^[:word:].-]`) func normalizePlayer(p string) string { p = nonSanitizedRe.ReplaceAllString(p, "") chars := 0 for i := range p { if chars >= maxNameLength { return p[:i] } chars++ } return strings.ToUpper(p) } func (ds *scoreStore) update(s score) []score { fs := float64(s.Score) s.Player = normalizePlayer(s.Player) ds.Lock() oldscore, has := ds.m[s.Player] if !has || fs > oldscore { ds.m[s.Player] = fs } ds.Unlock() return ds.tops() } type scoreHandler struct { store *scoreStore } func (h *scoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Max-Age", "86400") w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") switch { case r.Method == http.MethodGet && r.URL.Path == "/": h.List(w, r) return case r.Method == http.MethodPost && r.URL.Path == "/": h.Create(w, r) return case r.Method == http.MethodGet && r.URL.Path == "/all": h.All(w, r) default: notFound(w, r) return } } func (h *scoreHandler) List(w http.ResponseWriter, r *http.Request) { jsonBytes, err := json.Marshal(h.store.tops()) if err != nil { internalServerError(w, r) return } w.WriteHeader(http.StatusOK) w.Write(jsonBytes) } func (h *scoreHandler) Create(w http.ResponseWriter, r *http.Request) { var s score if err := json.NewDecoder(r.Body).Decode(&s); err != nil { internalServerError(w, r) return } top10 := h.store.update(s) jsonBytes, err := json.Marshal(top10) if err != nil { internalServerError(w, r) return } w.WriteHeader(http.StatusOK) w.Write(jsonBytes) } func (h *scoreHandler) All(w http.ResponseWriter, r *http.Request) { h.store.Lock() jsonBytes, err := json.Marshal(h.store.m) h.store.Unlock() if err != nil { internalServerError(w, r) return } w.WriteHeader(http.StatusOK) w.Write(jsonBytes) } func internalServerError(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("internal server error")) } func notFound(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) w.Write([]byte("not found")) } func scheduledDiscount(ss scoreStore) { for range time.Tick(24 * time.Hour) { ss.Lock() for k, v := range ss.m { ss.m[k] = v * dailyDiscount } ss.Unlock() } } func main() { addr := flag.String("addr", ":11567", "Listen address") initialFile := flag.String("file", "scores.json", "File with initial data in JSON format") flag.Parse() initialScoresJson, err := os.ReadFile(*initialFile) initialStore := make(map[string]float64) if err == nil { json.Unmarshal(initialScoresJson, &initialStore) } else { log.Println("initial file", *initialFile, "could not be read, defaulting to empty data") } store := scoreStore{m: initialStore, RWMutex: &sync.RWMutex{}} scoreH := &scoreHandler{ store: &store, } go scheduledDiscount(store) http.Handle("/", scoreH) http.Handle("/all", scoreH) http.ListenAndServe(*addr, nil) }