mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
feat(server): pair strangers with quick-match
The hub keeps a FIFO of waiting sessions; a second QuickMatch pops the first, opens a room via the existing createInput/joinInput path, and marks it to begin its own first game once both seats are connected in the lobby. CancelQuickMatch, a dropped connection, and entering a room by code all remove a session from the queue. Counts queued, cancelled and matched pairings in metrics.
This commit is contained in:
1 parent
865222d2cd
commit
4a78b8aad5
7 files changed
+342
No files matched your search
@@ -79,3 +79,9 @@ func pongMsg(clientTimeMs, serverTimeMs int64) *noituv1.ServerMessage {
|
|||||||
Pong: &noituv1.Pong{ClientTimeMs: clientTimeMs, ServerTimeMs: serverTimeMs},
|
Pong: &noituv1.Pong{ClientTimeMs: clientTimeMs, ServerTimeMs: serverTimeMs},
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func quickMatchStatusMsg(queued bool) *noituv1.ServerMessage {
|
||||||
|
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_QuickMatchStatus{
|
||||||
|
QuickMatchStatus: &noituv1.QuickMatchStatus{Queued: queued},
|
||||||
|
}}
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ var (
|
|||||||
// started shutting down, so the creator is told to come back rather than
|
// started shutting down, so the creator is told to come back rather than
|
||||||
// to wait — a full room fills back up, a draining one never will.
|
// to wait — a full room fills back up, a draining one never will.
|
||||||
errDraining = errors.New("wsapi: server draining")
|
errDraining = errors.New("wsapi: server draining")
|
||||||
|
// errAlreadyQueued answers a second QuickMatch from a session already
|
||||||
|
// waiting in the pairing queue.
|
||||||
|
errAlreadyQueued = errors.New("wsapi: already queued for quick match")
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultMaxRooms bounds live rooms across the whole process when nothing else
|
// defaultMaxRooms bounds live rooms across the whole process when nothing else
|
||||||
@@ -59,6 +62,13 @@ type hub struct {
|
|||||||
rooms map[string]*room
|
rooms map[string]*room
|
||||||
sessions map[string]*session // by resume token
|
sessions map[string]*session // by resume token
|
||||||
|
|
||||||
|
// waiting is the FIFO of sessions queued for a quick match. No key and no
|
||||||
|
// skill: the pool this server serves is small enough that "the next
|
||||||
|
// stranger who also asked" is the whole matching policy. Guarded by mu
|
||||||
|
// rather than a lock of its own — the hub is not a bottleneck any of this
|
||||||
|
// adds meaningful contention to.
|
||||||
|
waiting []*session
|
||||||
|
|
||||||
joinLimiter *keyedLimiter
|
joinLimiter *keyedLimiter
|
||||||
|
|
||||||
// draining refuses every new room once set, so a creator is told to come
|
// draining refuses every new room once set, so a creator is told to come
|
||||||
@@ -151,6 +161,69 @@ func (h *hub) createRoom(s *session) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// quickMatch pairs s with the next stranger waiting, or queues it as that
|
||||||
|
// stranger for whoever asks next.
|
||||||
|
//
|
||||||
|
// A match sends both sides their QuickMatchStatus itself, before either
|
||||||
|
// input reaches the room: the room's own messages — RoomState, then
|
||||||
|
// GameStarted — are sent from its goroutine afterwards, and doing the status
|
||||||
|
// sends here first is what guarantees neither of them can arrive still
|
||||||
|
// claiming "queued". The enqueue path sends its own status for the same
|
||||||
|
// reason, symmetry, and because the caller has nobody else to hear from.
|
||||||
|
func (h *hub) quickMatch(s *session) error {
|
||||||
|
h.mu.Lock()
|
||||||
|
for _, w := range h.waiting {
|
||||||
|
if w == s {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return errAlreadyQueued
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(h.waiting) == 0 {
|
||||||
|
h.waiting = append(h.waiting, s)
|
||||||
|
h.mu.Unlock()
|
||||||
|
metrics.quickMatchQueued.Add(1)
|
||||||
|
s.send(quickMatchStatusMsg(true))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
waiter := h.waiting[0]
|
||||||
|
h.waiting = h.waiting[1:]
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
r, err := h.newRegisteredRoom(roomModePvP)
|
||||||
|
if err != nil {
|
||||||
|
// The waiter has no dispatch call site of its own to answer this
|
||||||
|
// through, being the caller of an earlier message; s is told by
|
||||||
|
// session.dispatch's own roomCreateError path instead.
|
||||||
|
waiter.send(roomCreateError(waiter.id, err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics.quickMatchMatched.Add(1)
|
||||||
|
waiter.send(quickMatchStatusMsg(false))
|
||||||
|
s.send(quickMatchStatusMsg(false))
|
||||||
|
// autoStart carries through createInput because it is a room field the
|
||||||
|
// goroutine sets for itself from handleCreate — nothing outside that
|
||||||
|
// goroutine ever touches it directly.
|
||||||
|
r.send(createInput{sess: waiter, autoStart: true})
|
||||||
|
r.send(joinInput{sess: s})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancelQuickMatch drops s from the pairing queue if it is there. Idempotent:
|
||||||
|
// called on a teardown or a room-entry path that may or may not have found it
|
||||||
|
// queued, and neither is worth a special case at the call site.
|
||||||
|
func (h *hub) cancelQuickMatch(s *session) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
for i, w := range h.waiting {
|
||||||
|
if w == s {
|
||||||
|
h.waiting = append(h.waiting[:i], h.waiting[i+1:]...)
|
||||||
|
metrics.quickMatchCancelled.Add(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// joinRoom offers a second player to a room. Whether they are seated is the
|
// joinRoom offers a second player to a room. Whether they are seated is the
|
||||||
// room's decision, not the hub's.
|
// room's decision, not the hub's.
|
||||||
func (h *hub) joinRoom(code string, s *session) error {
|
func (h *hub) joinRoom(code string, s *session) error {
|
||||||
@@ -289,6 +362,10 @@ func randomCode() string {
|
|||||||
|
|
||||||
// shutdown tells every live room to stop, so clients learn why rather than
|
// shutdown tells every live room to stop, so clients learn why rather than
|
||||||
// finding the socket gone.
|
// finding the socket gone.
|
||||||
|
//
|
||||||
|
// A quick-match waiter needs nothing extra here: it registered with the hub
|
||||||
|
// the moment its Hello landed, same as any other connected session, so the
|
||||||
|
// loop below already reaches it.
|
||||||
func (h *hub) shutdown() {
|
func (h *hub) shutdown() {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
rooms := make([]*room, 0, len(h.rooms))
|
rooms := make([]*room, 0, len(h.rooms))
|
||||||
@@ -299,6 +376,7 @@ func (h *hub) shutdown() {
|
|||||||
for _, s := range h.sessions {
|
for _, s := range h.sessions {
|
||||||
sessions = append(sessions, s)
|
sessions = append(sessions, s)
|
||||||
}
|
}
|
||||||
|
h.waiting = nil
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
for _, s := range sessions {
|
for _, s := range sessions {
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ type metricSet struct {
|
|||||||
|
|
||||||
resumesAttempted *expvar.Int
|
resumesAttempted *expvar.Int
|
||||||
resumesSucceeded *expvar.Int
|
resumesSucceeded *expvar.Int
|
||||||
|
|
||||||
|
// quickMatchQueued, quickMatchCancelled and quickMatchMatched count the
|
||||||
|
// three things that can happen to a QuickMatch: it waits, it is withdrawn
|
||||||
|
// (by CancelQuickMatch, a disconnect, or entering a room another way), or
|
||||||
|
// it is paired. Matched counts pairings, not players, so it rises by one
|
||||||
|
// per room a quick match opened.
|
||||||
|
quickMatchQueued *expvar.Int
|
||||||
|
quickMatchCancelled *expvar.Int
|
||||||
|
quickMatchMatched *expvar.Int
|
||||||
}
|
}
|
||||||
|
|
||||||
// metrics is the one instance every call site writes through. Built at
|
// metrics is the one instance every call site writes through. Built at
|
||||||
@@ -78,5 +87,9 @@ func newMetricSet() *metricSet {
|
|||||||
botMoves: expvar.NewMap("noitu_bot_moves"),
|
botMoves: expvar.NewMap("noitu_bot_moves"),
|
||||||
resumesAttempted: expvar.NewInt("noitu_resumes_attempted"),
|
resumesAttempted: expvar.NewInt("noitu_resumes_attempted"),
|
||||||
resumesSucceeded: expvar.NewInt("noitu_resumes_succeeded"),
|
resumesSucceeded: expvar.NewInt("noitu_resumes_succeeded"),
|
||||||
|
|
||||||
|
quickMatchQueued: expvar.NewInt("noitu_quick_match_queued"),
|
||||||
|
quickMatchCancelled: expvar.NewInt("noitu_quick_match_cancelled"),
|
||||||
|
quickMatchMatched: expvar.NewInt("noitu_quick_match_matched"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package wsapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestQuickMatchPairsTwoWaiters is the core promise: two strangers who both
|
||||||
|
// ask are seated together and the first game begins itself, with nobody
|
||||||
|
// pressing ready or start.
|
||||||
|
func TestQuickMatchPairsTwoWaiters(t *testing.T) {
|
||||||
|
_, url := newTestServer(t, chainDict(), Config{})
|
||||||
|
|
||||||
|
first := dial(t, url)
|
||||||
|
first.hello("Người chờ")
|
||||||
|
first.quickMatch()
|
||||||
|
if !first.await("quick_match_status").GetQuickMatchStatus().GetQueued() {
|
||||||
|
t.Fatal("the first asker should be queued, nobody else waiting yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
second := dial(t, url)
|
||||||
|
second.hello("Người đến sau")
|
||||||
|
second.quickMatch()
|
||||||
|
|
||||||
|
if got := second.await("quick_match_status").GetQuickMatchStatus().GetQueued(); got {
|
||||||
|
t.Error("the one who completes the pair must not be left queued")
|
||||||
|
}
|
||||||
|
if got := first.await("quick_match_status").GetQuickMatchStatus().GetQueued(); got {
|
||||||
|
t.Error("the waiter must be told the wait is over once matched")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The room begins its own first game: neither side sets ready or start.
|
||||||
|
firstStart := first.await("game_started").GetGameStarted()
|
||||||
|
secondStart := second.await("game_started").GetGameStarted()
|
||||||
|
if firstStart.GetMyTurn() == secondStart.GetMyTurn() {
|
||||||
|
t.Fatal("exactly one player should be dealt the opening turn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuickMatchCancelStopsTheWait covers withdrawing from the queue and
|
||||||
|
// staying out of it: a cancelled waiter must not surface later as somebody's
|
||||||
|
// match.
|
||||||
|
func TestQuickMatchCancelStopsTheWait(t *testing.T) {
|
||||||
|
_, url := newTestServer(t, chainDict(), Config{})
|
||||||
|
|
||||||
|
waiter := dial(t, url)
|
||||||
|
waiter.hello("Người chờ")
|
||||||
|
waiter.quickMatch()
|
||||||
|
if !waiter.await("quick_match_status").GetQuickMatchStatus().GetQueued() {
|
||||||
|
t.Fatal("expected to be queued")
|
||||||
|
}
|
||||||
|
|
||||||
|
waiter.cancelQuickMatch()
|
||||||
|
if got := waiter.await("quick_match_status").GetQuickMatchStatus().GetQueued(); got {
|
||||||
|
t.Error("a cancel should answer queued:false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cancel a second time is not an error: it is idempotent.
|
||||||
|
waiter.cancelQuickMatch()
|
||||||
|
if got := waiter.await("quick_match_status").GetQuickMatchStatus().GetQueued(); got {
|
||||||
|
t.Error("cancelling twice should still answer queued:false, not an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
other := dial(t, url)
|
||||||
|
other.hello("Người khác")
|
||||||
|
other.quickMatch()
|
||||||
|
if !other.await("quick_match_status").GetQuickMatchStatus().GetQueued() {
|
||||||
|
t.Error("the cancelled waiter must not still be in line to be matched with")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuickMatchDropsADisconnectedWaiter is the ghost case: a waiter whose
|
||||||
|
// socket ends without a CancelQuickMatch must not be handed to the next
|
||||||
|
// stranger who asks.
|
||||||
|
func TestQuickMatchDropsADisconnectedWaiter(t *testing.T) {
|
||||||
|
_, url := newTestServer(t, chainDict(), Config{})
|
||||||
|
|
||||||
|
ghost := dial(t, url)
|
||||||
|
ghost.hello("Ma")
|
||||||
|
ghost.quickMatch()
|
||||||
|
ghost.await("quick_match_status")
|
||||||
|
_ = ghost.conn.Close(websocket.StatusNormalClosure, "")
|
||||||
|
settle()
|
||||||
|
|
||||||
|
a := dial(t, url)
|
||||||
|
a.hello("A")
|
||||||
|
a.quickMatch()
|
||||||
|
if !a.await("quick_match_status").GetQuickMatchStatus().GetQueued() {
|
||||||
|
t.Fatal("A should be freshly queued, not matched with a ghost")
|
||||||
|
}
|
||||||
|
|
||||||
|
b := dial(t, url)
|
||||||
|
b.hello("B")
|
||||||
|
b.quickMatch()
|
||||||
|
|
||||||
|
a.await("game_started")
|
||||||
|
b.await("game_started")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuickMatchRefusedWhenAlreadySeated guards against a session that
|
||||||
|
// already holds a seat asking to be queued as well.
|
||||||
|
func TestQuickMatchRefusedWhenAlreadySeated(t *testing.T) {
|
||||||
|
_, url := newTestServer(t, chainDict(), Config{})
|
||||||
|
|
||||||
|
c := dial(t, url)
|
||||||
|
c.hello("Chủ phòng")
|
||||||
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||||||
|
c.await("room_state")
|
||||||
|
|
||||||
|
c.quickMatch()
|
||||||
|
if got := c.await("error").GetError().GetCode(); got != "already_in_a_room" {
|
||||||
|
t.Errorf("error code = %q, want already_in_a_room", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuickMatchRefusesASecondRequest guards the other half of the same
|
||||||
|
// check: a session already queued must not be queued twice.
|
||||||
|
func TestQuickMatchRefusesASecondRequest(t *testing.T) {
|
||||||
|
_, url := newTestServer(t, chainDict(), Config{})
|
||||||
|
|
||||||
|
c := dial(t, url)
|
||||||
|
c.hello("Người chờ")
|
||||||
|
c.quickMatch()
|
||||||
|
c.await("quick_match_status")
|
||||||
|
|
||||||
|
c.quickMatch()
|
||||||
|
if got := c.await("error").GetError().GetCode(); got != "already_queued" {
|
||||||
|
t.Errorf("error code = %q, want already_queued", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuickMatchServerFullTellsBothSides is the room-cap edge: a match that
|
||||||
|
// cannot open a room must not leave either side believing it is still
|
||||||
|
// queued.
|
||||||
|
func TestQuickMatchServerFullTellsBothSides(t *testing.T) {
|
||||||
|
_, url := newTestServer(t, chainDict(), Config{MaxRooms: 1})
|
||||||
|
|
||||||
|
filler := dial(t, url)
|
||||||
|
filler.hello("Chiếm chỗ")
|
||||||
|
filler.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||||||
|
filler.await("room_state")
|
||||||
|
|
||||||
|
waiter := dial(t, url)
|
||||||
|
waiter.hello("Người chờ")
|
||||||
|
waiter.quickMatch()
|
||||||
|
waiter.await("quick_match_status")
|
||||||
|
|
||||||
|
newcomer := dial(t, url)
|
||||||
|
newcomer.hello("Người đến sau")
|
||||||
|
newcomer.quickMatch()
|
||||||
|
|
||||||
|
if got := newcomer.await("error").GetError().GetCode(); got != "server_full" {
|
||||||
|
t.Errorf("newcomer error = %q, want server_full", got)
|
||||||
|
}
|
||||||
|
if got := waiter.await("error").GetError().GetCode(); got != "server_full" {
|
||||||
|
t.Errorf("waiter error = %q, want server_full", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither is left queued: a fresh pair must not resurrect this one.
|
||||||
|
third := dial(t, url)
|
||||||
|
third.hello("Người thứ ba")
|
||||||
|
third.quickMatch()
|
||||||
|
if !third.await("quick_match_status").GetQuickMatchStatus().GetQueued() {
|
||||||
|
t.Fatal("third should be freshly queued, not immediately matched with a stale waiter")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,10 @@ const defaultIdleWindow = 10 * time.Minute
|
|||||||
// writes happened before `go r.run()`.
|
// writes happened before `go r.run()`.
|
||||||
type createInput struct {
|
type createInput struct {
|
||||||
sess *session
|
sess *session
|
||||||
|
// autoStart marks a room a quick match opened rather than a player asking
|
||||||
|
// for a code: once both seats are filled and connected, the room begins
|
||||||
|
// its own first game instead of waiting on readiness and StartGame.
|
||||||
|
autoStart bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type startBotInput struct {
|
type startBotInput struct {
|
||||||
@@ -253,6 +257,12 @@ type room struct {
|
|||||||
// the room is cancelled mid-game instead of finishing normally.
|
// the room is cancelled mid-game instead of finishing normally.
|
||||||
liveCounted atomic.Bool
|
liveCounted atomic.Bool
|
||||||
|
|
||||||
|
// autoStart marks a room opened by a quick match. Once both seats are
|
||||||
|
// filled and connected it begins its own first game — see handleJoin —
|
||||||
|
// and is cleared right there, so every later game in the room is agreed
|
||||||
|
// with readiness and StartGame like any other.
|
||||||
|
autoStart bool
|
||||||
|
|
||||||
seats [maxPlayers]*seat
|
seats [maxPlayers]*seat
|
||||||
|
|
||||||
// owner is the seat that may start a game and free the other one. It is a
|
// owner is the seat that may start a game and free the other one. It is a
|
||||||
@@ -539,8 +549,14 @@ func (r *room) run() {
|
|||||||
func (r *room) handleCreate(m createInput) {
|
func (r *room) handleCreate(m createInput) {
|
||||||
r.seats[0] = &seat{id: "p1", nickname: m.sess.nickname(), sess: m.sess, chatFrom: r.chatSeq}
|
r.seats[0] = &seat{id: "p1", nickname: m.sess.nickname(), sess: m.sess, chatFrom: r.chatSeq}
|
||||||
r.owner = "p1"
|
r.owner = "p1"
|
||||||
|
r.autoStart = m.autoStart
|
||||||
m.sess.attach(r, "p1")
|
m.sess.attach(r, "p1")
|
||||||
r.lobbyChanged = true
|
r.lobbyChanged = true
|
||||||
|
// A quick match already popped this session off the pairing queue before
|
||||||
|
// sending it here, but a plain CreateRoom might still be seating somebody
|
||||||
|
// who was also waiting in it from another attempt — one dequeue serves
|
||||||
|
// both room-entry paths.
|
||||||
|
r.hub.cancelQuickMatch(m.sess)
|
||||||
// Deliberately sent to a brand-new room's creator, where it is always
|
// Deliberately sent to a brand-new room's creator, where it is always
|
||||||
// empty: it is what replaces the conversation a client may still be
|
// empty: it is what replaces the conversation a client may still be
|
||||||
// holding from a room it was in before this one.
|
// holding from a room it was in before this one.
|
||||||
@@ -587,6 +603,7 @@ func (r *room) handleStartBot(m startBotInput) {
|
|||||||
r.seats[1] = &seat{id: botPlayerID, nickname: "Máy"}
|
r.seats[1] = &seat{id: botPlayerID, nickname: "Máy"}
|
||||||
r.owner = "p1"
|
r.owner = "p1"
|
||||||
m.sess.attach(r, "p1")
|
m.sess.attach(r, "p1")
|
||||||
|
r.hub.cancelQuickMatch(m.sess)
|
||||||
|
|
||||||
if err := r.beginGame(); err != nil {
|
if err := r.beginGame(); err != nil {
|
||||||
slog.Error("could not start bot game", "room", r.code, "err", err)
|
slog.Error("could not start bot game", "room", r.code, "err", err)
|
||||||
@@ -635,7 +652,24 @@ func (r *room) handleJoin(m joinInput) {
|
|||||||
}
|
}
|
||||||
m.sess.attach(r, string(id))
|
m.sess.attach(r, string(id))
|
||||||
r.lobbyChanged = true
|
r.lobbyChanged = true
|
||||||
|
r.hub.cancelQuickMatch(m.sess)
|
||||||
r.sendChatHistory(r.seats[free])
|
r.sendChatHistory(r.seats[free])
|
||||||
|
|
||||||
|
// A quick match seats both players itself rather than waiting on
|
||||||
|
// readiness and StartGame — there is no owner here to press it, only two
|
||||||
|
// strangers who both already asked to be matched. The lobby is shown
|
||||||
|
// first, with both seats filled, so the wait ends on an ordinary room a
|
||||||
|
// beat before GameStarted rather than jumping straight into one with no
|
||||||
|
// seating frame behind it.
|
||||||
|
if r.autoStart && r.seatedCount() >= minPlayers && r.allConnected() {
|
||||||
|
r.autoStart = false
|
||||||
|
r.lobbyChanged = false
|
||||||
|
r.broadcastRoomState()
|
||||||
|
if err := r.beginGame(); err != nil {
|
||||||
|
slog.Error("could not start quick-matched game", "room", r.code, "err", err)
|
||||||
|
r.broadcastError("game_start_failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleLobby applies one lobby action.
|
// handleLobby applies one lobby action.
|
||||||
|
|||||||
@@ -262,6 +262,10 @@ func (s *session) trySend(m *noituv1.ServerMessage) bool {
|
|||||||
func (s *session) run() {
|
func (s *session) run() {
|
||||||
defer s.close()
|
defer s.close()
|
||||||
defer s.leaveRoom()
|
defer s.leaveRoom()
|
||||||
|
// A connection that ends while queued must not leave a ghost in line: the
|
||||||
|
// next two strangers to ask are paired with each other, not with a socket
|
||||||
|
// that is already gone.
|
||||||
|
defer s.hub.cancelQuickMatch(s)
|
||||||
|
|
||||||
s.conn.SetReadLimit(maxFrameBytes)
|
s.conn.SetReadLimit(maxFrameBytes)
|
||||||
|
|
||||||
@@ -462,6 +466,31 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error {
|
|||||||
s.send(errorMsg("room_not_found"))
|
s.send(errorMsg("room_not_found"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case *noituv1.ClientMessage_QuickMatch:
|
||||||
|
if r, _ := s.currentRoom(); r != nil {
|
||||||
|
s.send(errorMsg("already_in_a_room"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// A match mints a room exactly as CreateRoom does, so it is charged
|
||||||
|
// the same way and for the same reason.
|
||||||
|
if !s.roomLimiter.allow(time.Now()) {
|
||||||
|
s.send(errorMsg("too_many_rooms"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.hub.quickMatch(s); err != nil {
|
||||||
|
if errors.Is(err, errAlreadyQueued) {
|
||||||
|
s.send(errorMsg("already_queued"))
|
||||||
|
} else {
|
||||||
|
s.send(roomCreateError(s.id, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case *noituv1.ClientMessage_CancelQuickMatch:
|
||||||
|
// Idempotent by design: a cancel that finds nothing queued is not an
|
||||||
|
// error, it is the answer the player wanted.
|
||||||
|
s.hub.cancelQuickMatch(s)
|
||||||
|
s.send(quickMatchStatusMsg(false))
|
||||||
|
|
||||||
case *noituv1.ClientMessage_SubmitWord:
|
case *noituv1.ClientMessage_SubmitWord:
|
||||||
s.handleSubmit(p.SubmitWord)
|
s.handleSubmit(p.SubmitWord)
|
||||||
|
|
||||||
|
|||||||
@@ -277,6 +277,8 @@ func payloadCase(m *noituv1.ServerMessage) string {
|
|||||||
return "chat_message"
|
return "chat_message"
|
||||||
case *noituv1.ServerMessage_ChatHistory:
|
case *noituv1.ServerMessage_ChatHistory:
|
||||||
return "chat_history"
|
return "chat_history"
|
||||||
|
case *noituv1.ServerMessage_QuickMatchStatus:
|
||||||
|
return "quick_match_status"
|
||||||
}
|
}
|
||||||
// Named rather than empty: a missing arm here makes every await for that
|
// Named rather than empty: a missing arm here makes every await for that
|
||||||
// message time out with nothing to say about why.
|
// message time out with nothing to say about why.
|
||||||
@@ -1151,6 +1153,18 @@ func (c *testClient) leaveRoom() {
|
|||||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_LeaveRoom{LeaveRoom: &noituv1.LeaveRoom{}}})
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_LeaveRoom{LeaveRoom: &noituv1.LeaveRoom{}}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *testClient) quickMatch() {
|
||||||
|
c.t.Helper()
|
||||||
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_QuickMatch{QuickMatch: &noituv1.QuickMatch{}}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testClient) cancelQuickMatch() {
|
||||||
|
c.t.Helper()
|
||||||
|
c.send(&noituv1.ClientMessage{
|
||||||
|
Payload: &noituv1.ClientMessage_CancelQuickMatch{CancelQuickMatch: &noituv1.CancelQuickMatch{}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (c *testClient) resign() {
|
func (c *testClient) resign() {
|
||||||
c.t.Helper()
|
c.t.Helper()
|
||||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
||||||
|
|||||||
Reference in new issue
Block a user