diff options
| author | Florian Fischer <florian.fischer@muhq.space> | 2025-07-28 18:13:49 +0200 |
|---|---|---|
| committer | Florian Fischer <florian.fischer@muhq.space> | 2025-07-28 18:36:00 +0200 |
| commit | 9b7d7cd127499eb2b1485df3cd5e74caa31dbaf1 (patch) | |
| tree | c127906c3fee01d3d596ed65b3d2520a703ccef2 /go/draftsim/server | |
| parent | 3f4c96b24697ac92901f26afdf4f65faddae8b23 (diff) | |
| download | muhqs-game-9b7d7cd127499eb2b1485df3cd5e74caa31dbaf1.tar.gz muhqs-game-9b7d7cd127499eb2b1485df3cd5e74caa31dbaf1.zip | |
initial draftsim commit
Diffstat (limited to 'go/draftsim/server')
| -rw-r--r-- | go/draftsim/server/main.go | 338 | ||||
| -rw-r--r-- | go/draftsim/server/main.go.gorilla | 253 |
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) + } +} |
