Created
January 20, 2025 18:41
-
-
Save zephyrtronium/1cda289cb99f96af0ad1fc42e770b88d to your computer and use it in GitHub Desktop.
Chan Chan Chan T: A Generic Tale
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package lobby | |
| import "context" | |
| // matchMade is a message sent from the challenger to the dealer to indicate | |
| // to the latter that they have matched. | |
| type matchMade[ID, T any] struct { | |
| match chan ID | |
| carry T | |
| } | |
| // match is a match request. Each match receives one message from the matching | |
| // opponent. It MUST be buffered with a capacity of at least 1. | |
| type match[ID, T any] chan matchMade[ID, T] | |
| // Lobby is a matchmaking service. ID is the type of a game ID. T is the type of an exchange from one player | |
| // to the other when a match is found. | |
| type Lobby[ID, T any] struct { | |
| // matches is dealers waiting for a match. It MUST be unbuffered. | |
| matches chan match[ID, T] | |
| } | |
| // New creates a matchmaking lobby. | |
| func New[ID, T any]() *Lobby[ID, T] { | |
| return &Lobby[ID, T]{ | |
| matches: make(chan match[ID, T]), | |
| } | |
| } | |
| // Queue waits for a match. Both recipients of the match receive the same | |
| // result of new and the challenger's value of p. | |
| func (l *Lobby[ID, T]) Queue(ctx context.Context, new func() ID, p T) (id ID, chall T, deal bool) { | |
| var zid ID | |
| var zero T | |
| select { | |
| case m := <-l.matches: | |
| // We found a dealer waiting for a match. | |
| r := matchMade[ID, T]{ | |
| match: make(chan ID, 1), | |
| carry: p, | |
| } | |
| // We don't need to check the context here because the challenger | |
| // channel is buffered and we have exclusive send access on it. | |
| m <- r | |
| // We do need to check the context here in case they disappeared. | |
| select { | |
| case <-ctx.Done(): | |
| return zid, zero, false | |
| case id := <-r.match: | |
| return id, p, false | |
| } | |
| default: // do nothing | |
| } | |
| // We're a new dealer. | |
| m := make(match[ID, T], 1) | |
| select { | |
| case <-ctx.Done(): | |
| return zid, zero, false | |
| case l.matches <- m: | |
| // Our match is submitted. Move on. | |
| case m := <-l.matches: | |
| // We might have become a dealer at the same time someone else did. | |
| // We created our match, but we can try to get theirs as well and | |
| // never send ours, since l.matches is unbuffered. | |
| r := matchMade[ID, T]{ | |
| match: make(chan ID, 1), | |
| carry: p, | |
| } | |
| m <- r | |
| select { | |
| case <-ctx.Done(): | |
| return zid, zero, false | |
| case id := <-r.match: | |
| return id, p, false | |
| } | |
| } | |
| select { | |
| case <-ctx.Done(): | |
| return zid, zero, false | |
| case r := <-m: | |
| // Got our challenger. Create the game and send the ID back. | |
| id := new() | |
| // Don't need to check context because the match channel is buffered. | |
| r.match <- id | |
| return id, r.carry, true | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package lobby_test | |
| import ( | |
| "context" | |
| "sync/atomic" | |
| "testing" | |
| "git.sunturtle.xyz/studio/shotgun/lobby" | |
| ) | |
| func TestQueue(t *testing.T) { | |
| const N = 10000 // must be even | |
| games := make([]int, 0, N) | |
| ch := make(chan int) | |
| pc := make(chan int, N) | |
| l := lobby.New[int, int]() | |
| var dealers, challs atomic.Int32 | |
| for i := 0; i < N; i++ { | |
| i := i | |
| new := func() int { return i } | |
| go func() { | |
| id, j, deal := l.Queue(context.Background(), new, i) | |
| if deal { | |
| dealers.Add(1) | |
| } else { | |
| challs.Add(1) | |
| } | |
| ch <- id | |
| pc <- j | |
| }() | |
| } | |
| for i := 0; i < N; i++ { | |
| games = append(games, <-ch) | |
| } | |
| // Every unique game ID should appear exactly twice. | |
| counts := make(map[int]int, N/2) | |
| for _, id := range games { | |
| counts[id]++ | |
| } | |
| for id, c := range counts { | |
| if c != 2 { | |
| t.Errorf("game %v appears %d times", id, c) | |
| } | |
| } | |
| // Every unique challenger info should appear exactly twice. | |
| ps := make(map[int]int, N/2) | |
| for i := 0; i < N; i++ { | |
| ps[<-pc]++ | |
| } | |
| // The number of dealers must match the number of challengers. | |
| if dealers.Load() != challs.Load() { | |
| t.Errorf("%d dealers != %d challengers", dealers.Load(), challs.Load()) | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package main | |
| import ( | |
| "context" | |
| "encoding/json" | |
| "errors" | |
| "log/slog" | |
| "net/http" | |
| "time" | |
| "github.com/google/uuid" | |
| "nhooyr.io/websocket" | |
| "nhooyr.io/websocket/wsjson" | |
| "git.sunturtle.xyz/studio/shotgun/game" | |
| "git.sunturtle.xyz/studio/shotgun/lobby" | |
| "git.sunturtle.xyz/studio/shotgun/player" | |
| "git.sunturtle.xyz/studio/shotgun/serve" | |
| ) | |
| type Server struct { | |
| l *lobby.Lobby[uuid.UUID, matchingPerson] | |
| creds db | |
| sessions db | |
| } | |
| type db interface { | |
| player.RowQuerier | |
| player.Execer | |
| } | |
| type person struct { | |
| conn *websocket.Conn | |
| id player.ID | |
| } | |
| type matchingPerson struct { | |
| sync chan struct{} | |
| person | |
| } | |
| func (s *Server) accept(w http.ResponseWriter, r *http.Request, p player.ID) (person, error) { | |
| conn, err := websocket.Accept(w, r, nil) | |
| if err != nil { | |
| return person{}, err | |
| } | |
| slog.Debug("upgraded", "player", p) | |
| return person{conn: conn, id: p}, nil | |
| } | |
| func (s *Server) register(w http.ResponseWriter, r *http.Request) { | |
| ctx := r.Context() | |
| slog := slog.With( | |
| slog.String("remote", r.RemoteAddr), | |
| slog.String("forwarded-for", r.Header.Get("X-Forwarded-For")), | |
| slog.String("user-agent", r.UserAgent()), | |
| ) | |
| if err := r.ParseForm(); err != nil { | |
| slog.WarnContext(ctx, "error parsing form on register", "err", err.Error()) | |
| http.Error(w, "what", http.StatusBadRequest) | |
| return | |
| } | |
| user, pass := r.PostFormValue("user"), r.PostFormValue("pass") | |
| if user == "" || pass == "" { | |
| slog.WarnContext(ctx, "missing user or pass on register") | |
| http.Error(w, "missing credentials", http.StatusBadRequest) | |
| return | |
| } | |
| err := player.Register(ctx, s.creds, user, pass) | |
| if err != nil { | |
| slog.ErrorContext(ctx, "registration failed", "err", err.Error()) | |
| http.Error(w, "something went wrong, maybe someone already has that username, idk", http.StatusInternalServerError) | |
| return | |
| } | |
| p, err := player.Login(ctx, s.creds, user, pass) | |
| if err != nil { | |
| slog.ErrorContext(ctx, "login failed", "err", err.Error()) | |
| http.Error(w, "no", http.StatusUnauthorized) | |
| return | |
| } | |
| id, err := player.StartSession(ctx, s.sessions, p, time.Now()) | |
| if err != nil { | |
| slog.ErrorContext(ctx, "failed to create session", "player", p, "err", err.Error()) | |
| http.Error(w, "something went wrong", http.StatusInternalServerError) | |
| return | |
| } | |
| serve.SetSession(w, id) | |
| http.Redirect(w, r, "/", http.StatusSeeOther) | |
| slog.InfoContext(ctx, "registered", "user", user) | |
| } | |
| func (s *Server) login(w http.ResponseWriter, r *http.Request) { | |
| ctx := r.Context() | |
| slog := slog.With( | |
| slog.String("remote", r.RemoteAddr), | |
| slog.String("forwarded-for", r.Header.Get("X-Forwarded-For")), | |
| slog.String("user-agent", r.UserAgent()), | |
| ) | |
| if err := r.ParseForm(); err != nil { | |
| slog.WarnContext(ctx, "error parsing form on login", "err", err.Error()) | |
| http.Error(w, "what", http.StatusBadRequest) | |
| return | |
| } | |
| user, pass := r.PostFormValue("user"), r.PostFormValue("pass") | |
| if user == "" || pass == "" { | |
| slog.WarnContext(ctx, "missing user or pass on login") | |
| http.Error(w, "missing credentials", http.StatusBadRequest) | |
| return | |
| } | |
| p, err := player.Login(ctx, s.creds, user, pass) | |
| if err != nil { | |
| slog.ErrorContext(ctx, "login failed", "err", err.Error()) | |
| http.Error(w, "no", http.StatusUnauthorized) | |
| return | |
| } | |
| id, err := player.StartSession(ctx, s.sessions, p, time.Now()) | |
| if err != nil { | |
| slog.ErrorContext(ctx, "failed to create session", "player", p, "err", err.Error()) | |
| http.Error(w, "something went wrong", http.StatusInternalServerError) | |
| return | |
| } | |
| serve.SetSession(w, id) | |
| http.Redirect(w, r, "/", http.StatusSeeOther) | |
| slog.InfoContext(ctx, "logged in", "player", p, "id", id) | |
| } | |
| func (s *Server) logout(w http.ResponseWriter, r *http.Request) { | |
| ctx := r.Context() | |
| slog := slog.With( | |
| slog.String("remote", r.RemoteAddr), | |
| slog.String("forwarded-for", r.Header.Get("X-Forwarded-For")), | |
| slog.String("user-agent", r.UserAgent()), | |
| ) | |
| id := serve.ReqSession(ctx) | |
| if id == (player.Session{}) { | |
| slog.WarnContext(ctx, "no session on logout") | |
| http.Error(w, "what", http.StatusUnauthorized) | |
| panic("unreachable") | |
| } | |
| err := player.Logout(ctx, s.sessions, id) | |
| if err != nil { | |
| slog.ErrorContext(ctx, "logout failed", "err", err.Error()) | |
| http.Error(w, "something went wrong", http.StatusInternalServerError) | |
| return | |
| } | |
| serve.RemoveSession(w) | |
| slog.InfoContext(ctx, "logged out", "session", id) | |
| http.Redirect(w, r, "/", http.StatusSeeOther) | |
| } | |
| func (s *Server) me(w http.ResponseWriter, r *http.Request) { | |
| id := serve.ReqPlayer(r.Context()) | |
| if id == (player.ID{}) { | |
| panic("missing player ID") | |
| } | |
| q := struct { | |
| ID player.ID `json:"id"` | |
| }{id} | |
| json.NewEncoder(w).Encode(q) | |
| } | |
| // queue connects players to games. The connection immediately upgrades. | |
| // This handler MUST be wrapped in [serve.WithPlayerID]. | |
| func (s *Server) queue(w http.ResponseWriter, r *http.Request) { | |
| p := serve.ReqPlayer(r.Context()) | |
| person, err := s.accept(w, r, p) | |
| if err != nil { | |
| http.Error(w, err.Error(), http.StatusBadRequest) | |
| return | |
| } | |
| s.joinAndServe(person) | |
| } | |
| func (s *Server) joinAndServe(p person) { | |
| slog.Debug("joining", "player", p.id) | |
| ctx, stop := context.WithTimeoutCause(context.Background(), 3*time.Minute, errQueueEmpty) | |
| ch := make(chan struct{}) | |
| go func() { | |
| defer close(ch) | |
| _, _, err := p.conn.Read(ctx) | |
| if err != nil { | |
| slog.ErrorContext(ctx, "player dropped on hello", "player", p.id, "err", err.Error()) | |
| stop() | |
| } | |
| }() | |
| mp := matchingPerson{sync: make(chan struct{}), person: p} | |
| id, chall, deal := s.l.Queue(ctx, uuid.New, mp) | |
| <-ch | |
| close(mp.sync) | |
| stop() | |
| if id == uuid.Nil { | |
| // Context canceled. | |
| p.conn.Close(websocket.StatusTryAgainLater, "sorry, queue is empty...") | |
| return | |
| } | |
| if deal { | |
| g := game.New(p.id, chall.id) | |
| <-chall.sync | |
| go gameActor(context.TODO(), g, p, chall.person, nil) | |
| } | |
| // Reply with the game ID so they can share. | |
| r := serve.GameStart{ | |
| ID: serve.GameID{UUID: id}, | |
| Dealer: deal, | |
| } | |
| if err := wsjson.Write(context.TODO(), p.conn, r); err != nil { | |
| slog.WarnContext(ctx, "got a game but player dropped", "game", id, "player", p.id) | |
| p.conn.Close(websocket.StatusNormalClosure, "looks like you dropped") | |
| return | |
| } | |
| } | |
| var errQueueEmpty = errors.New("sorry, queue is empty") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment