aboutsummaryrefslogtreecommitdiff
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
parent3f4c96b24697ac92901f26afdf4f65faddae8b23 (diff)
downloadmuhqs-game-9b7d7cd127499eb2b1485df3cd5e74caa31dbaf1.tar.gz
muhqs-game-9b7d7cd127499eb2b1485df3cd5e74caa31dbaf1.zip
initial draftsim commit
-rw-r--r--go/.gitignore2
-rw-r--r--go/Makefile9
-rw-r--r--go/draftsim/client/main.go200
-rw-r--r--go/draftsim/common/deadline.go30
-rw-r--r--go/draftsim/common/joinInfo.go19
-rw-r--r--go/draftsim/common/joinParams.go14
-rw-r--r--go/draftsim/server/main.go338
-rw-r--r--go/draftsim/server/main.go.gorilla253
-rw-r--r--go/utils/ws_generic.go23
-rw-r--r--go/utils/ws_wasm.go30
-rw-r--r--go/utils/ws_wasm.gogopherjs18
-rw-r--r--html/blog/draftsim.md16
12 files changed, 948 insertions, 4 deletions
diff --git a/go/.gitignore b/go/.gitignore
index 5ccfd5ad..9fc94036 100644
--- a/go/.gitignore
+++ b/go/.gitignore
@@ -1,4 +1,6 @@
dummy-ui/dummy-ui
client/client
server/server
+draftsim/client/client
+draftsim/server/server
**.wasm
diff --git a/go/Makefile b/go/Makefile
index 20ef58e9..01240dad 100644
--- a/go/Makefile
+++ b/go/Makefile
@@ -1,20 +1,21 @@
-PACKAGES := activities assets game server client dummy-ui
+PACKAGES := activities assets game server client dummy-ui draftsim/server draftsim/client ui
SHELL := bash
-APPS := server client dummy-ui
+APPS := server client dummy-ui draftsim/client draftsim/server
OBJS := $(foreach app,$(APPS),$(app)/$(app))
-WASM := client/client.wasm webtools/webtools.wasm dummy-ui/dummy-ui.wasm
+WASM := client/client.wasm webtools/webtools.wasm dummy-ui/dummy-ui.wasm draftsim/client/client.wasm
GOFMT ?= gofumpt -l -w .
.PHONY: all fmt test check $(OBJS) $(WASM)
all: $(OBJS) $(WASM)
-.PHONY: client server dummy-ui
+.PHONY: client server dummy-ui draftsim
client: client/client
server: server/server
dummy-ui: dummy-ui/dummy-ui
+draftsim: draftsim/server draftsim/client
$(OBJS):
@echo Building $(@F)
diff --git a/go/draftsim/client/main.go b/go/draftsim/client/main.go
new file mode 100644
index 00000000..d62ce544
--- /dev/null
+++ b/go/draftsim/client/main.go
@@ -0,0 +1,200 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+ "strings"
+
+ "github.com/hajimehoshi/ebiten/v2"
+
+ "muhq.space/muhqs-game/go/activities"
+ "muhq.space/muhqs-game/go/draftsim/common"
+ "muhq.space/muhqs-game/go/font"
+ "muhq.space/muhqs-game/go/game"
+ "muhq.space/muhqs-game/go/ui"
+ "muhq.space/muhqs-game/go/utils"
+)
+
+const (
+ // SERVER_URL = "draft.muhq.space"
+ SERVER_URL = "localhost:8080"
+)
+
+type app struct {
+ width, height int
+}
+
+func (a *app) Layout(int, int) (int, int) {
+ return a.width, a.height
+}
+
+func (a *app) Update() error {
+ return activities.CurActivity().Update()
+}
+
+func (a *app) Draw(screen *ebiten.Image) {
+ activities.CurActivity().Draw(screen)
+}
+
+type startActivity struct {
+ ui.Collection
+}
+
+func (a *startActivity) Layout(int, int) (int, int) {
+ return a.Collection.Layout()
+}
+
+func (a *startActivity) Update() error {
+ if err := ui.Update(); err != nil {
+ return err
+ }
+ return a.Collection.Update()
+}
+
+func genHandleNotification(wdth, hght int, ctrl game.PlayerControl) func(game.PlayerNotification) {
+ return func(n game.PlayerNotification) {
+ if n.Notification != game.DraftPickPrompt {
+ log.Fatalf("Unexpexted notification: %s\n", n)
+ }
+ // Pop the Session
+ activities.PopActivity()
+ activities.PushActivity(activities.StartNewRemoteDraft(wdth, hght, ctrl, n))
+ }
+}
+
+func newStartActivity(app *app) *startActivity {
+ a := &startActivity{ui.Collection{Width: app.width, Height: app.height}}
+
+ nameInput := ui.NewTextInput(
+ a.Width/2-150,
+ 50,
+ 300,
+ 40,
+ "name")
+ a.AddWidget(nameInput)
+
+ descInput := ui.NewTextInput(
+ a.Width/2-150,
+ 100,
+ 300,
+ 40,
+ "3x[2;8]")
+ a.AddWidget(descInput)
+
+ setInput := ui.NewTextInput(
+ a.Width/2-150,
+ 150,
+ 300,
+ 40,
+ "base,magic,equipments")
+ a.AddWidget(setInput)
+
+ aiNInput := ui.NewNumberChoice(
+ (a.Width-ui.NUMBER_CHOICE_WIDTH_MIN)/2,
+ 200,
+ 1,
+ nil,
+ )
+ a.AddWidget(aiNInput)
+
+ a.AddWidget(ui.NewSimpleButton(
+ a.Width/2-100,
+ 250,
+ 200,
+ 40,
+ "local",
+ func(*ui.SimpleButton) {
+ d, err := activities.StartNewLocalDraft(
+ a.Width,
+ a.Height,
+ nameInput.Text(),
+ descInput.TextOrLabel(),
+ setInput.TextOrLabel(),
+ aiNInput.GetChoosen(),
+ )
+
+ if err == nil {
+ activities.PushActivity(d)
+ return
+ }
+
+ // TODO: hint errors
+ }))
+
+ sessionInput := ui.NewTextInput(
+ a.Width/2-150,
+ 300,
+ 300,
+ 40,
+ "session")
+ a.AddWidget(sessionInput)
+
+ a.AddWidget(ui.NewSimpleButton(
+ a.Width/2-100,
+ 350,
+ 200,
+ 40,
+ "connect",
+ func(*ui.SimpleButton) {
+ playerName := nameInput.Text()
+ player := game.NewDraftPlayer(playerName)
+ session := sessionInput.Text()
+ joinArgs := common.JoinParams{
+ Table: session,
+ Name: playerName,
+ Desc: fmt.Sprintf("%s:%s", descInput.TextOrLabel(), setInput.TextOrLabel()),
+ NPlayers: aiNInput.GetChoosen() + 1,
+ }
+
+ joinUrl := fmt.Sprintf("ws://%s/join", SERVER_URL)
+ conn, err := utils.WsDial(joinUrl)
+ if err != nil {
+ log.Fatalf("dial failed: %s\n", err)
+ }
+
+ err = utils.WsJsonSend(conn, joinArgs)
+ if err != nil {
+ log.Fatalf("sending joinArgs failed: %s\n", err)
+ }
+
+ var joinInfo common.JoinInfo
+ err = utils.WsJsonRecv(conn, &joinInfo)
+ if err != nil {
+ log.Fatalf("receiving joinInfo failed: %s\n", err)
+ }
+
+ ctrl := game.NewRWPlayerControl(player, conn)
+ startFunc := func() {
+ startUrl := fmt.Sprintf("https://%s/start", SERVER_URL)
+ r, err := http.Post(startUrl, "text/plain", strings.NewReader(session))
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer r.Body.Close()
+ }
+
+ s := activities.NewSession(
+ a.Width, a.Height,
+ fmt.Sprintf("Draft session %s", session),
+ ctrl,
+ startFunc,
+ genHandleNotification(a.Width, a.Height, ctrl))
+
+ for _, p := range joinInfo.Players {
+ s.AddPlayer(p)
+ }
+
+ activities.PushActivity(s)
+ }))
+ return a
+}
+
+func main() {
+ app := &app{width: 800, height: 1200}
+ font.InitFont()
+ activities.PushActivity(newStartActivity(app))
+ if err := ebiten.RunGame(app); err != nil {
+ log.Fatalln(err)
+ }
+}
diff --git a/go/draftsim/common/deadline.go b/go/draftsim/common/deadline.go
new file mode 100644
index 00000000..9a8ce9d8
--- /dev/null
+++ b/go/draftsim/common/deadline.go
@@ -0,0 +1,30 @@
+package common
+
+import (
+ "strings"
+ "time"
+)
+
+type DeadlineFunc func(cards int) time.Time
+
+func genFixedDeadline(timeout int) DeadlineFunc {
+ return func(int) time.Time {
+ return time.Now().Add(time.Duration(timeout)*time.Second)
+ }
+}
+
+func genDynamicDeadline(perCard int) DeadlineFunc {
+ return func(cards int) time.Time {
+ return time.Now().Add(time.Duration(perCard * cards) * time.Second)
+ }
+}
+
+// GenDeadline returns a function returning a deadline from a description string.
+func GenDeadline(desc string) (DeadlineFunc, error) {
+ if strings.Contains(desc, "x") {
+ perCard := 5
+ return genDynamicDeadline(perCard), nil
+ } else {
+ return genFixedDeadline(30), nil
+ }
+}
diff --git a/go/draftsim/common/joinInfo.go b/go/draftsim/common/joinInfo.go
new file mode 100644
index 00000000..851c62e1
--- /dev/null
+++ b/go/draftsim/common/joinInfo.go
@@ -0,0 +1,19 @@
+package common
+
+import (
+ "muhq.space/muhqs-game/go/game"
+)
+
+// JoinInfo contains the information about joined draft provided by the server.
+type JoinInfo struct {
+ // Players contains the player names in the deaft.
+ Players []string
+ // Desc contains the description of the draft.
+ Desc string
+ // Sets specify the drafted card sets.
+ Sets []game.SetIdentifier
+ // PlayerId can be used to reconnect to the draft.
+ PlayerId int
+ // Deadline specifies the enforced deadline.
+ Deadline string
+}
diff --git a/go/draftsim/common/joinParams.go b/go/draftsim/common/joinParams.go
new file mode 100644
index 00000000..77d93b71
--- /dev/null
+++ b/go/draftsim/common/joinParams.go
@@ -0,0 +1,14 @@
+package common
+
+// JoinParams are the parameters send by the client to join a draft.
+// The parameters are sent after upgrading the http connection to the `/join` endpoint to a websocket.
+type JoinParams struct {
+ // Table identifies the draft to join.
+ Table string
+ // Name is the name of the joining player.
+ Name string
+ // Desc specifies the draft description if joining a new draft.
+ Desc string
+ // NPlayers specifies the number of players in the draft.
+ NPlayers int
+}
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)
+ }
+}
diff --git a/go/utils/ws_generic.go b/go/utils/ws_generic.go
new file mode 100644
index 00000000..2b5ddd95
--- /dev/null
+++ b/go/utils/ws_generic.go
@@ -0,0 +1,23 @@
+//go:build !wasm
+// +build !wasm
+
+package utils
+
+import (
+ "golang.org/x/net/websocket"
+)
+
+func WsDial(url string) (c *websocket.Conn, err error) {
+ c, err = websocket.Dial(url, "", "draft.muhq.space")
+ return
+}
+
+func WsJsonSend(c *websocket.Conn, o any) (err error) {
+ err = websocket.JSON.Send(c, o)
+ return
+}
+
+func WsJsonRecv(c *websocket.Conn, o any) (err error) {
+ err = websocket.JSON.Receive(c, o)
+ return
+}
diff --git a/go/utils/ws_wasm.go b/go/utils/ws_wasm.go
new file mode 100644
index 00000000..562f6791
--- /dev/null
+++ b/go/utils/ws_wasm.go
@@ -0,0 +1,30 @@
+package utils
+
+import (
+ "net"
+ "encoding/json"
+ "context"
+ "github.com/coder/websocket"
+)
+
+func WsDial(addr string) (c net.Conn, err error) {
+ ctx := context.Background()
+ ws, _, err := websocket.Dial(ctx, addr, nil)
+ if err != nil {
+ return nil, err
+ }
+ c = websocket.NetConn(ctx, ws, websocket.MessageText)
+ return
+}
+
+func WsJsonSend(c net.Conn, o any) (err error) {
+ enc := json.NewEncoder(c)
+ err = enc.Encode(o)
+ return
+}
+
+func WsJsonRecv(c net.Conn, o any) (err error) {
+ dec := json.NewDecoder(c)
+ err = dec.Decode(o)
+ return
+}
diff --git a/go/utils/ws_wasm.gogopherjs b/go/utils/ws_wasm.gogopherjs
new file mode 100644
index 00000000..84852c54
--- /dev/null
+++ b/go/utils/ws_wasm.gogopherjs
@@ -0,0 +1,18 @@
+package utils
+
+import (
+ "encoding/json"
+ "net"
+ "github.com/gopherjs/websocket"
+)
+
+func WsDial(addr string) (c net.Conn, err error) {
+ c, err = websocket.Dial(addr)
+ return
+}
+
+func WsJsonSend(c net.Conn, o any) (err error) {
+ enc := json.NewEncoder(c)
+ err = enc.Encode(o)
+ return
+}
diff --git a/html/blog/draftsim.md b/html/blog/draftsim.md
new file mode 100644
index 00000000..8d86bd80
--- /dev/null
+++ b/html/blog/draftsim.md
@@ -0,0 +1,16 @@
+---
+title: Draft Simulation
+---
+
+Muhq's Game now features an interactive draft simulation at [draft.muhq.space](https://draft.muhq.space).
+
+## Draft Rates
+
+In order to track the perceived power level of individual cards, the draft simulation tracks the rate when cards are picked.
+This draft rate is presented in the card listings as `DR: [0-1)`.
+
+The scale goes from 0, never picked, to close to 1, picked as the first card.
+To achieve a normalized scale with potentially different pack sizes the amount of alternative is divided by the starting pack ability to pick any other card by chance is used ($1 - (1 / pack size)$).
+This means picking for example {{Ritual!}} out of pack of cards gives a lower rating than picking it from a pack of 12 cards ($7/8 < 11/12$).
+
+Note that this rating does not allow values of `1` and does not distinguished between the last card from a pack without choice and cards not picked at all.