// Draftsim server implementation // TODO: implement timeout // TODO: implement reconnect // TODO: persist draft rates package main import ( "encoding/json" "errors" "flag" "io" "log" "net/http" "os" "strings" "sync" "github.com/adrg/xdg" "github.com/gorilla/websocket" "muhq.space/muhqs-game/go/draftsim/common" "muhq.space/muhqs-game/go/game" ) type table struct { id string draft *game.Draft lock sync.Mutex rates draftRates rwlock sync.RWMutex } type draftRates struct { rates map[string]float32 lock sync.RWMutex } var tables map[string]*table var rates draftRates func (rates *draftRates) loadFrom(path string) (err error) { if path == "" { path, err = xdg.DataFile("draftsim/rates.json") if err != nil { return } } f, err := os.Open(path) if err != nil { return } defer f.Close() d, err := io.ReadAll(f) if err != nil { return } err = json.Unmarshal(d, &rates.rates) return } func (r *draftRates) updateRateUnsafe(card string, rate float32) { if _, found := r.rates[card]; !found { r.rates[card] = rate } else { r.rates[card] = (r.rates[card] + rate) / 2 } } func (r *draftRates) updateRate(card string, rate float32) { r.lock.Lock() defer r.lock.Unlock() r.updateRateUnsafe(card, rate) } func (r *draftRates) mergeRates(r2 *draftRates) { r.lock.Lock() defer r.lock.Unlock() for c, v := range r2.rates { r.updateRateUnsafe(c, v) } } type recordingDraftCtrl struct { game.RWPlayerControl table *table } type gorillaConnRW struct { c *websocket.Conn } func (rw gorillaConnRW) Read(p []byte) (int, error) { _, r, err := rw.c.NextReader() if err != nil { return 0, err } return r.Read(p) } func (rw gorillaConnRW) Write(p []byte) (int, error) { w, err := rw.c.NextWriter(websocket.TextMessage) if err != nil { return 0, err } return w.Write(p) } func (rw gorillaConnRW) Close() (error) { return rw.c.Close() } func newRecordingDraftCrtl(p *game.Player, table *table, c *websocket.Conn) *recordingDraftCtrl { return &recordingDraftCtrl{ *game.NewRWPlayerControl(p, gorillaConnRW{c}, nil), table, } } // Record the received draft pick. func (ctrl *recordingDraftCtrl) RecvAction() game.Action { pick := ctrl.RWPlayerControl.RecvAction().(*game.DraftPick) rate := float32(pick.Pack().Size() / ctrl.table.draft.PackSize()) ctrl.table.rates.updateRate(pick.Pick().Name, rate) return pick } var upgrader = websocket.Upgrader { // FIXME: find a way to send an Origin header using wasm CheckOrigin: func(*http.Request) bool { return true }, } func joinDraft(w http.ResponseWriter, r *http.Request) { c, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println("upgrade failed:", err) } var args common.JoinParams err = c.ReadJSON(&args) if err != nil { c.WriteJSON("failed to parse request body") c.Close() return } tableId := args.Table var found bool var t *table if t, found = tables[tableId]; !found { t = &table{id: tableId, draft: game.NewDraftFromDesc([]*game.Player{}, args.Desc)} tables[tableId] = t } t.lock.Lock() defer t.lock.Unlock() p := game.NewPlayer(-1, args.Name, game.NewDeck(), nil, nil) p.Ctrl = newRecordingDraftCrtl(p, t, c) t.draft.AddPlayer(p) } func startDraft(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) w.Header().Set("Access-Control-Allow-Origin", "*") if err != nil { http.Error(w, "failed to read request body", 400) } tableId := string(body) if t, found := tables[tableId]; found { go func() { t.draft.Run() for _, p := range t.draft.Players() { p.Ctrl.(*recordingDraftCtrl).Close() } rates.mergeRates(&t.rates) }() } else { http.Error(w, "table not found", 404) } } func allStats(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Content-Type", "application/json") rates.lock.RLock() resp, _ := json.Marshal(rates.rates) rates.lock.RUnlock() w.Write(resp) } func specificStats(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "failed to read request body", 400) } cards := string(body) specific := make(map[string]float32) rates.lock.RLock() for card := range strings.SplitSeq(cards, "\n") { specific[card] = rates.rates[card] } rates.lock.RUnlock() resp, _ := json.Marshal(specific) w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Content-Type", "application/json") w.Write(resp) } func cardStat(w http.ResponseWriter, r *http.Request) { card := r.PathValue("card") rates.lock.RLock() resp, _ := json.Marshal(rates.rates[card]) rates.lock.RUnlock() w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Content-Type", "application/json") w.Write(resp) } var ratesFile = flag.String("ratesfile", "", "file to persist the rates") var addr = flag.String("addr", ":8080", "http service address") func main() { tables = make(map[string]*table) rates = draftRates{rates: make(map[string]float32)} flag.Parse() if err := rates.loadFrom(*ratesFile); err != nil && !errors.Is(err, os.ErrNotExist) { log.Fatal(err) } http.HandleFunc("/join", joinDraft) http.HandleFunc("POST /start", startDraft) http.HandleFunc("GET /stats", allStats) http.HandleFunc("POST /stats", specificStats) http.HandleFunc("GET /stats/{card}", cardStat) log.Println("listening on", *addr) if err := http.ListenAndServe(*addr, nil); err != nil { log.Fatal(err) } }