Skip to content

Instantly share code, notes, and snippets.

@zephyrtronium
Created January 20, 2025 18:41
Show Gist options
  • Select an option

  • Save zephyrtronium/1cda289cb99f96af0ad1fc42e770b88d to your computer and use it in GitHub Desktop.

Select an option

Save zephyrtronium/1cda289cb99f96af0ad1fc42e770b88d to your computer and use it in GitHub Desktop.
Chan Chan Chan T: A Generic Tale
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
}
}
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())
}
}
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