diff --git a/cli.go b/cli.go new file mode 100644 index 0000000..5df3c43 --- /dev/null +++ b/cli.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "errors" + "io" + "log/slog" + "strconv" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/abennett/ttt/pkg/client" + "github.com/abennett/ttt/pkg/messages" +) + +var ErrTooManyRedirects = errors.New("too many redirects") + +var baseStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + Align(lipgloss.Center) + +var columns = []table.Column{ + {Title: "User", Width: 10}, + {Title: "Result", Width: 6}, + {Title: "Done", Width: 6}, +} + +type ttt struct { + client *client.Client + table table.Model +} + +func newTTT(c *client.Client) (*ttt, error) { + t := table.New( + table.WithColumns(columns), + table.WithHeight(0), + table.WithFocused(false), + ) + s := table.DefaultStyles() + s.Header = s.Header.Foreground(lipgloss.Color("#01c5d1")) + s.Selected = s.Selected.Foreground(lipgloss.NoColor{}).Bold(false) + t.SetStyles(s) + return &ttt{ + client: c, + table: t, + }, nil +} + +func (t *ttt) Init() tea.Cmd { + err := t.client.Init() + if err != nil { + panic(err) + } + return func() tea.Msg { + return t.client.ReadUpdate() + } +} + +func resultsToRows(rrs []messages.RollResult) []table.Row { + rows := make([]table.Row, len(rrs)) + for idx, rr := range rrs { + if rr.IsDone { + rows[idx] = table.Row{rr.User, strconv.Itoa(rr.Result), "✅"} + } else { + rows[idx] = table.Row{rr.User, strconv.Itoa(rr.Result), ""} + } + } + return rows +} + +func (t *ttt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case []messages.RollResult: + slog.Debug("roll result") + t.table.SetHeight(len(msg) + 1) + t.table.SetRows(resultsToRows(msg)) + for _, rr := range msg { + if !rr.IsDone { + return t, func() tea.Msg { + return t.client.ReadUpdate() + } + } + } + return t, tea.Quit + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + err := t.client.Close() + if err != nil { + slog.Error("failed to close client", "error", err) + } + return t, tea.Quit + + // Attempt to update done index + case " ": + err := t.client.ToggleDone() + if err != nil { + panic(err) + } + } + case error: + slog.Error("exiting for error", "error", msg) + return t, tea.Quit + default: + slog.Debug("unsupported message", "msg", msg) + } + slog.Debug("no update") + return t, nil +} + +func (t *ttt) View() string { + slog.Debug("rerendering view") + return baseStyle.Render(t.table.View()) + "\n" +} + +func rollRemote(_ context.Context, args []string) error { + c, err := client.New(args[0], args[1], args[2], io.Discard) + if err != nil { + return err + } + ttt, err := newTTT(c) + if err != nil { + return err + } + + _, err = tea.NewProgram(ttt).Run() + return err +} diff --git a/client.go b/client.go deleted file mode 100644 index a13d2dd..0000000 --- a/client.go +++ /dev/null @@ -1,268 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "io" - "log/slog" - "net/url" - "os" - "strconv" - "time" - - "github.com/charmbracelet/bubbles/table" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/gorilla/websocket" - "github.com/vmihailenco/msgpack/v5" - - "github.com/abennett/ttt/pkg" -) - -var ErrTooManyRedirects = errors.New("too many redirects") - -var baseStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - Align(lipgloss.Center) - -var columns = []table.Column{ - {Title: "User", Width: 10}, - {Title: "Result", Width: 6}, -} - -type client struct { - user string - endpoint string - table table.Model - updates chan []pkg.RollResult - done chan struct{} - err error -} - -func connectLoop(wsUrl string) (*websocket.Conn, error) { - for x := 0; x < 3; x++ { - slog.Debug("attempting connection", "url", wsUrl) - conn, resp, err := websocket.DefaultDialer.Dial(wsUrl, nil) - slog.Debug("connection attempted", - "resp", resp, - "error", err) - if err != nil { - if resp != nil { - _, _ = io.Copy(os.Stderr, resp.Body) - } - return nil, err - } - if resp != nil && resp.StatusCode >= 300 && resp.StatusCode < 400 { - wsUrl = resp.Header.Get("Location") - slog.Debug("redirecting", "location", wsUrl) - continue - } - defer resp.Body.Close() - return conn, nil - } - - return nil, ErrTooManyRedirects -} - -func hostUrl(endpoint, room string) (string, error) { - parsed, err := url.Parse(endpoint) - if err != nil { - return "", err - } - var scheme string - switch parsed.Scheme { - case "https", "wss": - scheme = "wss" - case "http", "ws": - scheme = "ws" - default: - return "", fmt.Errorf("%s is not a valid protocol", parsed.Scheme) - } - parsed.Scheme = scheme - parsed.Path = room - return parsed.String(), nil -} - -func setupLogger(user string, logFile *string) error { - logWriter := io.Discard - if logFile != nil && *logFile != "" { - f, err := os.OpenFile(*logFile, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0644) - if err != nil { - return err - } - logWriter = f - } - h := slog.NewTextHandler(logWriter, &slog.HandlerOptions{Level: slog.LevelDebug}) - logger := slog.New(h) - logger = logger.With("user", user) - slog.SetDefault(logger) - return nil -} - -func newClient(host, room, user string) (*client, error) { - err := setupLogger(user, logFile) - if err != nil { - return nil, fmt.Errorf("unable to setup log file: %w", err) - } - - endpoint, err := hostUrl(host, room) - if err != nil { - return nil, err - } - slog.Debug("using endpoint", "endpoint", endpoint) - - t := table.New( - table.WithColumns(columns), - table.WithHeight(0), - table.WithFocused(false), - ) - s := table.DefaultStyles() - s.Header = s.Header.Foreground(lipgloss.Color("#01c5d1")) - s.Selected = s.Selected.Foreground(lipgloss.NoColor{}).Bold(false) - t.SetStyles(s) - return &client{ - user: user, - table: t, - endpoint: endpoint, - updates: make(chan []pkg.RollResult), - done: make(chan struct{}), - }, nil -} - -func errorCmd(err error) tea.Cmd { - return func() tea.Msg { - return err - } -} - -func (c *client) Init() tea.Cmd { - slog.Debug("running Init") - conn, err := connectLoop(c.endpoint) - if err != nil { - return errorCmd(err) - } - - req := pkg.RollRequest{ - User: c.user, - } - b, err := msgpack.Marshal(req) - if err != nil { - 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) - go updateLoop(conn, c.updates) - - return c.readUpdate() -} - -func resultsToRows(rrs []pkg.RollResult) []table.Row { - rows := make([]table.Row, len(rrs)) - for idx, rr := range rrs { - rows[idx] = table.Row{rr.User, strconv.Itoa(rr.Result)} - } - return rows -} - -func (c *client) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case []pkg.RollResult: - slog.Debug("roll result") - c.table.SetHeight(len(msg) + 1) - c.table.SetRows(resultsToRows(msg)) - return c, c.readUpdate() - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q": - c.done <- struct{}{} - return c, tea.Quit - } - case error: - 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 -} - -func (c *client) View() string { - slog.Debug("rerendering view") - if c.err != nil { - return fmt.Sprintln(c.err) - } - return baseStyle.Render(c.table.View()) + "\n" -} - -func (c *client) readUpdate() tea.Cmd { - slog.Debug("reading update") - return func() tea.Msg { - slog.Debug("reading from channel") - update := <-c.updates - slog.Debug("read from channel") - return update - } -} - -func waitClose(conn *websocket.Conn, done <-chan struct{}) { - <-done - slog.Debug("closing connection") - err := conn.WriteControl( - websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), - time.Now().Add(time.Second), - ) - if err != nil { - slog.Error("close control message failed", "error", err) - } -} - -func updateLoop(conn *websocket.Conn, updates chan<- []pkg.RollResult) { - slog.Debug("running update loop") - var currentVersion int - for { - _, b, err := conn.ReadMessage() - if err != nil { - slog.Error(err.Error()) - return - } - var room pkg.RoomState - 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 - for _, rr := range room.Rolls { - rolls[idx] = rr - idx++ - } - slog.Debug("pushing rolls on channel") - updates <- rolls - currentVersion = room.Version - } -} - -func rollRemote(ctx context.Context, args []string) error { - c, err := newClient(args[0], args[1], args[2]) - if err != nil { - return err - } - _, err = tea.NewProgram(c).Run() - return err -} diff --git a/go.mod b/go.mod index 397a34f..3dea580 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,12 @@ module github.com/abennett/ttt -go 1.22 +go 1.23.0 require ( - github.com/charmbracelet/bubbles v0.20.0 - github.com/charmbracelet/bubbletea v1.2.4 - github.com/charmbracelet/lipgloss v1.0.0 - github.com/go-chi/chi/v5 v5.2.0 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.6 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/go-chi/chi/v5 v5.2.2 github.com/gorilla/websocket v1.5.3 github.com/peterbourgon/ff/v3 v3.4.0 github.com/vmihailenco/msgpack/v5 v5.4.1 @@ -14,19 +14,24 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/x/ansi v0.6.0 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.15.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/shoenig/test v1.12.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 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 8d3db7c..aa77eab 100644 --- a/go.sum +++ b/go.sum @@ -4,14 +4,27 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.6.0 h1:qOznutrb93gx9oMiGf7caF7bqqubh6YIM0SWKyA08pA= github.com/charmbracelet/x/ansi v0.6.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 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= @@ -20,6 +33,10 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 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= github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -36,6 +53,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 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= @@ -43,19 +62,29 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN 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/shoenig/test v1.12.1 h1:mLHfnMv7gmhhP44WrvT+nKSxKkPDiNkIuHGdIGI9RLU= +github.com/shoenig/test v1.12.1/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= 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= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 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= diff --git a/main.go b/main.go index 884f9ac..763d0ab 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "github.com/peterbourgon/ff/v3/ffcli" "github.com/abennett/ttt/pkg" + "github.com/abennett/ttt/pkg/server" ) var ( @@ -21,7 +22,6 @@ var ( port = serverFS.Int("port", 8080, "port number of server") clientFS = flag.NewFlagSet("ttt roll", flag.ExitOnError) - logFile = clientFS.String("logfile", "", "log to a file") ) var ( @@ -47,7 +47,7 @@ func serve(ctx context.Context, args []string) error { h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}) slog.SetDefault(slog.New(h)) - server := pkg.NewServer() + server := server.NewServer() r := chi.NewRouter() r.Use(middleware.DefaultLogger) r.Get("/{roomName}", server.ServeHTTP) diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 0000000..b1e25df --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,221 @@ +package client + +import ( + "errors" + "fmt" + "io" + "log/slog" + "net/url" + "os" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/vmihailenco/msgpack/v5" + + "github.com/abennett/ttt/pkg/messages" +) + +var ErrTooManyRedirects = errors.New("too many redirects") + +type Client struct { + mu *sync.Mutex + user string + + conn *websocket.Conn + logger *slog.Logger + messages chan messages.Message + + Room messages.RoomState +} + +func connectLoop(wsUrl string) (*websocket.Conn, error) { + for range 3 { + slog.Debug("attempting connection", "url", wsUrl) + conn, resp, err := websocket.DefaultDialer.Dial(wsUrl, nil) + slog.Debug("connection attempted", + "resp", resp, + "error", err) + if err != nil { + if resp != nil { + _, _ = io.Copy(os.Stderr, resp.Body) + } + return nil, err + } + if resp != nil && resp.StatusCode >= 300 && resp.StatusCode < 400 { + wsUrl = resp.Header.Get("Location") + slog.Debug("redirecting", "location", wsUrl) + continue + } + defer resp.Body.Close() //nolint: errcheck + return conn, nil + } + + return nil, ErrTooManyRedirects +} + +func hostUrl(endpoint, room string) (string, error) { + parsed, err := url.Parse(endpoint) + if err != nil { + return "", err + } + var scheme string + switch parsed.Scheme { + case "https", "wss": + scheme = "wss" + case "http", "ws": + scheme = "ws" + default: + return "", fmt.Errorf("%s is not a valid protocol", parsed.Scheme) + } + parsed.Scheme = scheme + parsed.Path = room + return parsed.String(), nil +} + +func setupLogger(user string, logWriter io.Writer) *slog.Logger { + h := slog.NewTextHandler(logWriter, &slog.HandlerOptions{Level: slog.LevelDebug}) + logger := slog.New(h) + slog.SetDefault(logger) + logger = logger.With("user", user) + return logger +} + +func New(host, room, user string, logWriter io.Writer) (*Client, error) { + logger := setupLogger(user, logWriter) + + endpoint, err := hostUrl(host, room) + if err != nil { + return nil, err + } + slog.Debug("using endpoint", "endpoint", endpoint) + + conn, err := connectLoop(endpoint) + if err != nil { + return nil, err + } + + return &Client{ + mu: new(sync.Mutex), + user: user, + logger: logger, + conn: conn, + messages: make(chan messages.Message, 1), + Room: messages.RoomState{ + Rolls: []messages.RollResult{}, + }, + }, nil +} + +func (c *Client) Init() error { + c.logger.Debug("running Init") + req := messages.Message{ + Type: messages.RollRequestType, + Payload: messages.RollResult{ + User: c.user, + }, + } + b, err := msgpack.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal: %w", err) + } + + c.logger.Debug("writing initial message") + err = c.conn.WriteMessage(websocket.BinaryMessage, b) + if err != nil { + return fmt.Errorf("unable to write server: %w", err) + } + + go c.updateLoop(c.messages) + return nil +} + +func (c *Client) ToggleDone() error { + doneReq := messages.DoneRequest{ + User: c.user, + } + m := messages.Message{ + Type: messages.DoneRequestType, + Version: "1", + Payload: doneReq, + } + b, err := msgpack.Marshal(m) + if err != nil { + return err + } + err = c.conn.WriteMessage(websocket.BinaryMessage, b) + if err != nil { + return err + } + return nil +} + +func (c *Client) ReadUpdate() any { + c.logger.Debug("reading update") + msg := <-c.messages + c.logger.Debug("read from channel") + switch payload := msg.Payload.(type) { + case messages.RoomState: + c.logger.Debug("room state message received", "version", payload.Version) + if payload.Version <= c.Room.Version { + c.logger.Debug("version hasn't changed, continuing") + // return early or something + return payload.Rolls + } + + c.logger.Debug("new version") + c.logger.Debug("pushing rolls on channel") + c.Room = payload + return payload.Rolls + case messages.DoneRequest: + panic("not implemented") + default: + panic(fmt.Sprintf("unexpected messages.Type: %#v", msg.Type)) + } +} + +func (c *Client) Close() error { + slog.Debug("closing connection") + err := c.conn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), + time.Now().Add(time.Second), + ) + if err != nil { + slog.Error("close control message failed", "error", err) + return fmt.Errorf("close control message failed: %w", err) + } + return nil +} + +func (c *Client) updateLoop(updates chan<- messages.Message) { + c.logger.Debug("running update loop") + for { + t, b, err := c.conn.ReadMessage() + if err != nil { + c.logger.Error(err.Error()) + return + } + if t != websocket.BinaryMessage { + continue + } + c.logger.Debug("client recevied message") + var msg messages.Message + err = msgpack.Unmarshal(b, &msg) + if err != nil { + c.logger.Error("failed parsing room", "error", err, "payload", b) + return + } + c.logger.Debug("message recieved", "type", msg.Type) + switch payload := msg.Payload.(type) { + case messages.RoomState: + c.logger.Debug("new room version", "version", payload.Version) + c.mu.Lock() + c.Room = payload + c.mu.Unlock() + default: + panic(fmt.Sprintf("support not implemented for %T", payload)) + } + updates <- msg + } +} diff --git a/pkg/messages/messages.go b/pkg/messages/messages.go new file mode 100644 index 0000000..b319496 --- /dev/null +++ b/pkg/messages/messages.go @@ -0,0 +1,95 @@ +package messages + +import ( + "bytes" + "errors" + "fmt" + + "github.com/vmihailenco/msgpack/v5" +) + +var ( + ErrMessageInvalid = errors.New("message was invalid") + ErrUnknownMessageType = errors.New("unknown message type") +) + +type Type int + +const ( + StateMsgType Type = iota + DoneRequestType + RollRequestType +) + +type Message struct { + _msgpack struct{} `msgpack:",as_array"` //nolint:unused + Type Type `msgpack:"type"` + Version string `msgpack:"version"` + Payload any +} + +func (m *Message) UnmarshalMsgpack(b []byte) error { + decoder := msgpack.NewDecoder(bytes.NewReader(b)) + l, err := decoder.DecodeArrayLen() + if err != nil { + return err + } + if l != 3 { + panic("nope") + } + t, err := decoder.DecodeInt() + if err != nil { + return err + } + m.Type = Type(t) + + if err = decoder.Skip(); err != nil { + return err + } + + switch m.Type { + case DoneRequestType: + var done DoneRequest + if err = decoder.Decode(&done); err != nil { + return err + } + m.Payload = done + case StateMsgType: + var room RoomState + if err = decoder.Decode(&room); err != nil { + return err + } + m.Payload = room + case RollRequestType: + var roll RollRequest + if err = decoder.Decode(&roll); err != nil { + return err + } + m.Payload = roll + default: + panic(fmt.Sprintf("unexpected messages.Type: %#v", m.Type)) + } + return nil +} + +type RoomState struct { + Version int `msgpack:"version"` + Name string `msgpack:"name"` + Dice string `msgpack:"required_roll"` + Rolls []RollResult `msgpack:"rolls"` +} + +type RollRequest struct { + User string `msgpack:"user"` + Roll string `msgpack:"roll"` +} + +type RollResult struct { + User string `msgpack:"user"` + Result int `msgpack:"result"` + IsDone bool `msgpack:"is_done"` +} + +type DoneRequest struct { + User string `msgpack:"user"` +} diff --git a/pkg/server/http.go b/pkg/server/http.go new file mode 100644 index 0000000..e99ccd7 --- /dev/null +++ b/pkg/server/http.go @@ -0,0 +1,20 @@ +package server + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func health(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) +} + +func NewMux(server *Server) http.Handler { + r := chi.NewRouter() + r.Use(middleware.DefaultLogger) + r.Get("/{roomName}", server.ServeHTTP) + r.Get("/health", health) + return r +} diff --git a/pkg/server/room.go b/pkg/server/room.go new file mode 100644 index 0000000..837ac10 --- /dev/null +++ b/pkg/server/room.go @@ -0,0 +1,248 @@ +package server + +import ( + "cmp" + "context" + "fmt" + "log/slog" + "slices" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/vmihailenco/msgpack/v5" + + "github.com/abennett/ttt/pkg" + "github.com/abennett/ttt/pkg/messages" +) + +const ( + PingInterval = 5 * time.Second +) + +type userSession struct { + wg *sync.WaitGroup + logger *slog.Logger + name string + writeCh chan []byte +} + +type Room struct { + mu *sync.Mutex + logger *slog.Logger + userSessions map[string]userSession + + Version int + Name string + Dice pkg.DiceRoll + Rolls map[string]*messages.RollResult +} + +func (r *Room) RunSession(ctx context.Context, conn *websocket.Conn) { + _, b, err := conn.ReadMessage() + if err != nil { + r.logger.Error("failed to read initial message", "error", err) + return + } + + var msg messages.Message + if err = msgpack.Unmarshal(b, &msg); err != nil { + r.logger.Error("failed to parse initial message", "error", err, "payload", string(b)) + return + } + + req, ok := msg.Payload.(messages.RollRequest) + if !ok { + r.logger.Error("initial message was incorrect", "error", err, "payload", string(b)) + return + } + + name := req.User + r.logger.Debug("starting a session", "user", name) + writeCh := make(chan []byte, 1) + session := userSession{ + wg: new(sync.WaitGroup), + logger: slog.With("user", req.User), + name: req.User, + writeCh: writeCh, + } + + r.startUserSession(ctx, session, conn) + + roll := messages.RollResult{ + User: name, + Result: r.Dice.Roll(), + } + + err = r.Update(roll) + if err != nil { + r.logger.Error(err.Error()) + return + } + + session.wg.Wait() + r.stopUserSession(session) + r.logger.Info("closing session", "active_sessions", len(r.userSessions), "user", name) +} + +func (r *Room) startUserSession(ctx context.Context, session userSession, conn *websocket.Conn) { + r.mu.Lock() + r.userSessions[session.name] = session + r.mu.Unlock() + + // Add to the waitGroup outside of goroutines here to avoid race condition on Add + ctx, cancel := context.WithCancel(ctx) + session.wg.Add(2) + go r.userReadLoop(cancel, session, conn) + go r.userWriteLoop(ctx, session, conn) +} + +func (r *Room) stopUserSession(session userSession) { + r.mu.Lock() + delete(r.userSessions, session.name) + r.mu.Unlock() +} + +func (r *Room) userReadLoop(cancel func(), session userSession, conn *websocket.Conn) { + defer cancel() + defer session.wg.Done() + defer session.logger.Debug("closing read loop") + + for { + t, b, err := conn.ReadMessage() + if closeErr, ok := err.(*websocket.CloseError); ok { + if closeErr.Code == websocket.CloseNormalClosure { + session.logger.Info("close message received") + return + } + } + if err != nil { + r.logger.Error("failure in user read loop", "error", err) + return + } + + switch t { + case websocket.CloseMessage: + session.logger.Info("close message received") + return + case websocket.BinaryMessage: + session.logger.Info("binary message received") + var msg messages.Message + err := msgpack.Unmarshal(b, &msg) + if err != nil { + r.logger.Error("failed handling binary message", "error", err) + return + } + err = r.Update(msg.Payload) + if err != nil { + r.logger.Error("failed updating server", "error", err) + } + } + } +} + +func (r *Room) HandleBinaryMessage(b []byte) error { + var msg messages.Message + err := msgpack.Unmarshal(b, &msg) + if err != nil { + return messages.ErrMessageInvalid + } + + switch msg.Payload { + + } + return nil +} + +func (r *Room) userWriteLoop(ctx context.Context, session userSession, conn *websocket.Conn) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + defer session.wg.Done() + defer session.logger.Debug("closing write loop") + ticker := time.NewTicker(PingInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + session.logger.Debug("write loop is done") + return + case b := <-session.writeCh: + session.logger.Debug("writing message") + err := conn.WriteMessage(websocket.BinaryMessage, b) + if err != nil { + r.logger.Error(err.Error()) + return + } + case <-ticker.C: + session.logger.Debug("writing ping message") + err := conn.WriteMessage(websocket.PingMessage, []byte{}) + if err == websocket.ErrCloseSent { + session.logger.Debug("error close was sent") + return + } + if err != nil { + session.logger.Error("ping failed", "error", err) + return + } + } + } +} + +func (r *Room) Update(update any) error { + r.mu.Lock() + defer r.mu.Unlock() + + switch u := update.(type) { + case messages.RollResult: + r.Rolls[u.User] = &u + r.logger.Debug("added roll", "active_sessions", len(r.userSessions), "user", u.User) + case messages.DoneRequest: + user, ok := r.Rolls[u.User] + if !ok { + return fmt.Errorf("user %q does not exist", u.User) + } + user.IsDone = !user.IsDone + r.logger.Debug("user is done", "user", u.User) + default: + err := fmt.Errorf("unknown update type: %T", update) + r.logger.Error(err.Error()) + return err + } + + r.Version++ + + msg := messages.Message{ + Type: messages.StateMsgType, + Version: "1", + Payload: r.ToState(), + } + b, err := msgpack.Marshal(msg) + if err != nil { + r.logger.Error("failed marshalling room", "error", err) + return err + } + + for _, us := range r.userSessions { + r.logger.Debug("pushing update", "user", us.name, "version", r.Version) + us.writeCh <- b + } + return nil +} + +func (r *Room) ToState() messages.RoomState { + rolls := make([]messages.RollResult, len(r.Rolls)) + var i int + for _, roll := range r.Rolls { + rolls[i] = *roll + i++ + } + slices.SortFunc(rolls, func(a, b messages.RollResult) int { + return cmp.Compare(b.Result, a.Result) + }) + return messages.RoomState{ + Version: r.Version, + Name: r.Name, + Dice: r.Dice.String(), + Rolls: rolls, + } +} diff --git a/pkg/server/server.go b/pkg/server/server.go new file mode 100644 index 0000000..162d5fd --- /dev/null +++ b/pkg/server/server.go @@ -0,0 +1,117 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + "sync" + + "github.com/abennett/ttt/pkg" + "github.com/abennett/ttt/pkg/messages" + "github.com/go-chi/chi/v5" + "github.com/gorilla/websocket" +) + +var ( + ErrRoomExists = errors.New("room exists") + ErrRoomNotExists = errors.New("room does not exist") +) + +type Server struct { + rw *sync.RWMutex + upgrader websocket.Upgrader + + rooms map[string]*Room +} + +func NewServer() *Server { + return &Server{ + rw: &sync.RWMutex{}, + rooms: map[string]*Room{}, + } +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + roomName := chi.URLParam(r, "roomName") + if roomName == "" { + http.Error(w, "room name is required", http.StatusBadRequest) + return + } + slog.Info("serving request", "roomName", roomName) + var err error + room, ok := s.rooms[roomName] + if !ok { + room, err = s.NewRoom(roomName) + if err != nil { + slog.Error("unable to create new room", "room_name", roomName, "error", err) + http.Error(w, "unable to create new room", http.StatusInternalServerError) + return + } + } + conn, err := s.upgrader.Upgrade(w, r, nil) + if err != nil { + slog.Error(err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer conn.Close() //nolint: errcheck + + // Keep connection alive + room.RunSession(r.Context(), conn) + + room.mu.Lock() + if len(room.userSessions) == 0 { + s.deleteRoom(roomName) + slog.Info("closed room", "room", roomName) + } + room.mu.Unlock() +} + +func (s *Server) NewRoom(name string) (*Room, error) { + s.rw.Lock() + defer s.rw.Unlock() + _, ok := s.rooms[name] + if ok { + return nil, ErrRoomExists + } + s.rooms[name] = &Room{ + mu: new(sync.Mutex), + logger: slog.With("room", name), + userSessions: make(map[string]userSession), + Version: 0, + Dice: pkg.DiceRoll{ + Count: 1, + DiceSides: 20, + }, + Name: name, + Rolls: map[string]*messages.RollResult{}, + } + return s.rooms[name], nil +} + +func (s *Server) GetRooms() map[string]Room { + s.rw.RLock() + defer s.rw.RUnlock() + + rooms := make(map[string]Room, len(s.rooms)) + for k, v := range s.rooms { + rooms[k] = *v + } + return rooms +} + +func (s *Server) GetRoom(roomName string) (*Room, error) { + s.rw.RLock() + defer s.rw.RUnlock() + room, ok := s.rooms[roomName] + if !ok { + return room, ErrRoomNotExists + } + return room, nil +} + +func (s *Server) deleteRoom(roomName string) { + s.rw.Lock() + delete(s.rooms, roomName) + s.rw.Unlock() +} diff --git a/pkg/ttt.go b/pkg/ttt.go deleted file mode 100644 index b072a1b..0000000 --- a/pkg/ttt.go +++ /dev/null @@ -1,293 +0,0 @@ -package pkg - -import ( - "cmp" - "context" - "errors" - "fmt" - "log/slog" - "net/http" - "slices" - "sync" - "time" - - "github.com/go-chi/chi/v5" - "github.com/gorilla/websocket" - "github.com/vmihailenco/msgpack/v5" -) - -const ( - PingInterval = time.Second -) - -var ( - ErrRoomExists = errors.New("room exists") - ErrRoomNotExists = errors.New("room does not exist") -) - -type Server struct { - rw *sync.RWMutex - upgrader websocket.Upgrader - - Rooms map[string]*Room -} - -func NewServer() *Server { - return &Server{ - rw: &sync.RWMutex{}, - Rooms: map[string]*Room{}, - } -} - -func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - roomName := chi.URLParam(r, "roomName") - if roomName == "" { - http.Error(w, "room name is required", http.StatusBadRequest) - return - } - slog.Info("serving request", "roomName", roomName) - var err error - room, ok := s.Rooms[roomName] - if !ok { - room, err = s.NewRoom(roomName) - if err != nil { - slog.Error("unable to create new room", "room_name", roomName, "error", err) - http.Error(w, "unable to create new room", http.StatusInternalServerError) - return - } - } - conn, err := s.upgrader.Upgrade(w, r, nil) - if err != nil { - slog.Error(err.Error()) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer conn.Close() - - // Keep connection alive - room.RunSession(r.Context(), conn) - - room.mu.Lock() - if len(room.userSessions) == 0 { - s.rw.Lock() - delete(s.Rooms, roomName) - s.rw.Unlock() - slog.Info("closed room", "room", roomName) - } - 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 - userSessions map[string]userSession - - 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 -} - -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") - return - 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() - session.wg.Done() - }() -EXIT: - for { - select { - case <-ctx.Done(): - break EXIT - case b := <-session.writeCh: - slog.Debug("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{ - wg: new(sync.WaitGroup), - name: req.User, - writeCh: writeCh, - } - r.mu.Lock() - r.userSessions[name] = session - r.mu.Unlock() - - r.startUserSession(ctx, session, conn) - - roll := RollResult{ - User: name, - Result: r.Dice.Roll(), - } - err = r.Update(roll) - if err != nil { - slog.Error(err.Error()) - return - } - - session.wg.Wait() - slog.Info("closing session", "user", name) -} - -func (r *Room) Update(update any) error { - r.mu.Lock() - defer r.mu.Unlock() - - 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 - } - - r.Version++ - - b, err := msgpack.Marshal(r.toState()) - if err != nil { - slog.Error("failed marshalling room", "error", err) - return err - } - - for _, us := range r.userSessions { - slog.Debug("pushing update", "user", us.name, "version", r.Version) - us.writeCh <- b - } - 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(b.Result, a.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() - _, ok := s.Rooms[name] - if ok { - return nil, ErrRoomExists - } - s.Rooms[name] = &Room{ - mu: new(sync.Mutex), - userSessions: make(map[string]userSession), - Version: 0, - Dice: DiceRoll{ - Count: 1, - DiceSides: 20, - }, - Name: name, - Rolls: map[string]RollResult{}, - } - return s.Rooms[name], nil -} - -func (s *Server) GetRoom(roomName string) (*Room, error) { - s.rw.RLock() - defer s.rw.RUnlock() - room, ok := s.Rooms[roomName] - if !ok { - return room, ErrRoomNotExists - } - return room, nil -} diff --git a/pkg/ttt_test.go b/pkg/ttt_test.go deleted file mode 100644 index 11c14a9..0000000 --- a/pkg/ttt_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package pkg - -import ( - "testing" -) - -func TestParseDiceRoll(t *testing.T) { - example := DiceRoll{ - Count: 1, - DiceSides: 20, - Modifier: 1, - } - dr, err := ParseDiceRoll("1d20+1") - if err != nil { - t.Fatal(err) - } - if dr != example { - t.Fatal() - } - - _, err = ParseDiceRoll("cantaloupe") - if err == nil { - t.Fatal("that definitely shouldn't work") - } -} - -func TestDiceRollString(t *testing.T) { - dr := DiceRoll{ - Count: 1, - DiceSides: 20, - Modifier: 0, - } - if "1d20" != dr.String() { - t.Fatalf("%s should equal 1d20", dr.String()) - } -} diff --git a/ttt_test.go b/ttt_test.go new file mode 100644 index 0000000..50d22e4 --- /dev/null +++ b/ttt_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "io" + "net/http/httptest" + "testing" + "time" + + "github.com/shoenig/test/must" + "github.com/shoenig/test/wait" + + "github.com/abennett/ttt/pkg/client" + "github.com/abennett/ttt/pkg/server" +) + +func TestSingleClient(t *testing.T) { + t.Parallel() + srv := server.NewServer() + mux := server.NewMux(srv) + testSrv := httptest.NewServer(mux) + + client, err := client.New(testSrv.URL, "test1", "tester", io.Discard) + must.NoError(t, err) + + err = client.Init() + must.NoError(t, err) + + must.MapContainsKey(t, srv.GetRooms(), "test1") + must.Wait(t, wait.InitialSuccess(wait.BoolFunc(func() bool { + return len(client.Room.Rolls) > 0 + }))) + + rooms := srv.GetRooms() + roomState := rooms["test1"] + must.Eq(t, roomState.Version, client.Room.Version) + t.Log(roomState) + + isDone := roomState.Rolls["tester"].IsDone + must.False(t, isDone) + must.NoError(t, client.ToggleDone()) + time.Sleep(time.Second) + rooms = srv.GetRooms() + roomState = rooms["test1"] + isDone = roomState.Rolls["tester"].IsDone + must.True(t, isDone) + + err = client.Close() + must.NoError(t, err) + time.Sleep(time.Second) + must.MapEmpty(t, srv.GetRooms()) +} + +func TestMultipleClients(t *testing.T) { + t.Parallel() + srv := server.NewServer() + mux := server.NewMux(srv) + testSrv := httptest.NewServer(mux) + + client1, err := client.New(testSrv.URL, "test1", "tester1", io.Discard) + must.NoError(t, err) + + client2, err := client.New(testSrv.URL, "test1", "tester2", io.Discard) + must.NoError(t, err) + + err = client1.Init() + must.NoError(t, err) + + err = client2.Init() + must.NoError(t, err) + + must.MapContainsKey(t, srv.GetRooms(), "test1") + must.Wait(t, wait.InitialSuccess(wait.BoolFunc(func() bool { + return client1.Room.Version == 2 + }))) + must.Wait(t, wait.InitialSuccess(wait.BoolFunc(func() bool { + return client2.Room.Version == 2 + }))) +}