From 91e42d477ffc2f4f34c3b82925812a34fbdfd859 Mon Sep 17 00:00:00 2001 From: Aaron Bennett <10927621+abennett@users.noreply.github.com> Date: Mon, 13 Jan 2025 14:47:30 +0000 Subject: [PATCH 1/6] move dice rolls into separate file --- pkg/dice.go | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++ pkg/ttt.go | 66 ++-------------------------------------------------- 2 files changed, 69 insertions(+), 64 deletions(-) create mode 100644 pkg/dice.go diff --git a/pkg/dice.go b/pkg/dice.go new file mode 100644 index 0000000..045a380 --- /dev/null +++ b/pkg/dice.go @@ -0,0 +1,67 @@ +package pkg + +import ( + "errors" + "fmt" + "math" + "math/rand" + "regexp" + "strconv" + "strings" +) + +type DiceRoll struct { + Count int + DiceSides int + Modifier int +} + +func ParseDiceRoll(diceRoll string) (DiceRoll, error) { + // d[+|-] + // (\d+)d(\d+)??? + var d DiceRoll + r := regexp.MustCompile(`(\d+)d(\d+)(\+\d+|\-\d+)?`) + matches := r.FindStringSubmatch(diceRoll) + if len(matches) < 3 { + return d, errors.New("string does not match expression") + } + parsed := make([]int, 3) + for idx, s := range matches[1:] { + if s == "" { + parsed[idx] = 0 + continue + } + v, err := strconv.Atoi(s) + if err != nil { + return d, err + } + parsed[idx] = v + } + return DiceRoll{ + Count: parsed[0], + DiceSides: parsed[1], + Modifier: parsed[2], + }, nil +} + +func (dr DiceRoll) String() string { + var builder strings.Builder + base := fmt.Sprintf("%dd%d", dr.Count, dr.DiceSides) + builder.WriteString(base) + if dr.Modifier > 0 { + builder.WriteString("+" + strconv.Itoa(dr.Modifier)) + } + if dr.Modifier < 0 { + absolute := int(math.Abs(float64(dr.Modifier))) + builder.WriteString("-" + strconv.Itoa(absolute)) + } + return builder.String() +} + +func (dr DiceRoll) Roll() int { + var result int + for x := 0; x < dr.Count; x++ { + result += rand.Intn(dr.DiceSides) + 1 + } + return result + dr.Modifier +} diff --git a/pkg/ttt.go b/pkg/ttt.go index 1c6c62d..450329e 100644 --- a/pkg/ttt.go +++ b/pkg/ttt.go @@ -4,14 +4,8 @@ import ( "context" "encoding/json" "errors" - "fmt" "log/slog" - "math" - "math/rand" "net/http" - "regexp" - "strconv" - "strings" "sync" "time" @@ -20,7 +14,7 @@ import ( ) const ( - PING_INTERVAL = time.Second + PingInterval = time.Second ) var ( @@ -113,7 +107,7 @@ func (r *Room) NewSession(ctx context.Context, name string, conn *websocket.Conn } go func() { - ticker := time.NewTicker(PING_INTERVAL) + ticker := time.NewTicker(PingInterval) defer func() { doneCh <- struct{}{} @@ -192,62 +186,6 @@ type RollResult struct { Result int `json:"result"` } -type DiceRoll struct { - Count int - DiceSides int - Modifier int -} - -func ParseDiceRoll(diceRoll string) (DiceRoll, error) { - // d[+|-] - // (\d+)d(\d+)??? - var d DiceRoll - r := regexp.MustCompile(`(\d+)d(\d+)(\+\d+|\-\d+)?`) - matches := r.FindStringSubmatch(diceRoll) - if len(matches) < 3 { - return d, errors.New("string does not match expression") - } - parsed := make([]int, 3) - for idx, s := range matches[1:] { - if s == "" { - parsed[idx] = 0 - continue - } - v, err := strconv.Atoi(s) - if err != nil { - return d, err - } - parsed[idx] = v - } - return DiceRoll{ - Count: parsed[0], - DiceSides: parsed[1], - Modifier: parsed[2], - }, nil -} - -func (dr DiceRoll) String() string { - var builder strings.Builder - base := fmt.Sprintf("%dd%d", dr.Count, dr.DiceSides) - builder.WriteString(base) - if dr.Modifier > 0 { - builder.WriteString("+" + strconv.Itoa(dr.Modifier)) - } - if dr.Modifier < 0 { - absolute := int(math.Abs(float64(dr.Modifier))) - builder.WriteString("-" + strconv.Itoa(absolute)) - } - return builder.String() -} - -func (dr DiceRoll) Roll() int { - var result int - for x := 0; x < dr.Count; x++ { - result += rand.Intn(dr.DiceSides) + 1 - } - return result + dr.Modifier -} - func (s *Server) NewRoom(name string) (*Room, error) { s.rw.Lock() defer s.rw.Unlock() From 3f91509f54fee249f085db6e9d352be94d9d4318 Mon Sep 17 00:00:00 2001 From: Aaron Bennett <10927621+abennett@users.noreply.github.com> Date: Mon, 13 Jan 2025 17:13:51 +0000 Subject: [PATCH 2/6] move to msgpack and a reader and writer goroutine per client --- client.go | 26 +++++-- go.mod | 2 + go.sum | 4 ++ pkg/ttt.go | 196 ++++++++++++++++++++++++++++++++--------------------- 4 files changed, 145 insertions(+), 83 deletions(-) diff --git a/client.go b/client.go index 7e74d80..d4d8fe1 100644 --- a/client.go +++ b/client.go @@ -17,6 +17,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/gorilla/websocket" + "github.com/vmihailenco/msgpack/v5" "github.com/abennett/ttt/pkg" ) @@ -146,9 +147,13 @@ func (c client) Init() tea.Cmd { req := pkg.RollRequest{ User: c.user, } - err = conn.WriteJSON(req) + b, err := msgpack.Marshal(req) if err != nil { - return errorCmd(fmt.Errorf("unable to write json: %w", err)) + return errorCmd(fmt.Errorf("failed to marshal: %w", err)) + } + err = conn.WriteMessage(websocket.BinaryMessage, b) + if err != nil { + return errorCmd(fmt.Errorf("unable to write server: %w", err)) } go waitClose(conn, c.done) @@ -166,11 +171,10 @@ func resultsToRows(rrs []pkg.RollResult) []table.Row { } func (c *client) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - slog.Debug("updating model", "msg", msg) switch msg := msg.(type) { case []pkg.RollResult: slog.Debug("roll result") - c.table.SetHeight(len(msg)) + c.table.SetHeight(len(msg) + 1) c.table.SetRows(resultsToRows(msg)) return c, c.readUpdate() case tea.KeyMsg: @@ -183,6 +187,8 @@ func (c *client) Update(msg tea.Msg) (tea.Model, tea.Cmd) { slog.Error("exiting for error", "error", msg) c.err = msg return c, tea.Quit + default: + slog.Debug("unsupported message", "msg", msg) } slog.Debug("no update") return c, nil @@ -223,17 +229,23 @@ func updateLoop(conn *websocket.Conn, updates chan<- []pkg.RollResult) { slog.Debug("running update loop") var currentVersion int for { - var room pkg.Room - err := conn.ReadJSON(&room) + _, b, err := conn.ReadMessage() if err != nil { slog.Error(err.Error()) return } - slog.Debug("message recieved", "version", room.Version) + var room pkg.Room + err = msgpack.Unmarshal(b, &room) + if err != nil { + slog.Error("failed parsing room", "error", err) + return + } + slog.Debug("message recieved", "room", room) if currentVersion == room.Version { slog.Debug("version hasn't changed, continuing") continue } + slog.Debug("new version") rolls := make([]pkg.RollResult, len(room.Rolls)) var idx int diff --git a/go.mod b/go.mod index 55778c8..0471968 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,8 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect diff --git a/go.sum b/go.sum index 3d82735..95f147a 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,10 @@ github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyX github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/ttt.go b/pkg/ttt.go index 450329e..096e12b 100644 --- a/pkg/ttt.go +++ b/pkg/ttt.go @@ -2,7 +2,6 @@ package pkg import ( "context" - "encoding/json" "errors" "log/slog" "net/http" @@ -11,6 +10,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/gorilla/websocket" + "github.com/vmihailenco/msgpack/v5" ) const ( @@ -60,15 +60,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } defer conn.Close() - var rollRequest RollRequest - err = conn.ReadJSON(&rollRequest) - if err != nil { - slog.Error(err.Error()) - return - } - slog.Info("message received", "message", rollRequest) - <-room.NewSession(r.Context(), rollRequest.User, conn) - slog.Info("session ended", "user", rollRequest.User) + + // Keep connection alive + room.RunSession(r.Context(), conn) room.mu.Lock() if len(room.userSessions) == 0 { @@ -80,71 +74,133 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { room.mu.Unlock() } +type RollRequest struct { + User string `msgpack:"user"` + Roll string `msgpack:"roll"` +} + +type RollResult struct { + User string `msgpack:"user"` + Result int `msgpack:"result"` +} + type Room struct { - mu sync.Mutex + mu *sync.Mutex userSessions map[string]userSession - Version int `json:"version"` - Name string `json:"name"` - Dice string `json:"required_roll"` - Rolls map[string]RollResult `json:"rolls"` + Version int `msgpack:"version"` + Name string `msgpack:"name"` + Dice string `msgpack:"required_roll"` + Rolls map[string]RollResult `msgpack:"rolls"` +} + +type userSession struct { + name string + writeCh chan []byte + wg *sync.WaitGroup } -func (r *Room) NewSession(ctx context.Context, name string, conn *websocket.Conn) <-chan struct{} { - doneCh := make(chan struct{}, 1) +func (r *Room) startUserSession(ctx context.Context, session userSession, conn *websocket.Conn) { + // Add to the waitGroup outside of goroutines here to avoid race condition on Add + session.wg.Add(2) + go r.userReadLoop(ctx, session, conn) + go r.userWriteLoop(ctx, session, conn) +} + +func (r *Room) userReadLoop(ctx context.Context, session userSession, conn *websocket.Conn) { + defer session.wg.Done() + for { + t, _, err := conn.ReadMessage() + if closeErr, ok := err.(*websocket.CloseError); ok { + if closeErr.Code == websocket.CloseNormalClosure { + return + } + } + if err != nil { + slog.Error("failure in user read loop", "error", err) + return + } + + switch t { + case websocket.CloseMessage: + slog.Info("close message received") + // do something + case websocket.BinaryMessage: + slog.Info("binary message received") + // handle + } + } +} + +func (r *Room) userWriteLoop(ctx context.Context, session userSession, conn *websocket.Conn) { + ticker := time.NewTicker(PingInterval) + defer func() { + r.mu.Lock() + delete(r.userSessions, session.name) + r.mu.Unlock() + + ticker.Stop() + <-ticker.C + session.wg.Done() + }() +EXIT: + for { + select { + case <-ctx.Done(): + break EXIT + case b := <-session.writeCh: + slog.Info("writing message", "user", session.name) + err := conn.WriteMessage(websocket.BinaryMessage, b) + if err != nil { + slog.Error(err.Error()) + return + } + case <-ticker.C: + err := conn.WriteMessage(websocket.PingMessage, []byte{}) + if err == websocket.ErrCloseSent { + return + } + if err != nil { + slog.Error("ping failed", "error", err) + return + } + } + } +} + +func (r *Room) RunSession(ctx context.Context, conn *websocket.Conn) { + _, b, err := conn.ReadMessage() + if err != nil { + slog.Error("failed to read initial message", "error", err) + return + } + var req RollRequest + if err = msgpack.Unmarshal(b, &req); err != nil { + slog.Error("failed to parse initial message", "error", err, "payload", string(b)) + return + } + name := req.User writeCh := make(chan []byte, 1) session := userSession{ - name: name, + wg: new(sync.WaitGroup), + name: req.User, writeCh: writeCh, } r.mu.Lock() r.userSessions[name] = session r.mu.Unlock() - err := r.Roll(name) + + r.startUserSession(ctx, session, conn) + + err = r.Roll(session.name) if err != nil { slog.Error(err.Error()) - return nil - } - - go func() { - ticker := time.NewTicker(PingInterval) - defer func() { - doneCh <- struct{}{} - - r.mu.Lock() - delete(r.userSessions, name) - r.mu.Unlock() - - ticker.Stop() - <-ticker.C - }() - EXIT: - for { - select { - case <-ctx.Done(): - break EXIT - case b := <-writeCh: - err := conn.WriteMessage(websocket.TextMessage, b) - if err != nil { - slog.Error(err.Error()) - return - } - case <-ticker.C: - err := conn.WriteMessage(websocket.PingMessage, []byte{}) - if err != nil { - slog.Error("ping failed", "error", err) - return - } - } - } - }() - return doneCh -} + return + } -func (r *Room) RemoveSession(name string) { - r.mu.Lock() - delete(r.userSessions, name) - r.mu.Unlock() + session.wg.Wait() + slog.Info("closing session", "user", name) + return } func (r *Room) Roll(user string) error { @@ -154,38 +210,25 @@ func (r *Room) Roll(user string) error { if err != nil { return err } + slog.Info("rolling", "user", user) rollResult := RollResult{ User: user, Result: dice.Roll(), } r.Rolls[user] = rollResult r.Version++ - b, err := json.Marshal(r) + b, err := msgpack.Marshal(r) if err != nil { slog.Error("failed marshalling room", "error", err) return err } for _, us := range r.userSessions { + slog.Info("pushing update", "user", us.name, "version", r.Version) us.writeCh <- b } return nil } -type userSession struct { - name string - writeCh chan<- []byte -} - -type RollRequest struct { - User string `json:"user"` - Roll string `json:"roll"` -} - -type RollResult struct { - User string `json:"user"` - Result int `json:"result"` -} - func (s *Server) NewRoom(name string) (*Room, error) { s.rw.Lock() defer s.rw.Unlock() @@ -194,6 +237,7 @@ func (s *Server) NewRoom(name string) (*Room, error) { return nil, ErrRoomExists } s.Rooms[name] = &Room{ + mu: new(sync.Mutex), userSessions: make(map[string]userSession), Version: 0, Dice: "1d20", From fc8a23253b9c6832b43651f0de2d4d50dc630eab Mon Sep 17 00:00:00 2001 From: Aaron Bennett <10927621+abennett@users.noreply.github.com> Date: Mon, 13 Jan 2025 19:32:10 +0000 Subject: [PATCH 3/6] tweaks --- client.go | 10 ++++------ go.mod | 2 +- pkg/dice.go | 9 +++++---- pkg/ttt.go | 3 +-- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/client.go b/client.go index d4d8fe1..5ad04a1 100644 --- a/client.go +++ b/client.go @@ -60,6 +60,7 @@ func connectLoop(wsUrl string) (*websocket.Conn, error) { slog.Debug("redirecting", "location", wsUrl) continue } + defer resp.Body.Close() return conn, nil } @@ -137,7 +138,7 @@ func errorCmd(err error) tea.Cmd { } } -func (c client) Init() tea.Cmd { +func (c *client) Init() tea.Cmd { slog.Debug("running Init") conn, err := connectLoop(c.endpoint) if err != nil { @@ -194,7 +195,7 @@ func (c *client) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, nil } -func (c client) View() string { +func (c *client) View() string { slog.Debug("rerendering view") if c.err != nil { return fmt.Sprintln(c.err) @@ -202,7 +203,7 @@ func (c client) View() string { return baseStyle.Render(c.table.View()) + "\n" } -func (c client) readUpdate() tea.Cmd { +func (c *client) readUpdate() tea.Cmd { slog.Debug("reading update") return func() tea.Msg { slog.Debug("reading from channel") @@ -253,9 +254,6 @@ func updateLoop(conn *websocket.Conn, updates chan<- []pkg.RollResult) { rolls[idx] = rr idx++ } - slices.SortFunc(rolls, func(a, b pkg.RollResult) int { - return cmp.Compare(b.Result, a.Result) - }) slog.Debug("pushing rolls on channel") updates <- rolls currentVersion = room.Version diff --git a/go.mod b/go.mod index 0471968..964f5a6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/abennett/ttt -go 1.21.0 +go 1.22 require ( github.com/charmbracelet/bubbles v0.20.0 diff --git a/pkg/dice.go b/pkg/dice.go index 045a380..0d249c2 100644 --- a/pkg/dice.go +++ b/pkg/dice.go @@ -4,12 +4,14 @@ import ( "errors" "fmt" "math" - "math/rand" + "math/rand/v2" "regexp" "strconv" "strings" ) +var diceRegex = regexp.MustCompile(`(\d+)d(\d+)(\+\d+|\-\d+)?`) + type DiceRoll struct { Count int DiceSides int @@ -20,8 +22,7 @@ func ParseDiceRoll(diceRoll string) (DiceRoll, error) { // d[+|-] // (\d+)d(\d+)??? var d DiceRoll - r := regexp.MustCompile(`(\d+)d(\d+)(\+\d+|\-\d+)?`) - matches := r.FindStringSubmatch(diceRoll) + matches := diceRegex.FindStringSubmatch(diceRoll) if len(matches) < 3 { return d, errors.New("string does not match expression") } @@ -61,7 +62,7 @@ func (dr DiceRoll) String() string { func (dr DiceRoll) Roll() int { var result int for x := 0; x < dr.Count; x++ { - result += rand.Intn(dr.DiceSides) + 1 + result += rand.IntN(dr.DiceSides) + 1 } return result + dr.Modifier } diff --git a/pkg/ttt.go b/pkg/ttt.go index 096e12b..a16094f 100644 --- a/pkg/ttt.go +++ b/pkg/ttt.go @@ -124,7 +124,7 @@ func (r *Room) userReadLoop(ctx context.Context, session userSession, conn *webs switch t { case websocket.CloseMessage: slog.Info("close message received") - // do something + return case websocket.BinaryMessage: slog.Info("binary message received") // handle @@ -200,7 +200,6 @@ func (r *Room) RunSession(ctx context.Context, conn *websocket.Conn) { session.wg.Wait() slog.Info("closing session", "user", name) - return } func (r *Room) Roll(user string) error { From 007e308d05fe2ea611a25d8884b9c4b7c5a4eb0e Mon Sep 17 00:00:00 2001 From: Aaron Bennett <10927621+abennett@users.noreply.github.com> Date: Mon, 13 Jan 2025 14:42:55 -0500 Subject: [PATCH 4/6] fixes --- Dockerfile | 2 +- client.go | 2 -- go.mod | 2 +- go.sum | 8 ++++++++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2449ed7..580b1d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:alpine as builder +FROM golang:1.23-alpine AS builder WORKDIR /build COPY go.mod go.sum ./ diff --git a/client.go b/client.go index 5ad04a1..2365fa9 100644 --- a/client.go +++ b/client.go @@ -1,7 +1,6 @@ package main import ( - "cmp" "context" "errors" "fmt" @@ -9,7 +8,6 @@ import ( "log/slog" "net/url" "os" - "slices" "strconv" "time" diff --git a/go.mod b/go.mod index 964f5a6..397a34f 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/go-chi/chi/v5 v5.2.0 github.com/gorilla/websocket v1.5.3 github.com/peterbourgon/ff/v3 v3.4.0 + github.com/vmihailenco/msgpack/v5 v5.4.1 ) require ( @@ -24,7 +25,6 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect diff --git a/go.sum b/go.sum index 95f147a..8d3db7c 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAM github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= @@ -36,9 +38,13 @@ github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc= github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= @@ -51,3 +57,5 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From ea670d91bc2460637a7ef9214286cc7cd25d89e5 Mon Sep 17 00:00:00 2001 From: Aaron Bennett <10927621+abennett@users.noreply.github.com> Date: Mon, 13 Jan 2025 16:02:28 -0500 Subject: [PATCH 5/6] rework state representation --- client.go | 2 +- pkg/ttt.go | 75 ++++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/client.go b/client.go index 2365fa9..a13d2dd 100644 --- a/client.go +++ b/client.go @@ -233,7 +233,7 @@ func updateLoop(conn *websocket.Conn, updates chan<- []pkg.RollResult) { slog.Error(err.Error()) return } - var room pkg.Room + var room pkg.RoomState err = msgpack.Unmarshal(b, &room) if err != nil { slog.Error("failed parsing room", "error", err) diff --git a/pkg/ttt.go b/pkg/ttt.go index a16094f..23bd797 100644 --- a/pkg/ttt.go +++ b/pkg/ttt.go @@ -1,10 +1,13 @@ package pkg import ( + "cmp" "context" "errors" + "fmt" "log/slog" "net/http" + "slices" "sync" "time" @@ -88,16 +91,23 @@ type Room struct { mu *sync.Mutex userSessions map[string]userSession - Version int `msgpack:"version"` - Name string `msgpack:"name"` - Dice string `msgpack:"required_roll"` - Rolls map[string]RollResult `msgpack:"rolls"` + Version int + Name string + Dice DiceRoll + Rolls map[string]RollResult +} + +type RoomState struct { + Version int `msgpack:"version"` + Name string `msgpack:"name"` + Dice string `msgpack:"required_roll"` + Rolls []RollResult `msgpack:"rolls"` } type userSession struct { + wg *sync.WaitGroup name string writeCh chan []byte - wg *sync.WaitGroup } func (r *Room) startUserSession(ctx context.Context, session userSession, conn *websocket.Conn) { @@ -192,7 +202,11 @@ func (r *Room) RunSession(ctx context.Context, conn *websocket.Conn) { r.startUserSession(ctx, session, conn) - err = r.Roll(session.name) + roll := RollResult{ + User: name, + Result: r.Dice.Roll(), + } + err = r.Update(roll) if err != nil { slog.Error(err.Error()) return @@ -202,25 +216,27 @@ func (r *Room) RunSession(ctx context.Context, conn *websocket.Conn) { slog.Info("closing session", "user", name) } -func (r *Room) Roll(user string) error { +func (r *Room) Update(update any) error { r.mu.Lock() defer r.mu.Unlock() - dice, err := ParseDiceRoll(r.Dice) - if err != nil { + + switch u := update.(type) { + case RollResult: + r.Rolls[u.User] = u + default: + err := fmt.Errorf("unknown update type: %T", update) + slog.Error(err.Error()) return err } - slog.Info("rolling", "user", user) - rollResult := RollResult{ - User: user, - Result: dice.Roll(), - } - r.Rolls[user] = rollResult + r.Version++ - b, err := msgpack.Marshal(r) + + b, err := msgpack.Marshal(r.toState()) if err != nil { slog.Error("failed marshalling room", "error", err) return err } + for _, us := range r.userSessions { slog.Info("pushing update", "user", us.name, "version", r.Version) us.writeCh <- b @@ -228,6 +244,24 @@ func (r *Room) Roll(user string) error { return nil } +func (r *Room) toState() RoomState { + rolls := make([]RollResult, len(r.Rolls)) + var i int + for _, roll := range r.Rolls { + rolls[i] = roll + i++ + } + slices.SortFunc(rolls, func(a, b RollResult) int { + return cmp.Compare(a.Result, b.Result) + }) + return RoomState{ + Version: r.Version, + Name: r.Name, + Dice: r.Dice.String(), + Rolls: rolls, + } +} + func (s *Server) NewRoom(name string) (*Room, error) { s.rw.Lock() defer s.rw.Unlock() @@ -239,9 +273,12 @@ func (s *Server) NewRoom(name string) (*Room, error) { mu: new(sync.Mutex), userSessions: make(map[string]userSession), Version: 0, - Dice: "1d20", - Name: name, - Rolls: map[string]RollResult{}, + Dice: DiceRoll{ + Count: 1, + DiceSides: 20, + }, + Name: name, + Rolls: map[string]RollResult{}, } return s.Rooms[name], nil } From b23166259b53f3caf78cdd618471c8a74c8f2dfd Mon Sep 17 00:00:00 2001 From: Aaron Bennett <10927621+abennett@users.noreply.github.com> Date: Mon, 13 Jan 2025 16:12:13 -0500 Subject: [PATCH 6/6] reverse sort order --- pkg/ttt.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/ttt.go b/pkg/ttt.go index 23bd797..655a6d3 100644 --- a/pkg/ttt.go +++ b/pkg/ttt.go @@ -159,7 +159,7 @@ EXIT: case <-ctx.Done(): break EXIT case b := <-session.writeCh: - slog.Info("writing message", "user", session.name) + slog.Debug("writing message", "user", session.name) err := conn.WriteMessage(websocket.BinaryMessage, b) if err != nil { slog.Error(err.Error()) @@ -238,7 +238,7 @@ func (r *Room) Update(update any) error { } for _, us := range r.userSessions { - slog.Info("pushing update", "user", us.name, "version", r.Version) + slog.Debug("pushing update", "user", us.name, "version", r.Version) us.writeCh <- b } return nil @@ -252,7 +252,7 @@ func (r *Room) toState() RoomState { i++ } slices.SortFunc(rolls, func(a, b RollResult) int { - return cmp.Compare(a.Result, b.Result) + return cmp.Compare(b.Result, a.Result) }) return RoomState{ Version: r.Version,