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 | |
| parent | 3f4c96b24697ac92901f26afdf4f65faddae8b23 (diff) | |
| download | muhqs-game-9b7d7cd127499eb2b1485df3cd5e74caa31dbaf1.tar.gz muhqs-game-9b7d7cd127499eb2b1485df3cd5e74caa31dbaf1.zip | |
initial draftsim commit
| -rw-r--r-- | go/.gitignore | 2 | ||||
| -rw-r--r-- | go/Makefile | 9 | ||||
| -rw-r--r-- | go/draftsim/client/main.go | 200 | ||||
| -rw-r--r-- | go/draftsim/common/deadline.go | 30 | ||||
| -rw-r--r-- | go/draftsim/common/joinInfo.go | 19 | ||||
| -rw-r--r-- | go/draftsim/common/joinParams.go | 14 | ||||
| -rw-r--r-- | go/draftsim/server/main.go | 338 | ||||
| -rw-r--r-- | go/draftsim/server/main.go.gorilla | 253 | ||||
| -rw-r--r-- | go/utils/ws_generic.go | 23 | ||||
| -rw-r--r-- | go/utils/ws_wasm.go | 30 | ||||
| -rw-r--r-- | go/utils/ws_wasm.gogopherjs | 18 | ||||
| -rw-r--r-- | html/blog/draftsim.md | 16 |
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. |
