193 lines
4.1 KiB
Go
193 lines
4.1 KiB
Go
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)
|
|
}
|