aboutsummaryrefslogtreecommitdiff
path: root/go/draftsim/server
diff options
context:
space:
mode:
authorFlorian Fischer <florian.fischer@muhq.space>2025-07-28 18:13:49 +0200
committerFlorian Fischer <florian.fischer@muhq.space>2025-07-28 18:36:00 +0200
commit9b7d7cd127499eb2b1485df3cd5e74caa31dbaf1 (patch)
treec127906c3fee01d3d596ed65b3d2520a703ccef2 /go/draftsim/server
parent3f4c96b24697ac92901f26afdf4f65faddae8b23 (diff)
downloadmuhqs-game-9b7d7cd127499eb2b1485df3cd5e74caa31dbaf1.tar.gz
muhqs-game-9b7d7cd127499eb2b1485df3cd5e74caa31dbaf1.zip
initial draftsim commit
Diffstat (limited to 'go/draftsim/server')
-rw-r--r--go/draftsim/server/main.go338
-rw-r--r--go/draftsim/server/main.go.gorilla253
2 files changed, 591 insertions, 0 deletions
diff --git a/go/draftsim/server/main.go b/go/draftsim/server/main.go
new file mode 100644
index 00000000..57f2c061
--- /dev/null
+++ b/go/draftsim/server/main.go
@@ -0,0 +1,338 @@
+// 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)
+ }
+}
diff --git a/go/draftsim/server/main.go.gorilla b/go/draftsim/server/main.go.gorilla
new file mode 100644
index 00000000..7b0b9a7b
--- /dev/null
+++ b/go/draftsim/server/main.go.gorilla
@@ -0,0 +1,253 @@
+// 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)
+ }
+}