// Draftsim server implementation // TODO: implement reconnect // TODO: persist draft rates package main import ( "encoding/json" "errors" "flag" "io" "log" "math/rand" "net/http" "os" "strings" "sync" "github.com/adrg/xdg" "golang.org/x/net/websocket" "muhq.space/muhqs-game/go/draftsim/common" "muhq.space/muhqs-game/go/game" ) const DEFAULT_DEADLINE = "5x" type table struct { id string draft *game.Draft deadline common.DeadlineFunc lock sync.Mutex rates draftRates rwlock sync.RWMutex } func (t *table) start() { packs := t.draft.PreparePacks() for pack := range t.draft.PacksPerPlayer() { for pick := range t.draft.CardsPerPack() { deadline := t.deadline(t.draft.PackSize() - pick + 1) for _, p := range t.draft.Players() { ctrl := p.Ctrl.(*recordingDraftCtrl) ctrl.ws.SetDeadline(deadline) } t.draft.DealRound(pack, pick, packs) } } } var tables map[string]*table // Cummulative Average type ca struct { Ca float32 N int } func (a *ca) add(x float32) { a.N = a.N + 1 a.Ca = a.Ca + float32(float64(x-a.Ca)/float64(a.N)) } func (a *ca) merge(a2 *ca) { a.N = a.N + a2.N a.Ca = a.Ca + float32(float64(a2.Ca-a.Ca)/float64(a.N)) } type draftRates struct { rates map[string]*ca lock sync.RWMutex } 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() dec := json.NewDecoder(f) err = dec.Decode(&rates.rates) return } func (rates *draftRates) storeTo(path string) (err error) { if path == "" { path, err = xdg.DataFile("draftsim/rates.json") if err != nil { return } } f, err := os.OpenFile(path, os.O_CREATE|os.O_RDONLY|os.O_APPEND, 0x660) if err != nil { return } defer f.Close() enc := json.NewEncoder(f) err = enc.Encode(&rates.rates) return } func (r *draftRates) updateRateUnsafe(card string, rate float32) { if _, found := r.rates[card]; !found { r.rates[card] = &ca{Ca: rate, N: 1} } else { r.rates[card].add(rate) } } func (r *draftRates) updateRate(card string, rate float32) { r.lock.Lock() r.updateRateUnsafe(card, rate) r.lock.Unlock() r.lock.RLock() if err := r.storeTo(*ratesFile); err != nil { log.Fatal(err) } r.lock.RUnlock() } func (r *draftRates) mergeRates(r2 *draftRates) { r.lock.Lock() defer r.lock.Unlock() for c, v := range r2.rates { if a, found := r.rates[c]; !found { r.rates[c] = v } else { a.merge(v) } } } type recordingDraftCtrl struct { game.RWPlayerControl table *table ws *websocket.Conn } func newRecordingDraftCrtl(p *game.Player, table *table, c *websocket.Conn) *recordingDraftCtrl { ctrl := &recordingDraftCtrl{ RWPlayerControl: *game.NewRWPlayerControl(p, c), table: table, ws: c, } return ctrl } // Record the received draft pick. func (ctrl *recordingDraftCtrl) RecvAction() (game.Action, error) { _pick, err := ctrl.RWPlayerControl.RecvAction() if err != nil { return nil, err } pick := _pick.(*game.DraftPick) rate := float32(pick.Pack().Size() / ctrl.table.draft.PackSize()) ctrl.table.rates.updateRate(pick.Pick().Name, rate) return pick, err } func joinDraft(c *websocket.Conn) { var args common.JoinParams err := websocket.JSON.Receive(c, &args) if err != nil { websocket.JSON.Send(c, "failed to parse request body") c.Close() return } tableId := args.Table var found bool var t *table if t, found = tables[tableId]; !found { d, err := game.NewDraftFromDesc([]*game.Player{}, args.Desc) if err != nil { websocket.JSON.Send(c, "failed to create draft") c.Close() return } log.Println("add", args.NPlayers, "AI players") for range args.NPlayers { d.AddRandomAi() } deadline, err := common.GenDeadline(DEFAULT_DEADLINE) if err != nil { // FIXME } t = &table{ id: tableId, draft: d, deadline: deadline, } // FIXME: prevent data race tables[tableId] = t } p := game.NewDraftPlayer(args.Name) p.Id = rand.Int() p.Ctrl = newRecordingDraftCrtl(p, t, c) t.lock.Lock() t.draft.AddPlayerReplacingAI(p) joinInfo := common.JoinInfo{ Players: t.draft.PlayerNames(), Desc: t.draft.Desc(), Sets: t.draft.Sets(), PlayerId: p.Id, Deadline: DEFAULT_DEADLINE, } t.lock.Unlock() log.Println("send join info") err = websocket.JSON.Send(c, joinInfo) if err != nil { log.Println("send joinInfo:", err) c.Close() return } joinNotification := game.NewJoinedPlayerNotification(p) for _, _p := range t.draft.Players() { if _p != p { log.Println("send join notification") _p.Ctrl.SendNotification(joinNotification) } } } 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 { log.Println("starting draft", tableId) go func() { t.start() 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]ca) rates.lock.RLock() for card := range strings.SplitSeq(cards, "\n") { if ca, found := rates.rates[card]; found { specific[card] = *ca } } 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]*ca)} flag.Parse() if err := rates.loadFrom(*ratesFile); err != nil && !errors.Is(err, os.ErrNotExist) { log.Fatal(err) } wsServer := websocket.Server{ Handshake: func(*websocket.Config, *http.Request) error { return nil }, Handler: joinDraft, } http.HandleFunc("/join", wsServer.ServeHTTP) 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) } }