mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
feat(wsapi): offer a rematch when an online game ends
A finished room now stays alive to ask both players whether they want another, and restarts with a fresh opening word once both do. Only a room with two connected humans offers one: a bot has nothing to negotiate, so a bot room closes the moment its game ends rather than leaking a goroutine and an engine per finished game. turn_seq no longer restarts at one. A rematch reuses the same connections, so a submission still in flight from the previous game could otherwise match a turn in the new one and be applied to it. The end-of-game decision sits after the whole select, so every way a game can end reaches it. Opening the offer from the message arm alone meant the turn clock — the most common natural ending — closed the room with nothing to accept. A resume is refused into a finished game, including one waiting on a rematch answer, so the room now retires the connection being replaced only once it has agreed to the swap. Retiring it up front ended the game the client was trying to rejoin, which a duplicated tab was enough to trigger. attach releases the room it is leaving. Nothing else told that room the connection had gone, so a session asking for several rooms stranded all but the last, each parked in select holding a goroutine and a room code for the life of the process. Also: RequestRematch is rate limited, because it is the only client message that fans out to both players and an unbounded one lets a burst fill the opponent's outbox until their session is closed for falling behind. And a resume announces itself to the opponent, who was otherwise left watching a disconnect banner for someone already playing again.
This commit is contained in:
1 parent
b541ddf0b5
commit
aea9ea8cc0
5 files changed
+578
-56
No files matched your search
@@ -38,8 +38,9 @@ type hub struct {
|
||||
ctx context.Context
|
||||
dict Dictionary
|
||||
|
||||
turnLimit time.Duration
|
||||
graceFor time.Duration
|
||||
turnLimit time.Duration
|
||||
graceFor time.Duration
|
||||
rematchAfter time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
rooms map[string]*room
|
||||
@@ -48,15 +49,16 @@ type hub struct {
|
||||
joinLimiter *keyedLimiter
|
||||
}
|
||||
|
||||
func newHub(ctx context.Context, dict Dictionary, turnLimit, graceFor time.Duration) *hub {
|
||||
func newHub(ctx context.Context, dict Dictionary, turnLimit, graceFor, rematchAfter time.Duration) *hub {
|
||||
return &hub{
|
||||
ctx: ctx,
|
||||
dict: dict,
|
||||
turnLimit: turnLimit,
|
||||
graceFor: graceFor,
|
||||
rooms: map[string]*room{},
|
||||
sessions: map[string]*session{},
|
||||
joinLimiter: newKeyedLimiter(joinsPerSecond, joinBurst, limiterIdleFor),
|
||||
ctx: ctx,
|
||||
dict: dict,
|
||||
turnLimit: turnLimit,
|
||||
graceFor: graceFor,
|
||||
rematchAfter: rematchAfter,
|
||||
rooms: map[string]*room{},
|
||||
sessions: map[string]*session{},
|
||||
joinLimiter: newKeyedLimiter(joinsPerSecond, joinBurst, limiterIdleFor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +143,7 @@ func (h *hub) newRegisteredRoom() (*room, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := newRoom(h, code, h.turnLimit, h.graceFor)
|
||||
r := newRoom(h, code, h.turnLimit, h.graceFor, h.rematchAfter)
|
||||
|
||||
h.mu.Lock()
|
||||
h.rooms[code] = r
|
||||
|
||||
+215
-31
@@ -26,6 +26,11 @@ const minOpeningOutDegree = 20
|
||||
// neither is worth blocking a session goroutine for.
|
||||
const roomInputCap = 32
|
||||
|
||||
// defaultRematchWindow is how long a finished room waits for both players to
|
||||
// ask for another game when nothing else is configured. It bounds how long a
|
||||
// room outlives its game.
|
||||
const defaultRematchWindow = 30 * time.Second
|
||||
|
||||
// Room input messages. Everything that can change a game arrives as one of
|
||||
// these on a single channel, which is what makes the engine safe without a
|
||||
// lock: the room goroutine is its only reader.
|
||||
@@ -59,6 +64,12 @@ type submitInput struct {
|
||||
turnSeq uint32
|
||||
}
|
||||
|
||||
// rematchInput is one player asking to play the same room again.
|
||||
type rematchInput struct {
|
||||
sess *session
|
||||
player game.PlayerID
|
||||
}
|
||||
|
||||
type resignInput struct {
|
||||
sess *session
|
||||
player game.PlayerID
|
||||
@@ -75,6 +86,10 @@ type disconnectInput struct {
|
||||
type resumeInput struct {
|
||||
player game.PlayerID
|
||||
sess *session
|
||||
// prior is the connection being replaced. The room retires it only once it
|
||||
// has decided the resume is allowed, because closing it on a refusal would
|
||||
// end the very game the client was trying to rejoin.
|
||||
prior *session
|
||||
}
|
||||
|
||||
type botMoveInput struct {
|
||||
@@ -90,6 +105,9 @@ type seat struct {
|
||||
id game.PlayerID
|
||||
nickname string
|
||||
sess *session // nil for the bot, or while a human is disconnected
|
||||
// wantsRematch is this seat's answer to the offer that opens when a game
|
||||
// ends. Cleared whenever a new game starts.
|
||||
wantsRematch bool
|
||||
}
|
||||
|
||||
// room owns one game.
|
||||
@@ -102,13 +120,14 @@ type room struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
hub *hub
|
||||
dict Dictionary
|
||||
engine *game.Engine
|
||||
opening string
|
||||
strategy bot.Strategy
|
||||
turnLimit time.Duration
|
||||
graceFor time.Duration
|
||||
hub *hub
|
||||
dict Dictionary
|
||||
engine *game.Engine
|
||||
opening string
|
||||
strategy bot.Strategy
|
||||
turnLimit time.Duration
|
||||
graceFor time.Duration
|
||||
rematchAfter time.Duration
|
||||
|
||||
seats [2]*seat
|
||||
|
||||
@@ -121,6 +140,11 @@ type room struct {
|
||||
// or nil. Only one seat can be waiting: if the second also drops, there is
|
||||
// nobody left to win and the room ends.
|
||||
disconnected *seat
|
||||
|
||||
// rematchUntil is when the offer that follows a finished game expires, or
|
||||
// the zero time when no offer is open. It is the one flag that keeps a
|
||||
// room alive past its game.
|
||||
rematchUntil time.Time
|
||||
}
|
||||
|
||||
// Dictionary is everything the transport layer needs from the wordlist: the
|
||||
@@ -135,17 +159,21 @@ type Dictionary interface {
|
||||
RandomOpeningWord(minOutDegree int) (string, error)
|
||||
}
|
||||
|
||||
func newRoom(h *hub, code string, turnLimit, graceFor time.Duration) *room {
|
||||
func newRoom(h *hub, code string, turnLimit, graceFor, rematchAfter time.Duration) *room {
|
||||
if rematchAfter <= 0 {
|
||||
rematchAfter = defaultRematchWindow
|
||||
}
|
||||
ctx, cancel := context.WithCancel(h.ctx)
|
||||
return &room{
|
||||
code: code,
|
||||
inputs: make(chan any, roomInputCap),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
hub: h,
|
||||
dict: h.dict,
|
||||
turnLimit: turnLimit,
|
||||
graceFor: graceFor,
|
||||
code: code,
|
||||
inputs: make(chan any, roomInputCap),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
hub: h,
|
||||
dict: h.dict,
|
||||
turnLimit: turnLimit,
|
||||
graceFor: graceFor,
|
||||
rematchAfter: rematchAfter,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +212,7 @@ func (r *room) run() {
|
||||
defer r.cancel()
|
||||
defer r.hub.evict(r.code)
|
||||
|
||||
var turnTimer, graceTimer *time.Timer
|
||||
var turnTimer, graceTimer, rematchTimer *time.Timer
|
||||
stop := func(t *time.Timer) {
|
||||
if t != nil {
|
||||
t.Stop()
|
||||
@@ -193,6 +221,7 @@ func (r *room) run() {
|
||||
defer func() {
|
||||
stop(turnTimer)
|
||||
stop(graceTimer)
|
||||
stop(rematchTimer)
|
||||
}()
|
||||
|
||||
// resetTurnTimer rebuilds the deadline timer after anything that changes
|
||||
@@ -209,13 +238,16 @@ func (r *room) run() {
|
||||
}
|
||||
|
||||
for {
|
||||
var turnC, graceC <-chan time.Time
|
||||
var turnC, graceC, rematchC <-chan time.Time
|
||||
if turnTimer != nil {
|
||||
turnC = turnTimer.C
|
||||
}
|
||||
if graceTimer != nil {
|
||||
graceC = graceTimer.C
|
||||
}
|
||||
if rematchTimer != nil {
|
||||
rematchC = rematchTimer.C
|
||||
}
|
||||
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
@@ -247,7 +279,21 @@ func (r *room) run() {
|
||||
}
|
||||
resetTurnTimer()
|
||||
case disconnectInput:
|
||||
if r.handleDisconnect(m) {
|
||||
// Leaving is how a rematch is declined, so a player who drops
|
||||
// while the offer is open ends the room rather than leaving
|
||||
// the other one watching a countdown that cannot resolve.
|
||||
//
|
||||
// handleDisconnect returning false means the notice was stale —
|
||||
// from a connection the seat no longer holds — and acting on
|
||||
// that would end a room whose players are both still here.
|
||||
if r.offeringRematch() {
|
||||
if applied, _ := r.handleDisconnect(m); applied {
|
||||
r.abandonRematch()
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
if _, live := r.handleDisconnect(m); live {
|
||||
stop(graceTimer)
|
||||
graceTimer = time.NewTimer(r.graceFor)
|
||||
}
|
||||
@@ -257,10 +303,11 @@ func (r *room) run() {
|
||||
stop(graceTimer)
|
||||
graceTimer = nil
|
||||
resetTurnTimer()
|
||||
}
|
||||
|
||||
if r.engine != nil && r.engine.Over() {
|
||||
return
|
||||
case rematchInput:
|
||||
r.handleRematch(m, time.Now())
|
||||
// A rematch that both sides accepted has already started a new
|
||||
// game, so the turn clock has to come back with it.
|
||||
resetTurnTimer()
|
||||
}
|
||||
|
||||
case <-turnC:
|
||||
@@ -269,13 +316,33 @@ func (r *room) run() {
|
||||
// strictly after it. There is no window where both apply.
|
||||
if r.engine != nil && r.engine.Timeout(time.Now()) {
|
||||
r.broadcastGameOver()
|
||||
return
|
||||
}
|
||||
resetTurnTimer()
|
||||
|
||||
case <-graceC:
|
||||
r.endForAbandonment()
|
||||
return
|
||||
|
||||
case <-rematchC:
|
||||
// Nobody, or only one of them, asked in time.
|
||||
r.abandonRematch()
|
||||
return
|
||||
}
|
||||
|
||||
// Every way a game can end arrives here: a move, a resignation, a
|
||||
// disconnection, or the turn clock. A finished game closes the room
|
||||
// unless both players are still present to be asked for another. The
|
||||
// offer check keeps this from firing again while one is already open.
|
||||
if r.engine != nil && r.engine.Over() && !r.offeringRematch() {
|
||||
if !r.offerRematch(time.Now()) {
|
||||
return
|
||||
}
|
||||
stop(rematchTimer)
|
||||
rematchTimer = time.NewTimer(r.rematchAfter)
|
||||
}
|
||||
if !r.offeringRematch() {
|
||||
stop(rematchTimer)
|
||||
rematchTimer = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,7 +431,14 @@ func (r *room) beginGame() error {
|
||||
}
|
||||
r.engine = engine
|
||||
r.opening = opening
|
||||
r.turnSeq = 1
|
||||
// Never restarts at 1. A rematch reuses the same connections, so a
|
||||
// submission still in flight from the previous game would otherwise be
|
||||
// able to match a turn in this one and be applied to it.
|
||||
r.turnSeq++
|
||||
for _, s := range r.seats {
|
||||
s.wantsRematch = false
|
||||
}
|
||||
r.rematchUntil = time.Time{}
|
||||
|
||||
for _, s := range r.seats {
|
||||
r.sendGameStarted(s)
|
||||
@@ -543,12 +617,16 @@ func (r *room) broadcastGameOver() {
|
||||
|
||||
// handleDisconnect holds the seat open, reporting whether a grace window
|
||||
// should now run.
|
||||
func (r *room) handleDisconnect(m disconnectInput) bool {
|
||||
// handleDisconnect reports two separate things, because the caller needs both
|
||||
// and they are not the same question: whether the notice actually applied to
|
||||
// the seat, and whether a game is still running that the player could come
|
||||
// back to. A disconnection during a rematch offer applies and is not live.
|
||||
func (r *room) handleDisconnect(m disconnectInput) (applied, live bool) {
|
||||
s := r.seatOf(m.player)
|
||||
// A stale notice from a connection the player already replaced. Evicting
|
||||
// on it would drop the seat the new socket is sitting in.
|
||||
if s == nil || s.sess == nil || s.sess != m.sess {
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
s.sess = nil
|
||||
|
||||
@@ -556,7 +634,7 @@ func (r *room) handleDisconnect(m disconnectInput) bool {
|
||||
// room open for.
|
||||
if r.disconnected != nil && r.disconnected != s {
|
||||
r.cancel()
|
||||
return false
|
||||
return true, false
|
||||
}
|
||||
r.disconnected = s
|
||||
|
||||
@@ -565,13 +643,17 @@ func (r *room) handleDisconnect(m disconnectInput) bool {
|
||||
// forever, holding a goroutine and a room code for a game nobody is in.
|
||||
if r.engine == nil {
|
||||
r.cancel()
|
||||
return false
|
||||
return true, false
|
||||
}
|
||||
|
||||
live = !r.engine.Over()
|
||||
if other := r.opponentSeat(s.id); other != nil && other.sess != nil {
|
||||
other.sess.send(opponentLeftMsg(true, uint32(r.graceFor.Milliseconds())))
|
||||
// can_reconnect only means something while there is a game to come
|
||||
// back to. Promising it after the final move contradicts the frame
|
||||
// that follows it.
|
||||
other.sess.send(opponentLeftMsg(live, uint32(r.graceFor.Milliseconds())))
|
||||
}
|
||||
return r.engine != nil && !r.engine.Over()
|
||||
return true, live
|
||||
}
|
||||
|
||||
// handleResume rebinds a seat to a new connection and replays the position.
|
||||
@@ -585,10 +667,21 @@ func (r *room) handleResume(m resumeInput) {
|
||||
m.sess.send(errorMsg("session_not_resumable"))
|
||||
return
|
||||
}
|
||||
// A finished game has no seat to take, including one still waiting on a
|
||||
// rematch answer. The old connection stays exactly as it was.
|
||||
if r.engine != nil && r.engine.Over() {
|
||||
m.sess.send(errorMsg("game_already_over"))
|
||||
return
|
||||
}
|
||||
|
||||
// Accepted. Only now is the old connection finished: its token is spent and
|
||||
// its socket is either gone or about to be, and leaving it registered would
|
||||
// let a third connection claim the same seat.
|
||||
m.sess.attach(r, string(m.player))
|
||||
if m.prior != nil {
|
||||
m.sess.hub.unregister(m.prior.resumeToken)
|
||||
m.prior.close()
|
||||
}
|
||||
s.sess = m.sess
|
||||
// The seat keeps the name it was given. Re-reading it from the new
|
||||
// connection would let a reconnect rename a player mid-game, including
|
||||
@@ -597,6 +690,13 @@ func (r *room) handleResume(m resumeInput) {
|
||||
r.disconnected = nil
|
||||
}
|
||||
|
||||
// Tell the other player their opponent is back. Without this the seat is
|
||||
// restored but the waiting player is left watching a disconnect banner for
|
||||
// somebody who is already playing again.
|
||||
if other := r.opponentSeat(s.id); other != nil && other.sess != nil {
|
||||
other.sess.send(roomJoinedMsg(r.code, s.nickname))
|
||||
}
|
||||
|
||||
// Resumed into a room whose game has not started: the seat is restored and
|
||||
// the client waits for an opponent exactly as it was.
|
||||
if r.engine == nil {
|
||||
@@ -648,6 +748,90 @@ func (r *room) endForAbandonment() {
|
||||
}
|
||||
}
|
||||
|
||||
// offerRematch decides what a finished game means for the room, and reports
|
||||
// whether the room should keep running.
|
||||
//
|
||||
// Only a room with two connected humans can offer one. A bot room has nothing
|
||||
// to negotiate — the client simply asks for another game — and a room whose
|
||||
// opponent has already gone has nobody to ask.
|
||||
func (r *room) offerRematch(now time.Time) bool {
|
||||
if r.strategy != nil {
|
||||
return false
|
||||
}
|
||||
for _, s := range r.seats {
|
||||
if s == nil || s.sess == nil {
|
||||
return false
|
||||
}
|
||||
s.wantsRematch = false
|
||||
}
|
||||
|
||||
r.rematchUntil = now.Add(r.rematchAfter)
|
||||
r.broadcastRematchState(now)
|
||||
return true
|
||||
}
|
||||
|
||||
// offeringRematch reports whether an offer is currently open.
|
||||
func (r *room) offeringRematch() bool { return !r.rematchUntil.IsZero() }
|
||||
|
||||
// handleRematch records one player's answer and starts the next game once both
|
||||
// have given it.
|
||||
func (r *room) handleRematch(m rematchInput, now time.Time) {
|
||||
if !r.occupies(m.sess, m.player) {
|
||||
m.sess.send(errorMsg("not_your_seat"))
|
||||
return
|
||||
}
|
||||
if !r.offeringRematch() {
|
||||
m.sess.send(errorMsg("no_rematch_offered"))
|
||||
return
|
||||
}
|
||||
|
||||
s := r.seatOf(m.player)
|
||||
s.wantsRematch = true
|
||||
|
||||
if !r.seats[0].wantsRematch || !r.seats[1].wantsRematch {
|
||||
r.broadcastRematchState(now)
|
||||
return
|
||||
}
|
||||
|
||||
// beginGame clears the offer and the acceptances, so the state broadcast
|
||||
// above is not repeated here: GameStarted is the answer.
|
||||
if err := r.beginGame(); err != nil {
|
||||
slog.Error("could not start rematch", "room", r.code, "err", err)
|
||||
r.broadcastError("game_start_failed")
|
||||
r.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastRematchState tells each player where both answers stand. It is
|
||||
// built per recipient because "mine" and "theirs" are different for each.
|
||||
func (r *room) broadcastRematchState(now time.Time) {
|
||||
left := uint32(max(0, r.rematchUntil.Sub(now).Milliseconds()))
|
||||
|
||||
for i, s := range r.seats {
|
||||
if s == nil || s.sess == nil {
|
||||
continue
|
||||
}
|
||||
s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RematchState{
|
||||
RematchState: &noituv1.RematchState{
|
||||
IAccepted: s.wantsRematch,
|
||||
OpponentAccepted: r.seats[1-i].wantsRematch,
|
||||
ExpiresInMs: left,
|
||||
},
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
// abandonRematch tells whoever is still here that the offer is dead, so their
|
||||
// countdown resolves into an answer instead of just running out.
|
||||
func (r *room) abandonRematch() {
|
||||
for _, s := range r.seats {
|
||||
if s == nil || s.sess == nil {
|
||||
continue
|
||||
}
|
||||
s.sess.send(opponentLeftMsg(false, 0))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *room) broadcastError(code string) {
|
||||
for _, s := range r.seats {
|
||||
if s != nil && s.sess != nil {
|
||||
|
||||
@@ -20,6 +20,9 @@ type Config struct {
|
||||
TurnLimit time.Duration
|
||||
// GraceFor is how long a disconnected seat is held open.
|
||||
GraceFor time.Duration
|
||||
// RematchFor is how long a finished room waits for both players to ask for
|
||||
// another game. Zero falls back to a built-in default.
|
||||
RematchFor time.Duration
|
||||
// AllowedOrigins is matched by coder/websocket against the Origin header.
|
||||
// Empty means same-origin only, which is the right default for a binary
|
||||
// that also serves the frontend.
|
||||
@@ -42,7 +45,7 @@ func NewServer(ctx context.Context, dict Dictionary, cfg Config) *Server {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
s := &Server{
|
||||
hub: newHub(ctx, dict, cfg.TurnLimit, cfg.GraceFor),
|
||||
hub: newHub(ctx, dict, cfg.TurnLimit, cfg.GraceFor, cfg.RematchFor),
|
||||
mux: http.NewServeMux(),
|
||||
cancel: cancel,
|
||||
cfg: cfg,
|
||||
|
||||
@@ -111,9 +111,9 @@ func newSession(ctx context.Context, conn *websocket.Conn, h *hub, remoteIP stri
|
||||
readCtx, cancelRead := context.WithCancel(context.Background())
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
return &session{
|
||||
readCtx: readCtx,
|
||||
cancelRead: cancelRead,
|
||||
flushed: make(chan struct{}),
|
||||
readCtx: readCtx,
|
||||
cancelRead: cancelRead,
|
||||
flushed: make(chan struct{}),
|
||||
id: randomToken(),
|
||||
resumeToken: randomToken(),
|
||||
remoteIP: remoteIP,
|
||||
@@ -142,9 +142,19 @@ func (s *session) setNickname(n string) {
|
||||
// attach binds this connection to a room seat.
|
||||
func (s *session) attach(r *room, seatName string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
previous, previousID := s.room, s.playerID
|
||||
s.room = r
|
||||
s.playerID = playerIDFor(seatName)
|
||||
s.mu.Unlock()
|
||||
|
||||
// Releasing the old room is not tidiness. Nothing else tells it this
|
||||
// connection has gone: leaveRoom only ever notifies the current room, so an
|
||||
// unreleased room parks in select forever, holding a goroutine and a room
|
||||
// code for the life of the process. One connection asking for several rooms
|
||||
// is all it takes.
|
||||
if previous != nil && previous != r {
|
||||
previous.send(disconnectInput{player: previousID, sess: s})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) currentRoom() (*room, game.PlayerID) {
|
||||
@@ -391,6 +401,24 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error {
|
||||
s.send(errorMsg("not_in_a_game"))
|
||||
}
|
||||
|
||||
case *noituv1.ClientMessage_RequestRematch:
|
||||
// Rate-limited like a submission: every accepted request is broadcast
|
||||
// to both seats, so an unbounded one lets a player flood the opponent's
|
||||
// outbox until their session is closed for falling behind.
|
||||
if !s.submitLimiter.allow(time.Now()) {
|
||||
s.send(errorMsg("too_fast"))
|
||||
return nil
|
||||
}
|
||||
// A dropped request leaves the player watching a countdown that will
|
||||
// never resolve, so it is worth an explicit refusal.
|
||||
if r, id := s.currentRoom(); r != nil {
|
||||
if !r.send(rematchInput{sess: s, player: id}) {
|
||||
s.send(errorMsg("game_already_over"))
|
||||
}
|
||||
} else {
|
||||
s.send(errorMsg("not_in_a_game"))
|
||||
}
|
||||
|
||||
case *noituv1.ClientMessage_Ping:
|
||||
s.send(pongMsg(p.Ping.GetClientTimeMs(), time.Now().UnixMilli()))
|
||||
}
|
||||
@@ -440,17 +468,13 @@ func (s *session) resumeFrom(prior *session) {
|
||||
s.send(errorMsg("game_already_over"))
|
||||
return
|
||||
}
|
||||
s.attach(r, string(id))
|
||||
if !r.send(resumeInput{player: id, sess: s}) {
|
||||
if !r.send(resumeInput{player: id, sess: s, prior: prior}) {
|
||||
s.send(errorMsg("game_already_over"))
|
||||
return
|
||||
}
|
||||
|
||||
// The old connection is finished: its token is spent and its socket is
|
||||
// either gone or about to be. Leaving it registered would let a third
|
||||
// connection claim the same seat.
|
||||
s.hub.unregister(prior.resumeToken)
|
||||
prior.close()
|
||||
// Deliberately no attach and no close here. The room has not decided yet,
|
||||
// and a refused resume that had already closed the old connection would end
|
||||
// the game it was trying to rejoin.
|
||||
}
|
||||
|
||||
func (s *session) handleSubmit(w *noituv1.SubmitWord) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package wsapi
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
@@ -111,6 +112,9 @@ func newTestServer(t *testing.T, dict Dictionary, cfg Config) (*Server, string)
|
||||
if cfg.GraceFor == 0 {
|
||||
cfg.GraceFor = time.Second
|
||||
}
|
||||
if cfg.RematchFor == 0 {
|
||||
cfg.RematchFor = time.Second
|
||||
}
|
||||
|
||||
api := NewServer(ctx, dict, cfg)
|
||||
hs := httptest.NewServer(api)
|
||||
@@ -201,8 +205,12 @@ func payloadCase(m *noituv1.ServerMessage) string {
|
||||
return "error"
|
||||
case *noituv1.ServerMessage_Pong:
|
||||
return "pong"
|
||||
case *noituv1.ServerMessage_RematchState:
|
||||
return "rematch_state"
|
||||
}
|
||||
return ""
|
||||
// Named rather than empty: a missing arm here makes every await for that
|
||||
// message time out with nothing to say about why.
|
||||
return fmt.Sprintf("unmapped(%T)", m.GetPayload())
|
||||
}
|
||||
|
||||
func (c *testClient) hello(nickname string) *noituv1.Welcome {
|
||||
@@ -911,3 +919,304 @@ func settle() {
|
||||
}
|
||||
runtime.GC()
|
||||
}
|
||||
|
||||
// --- rematch ---------------------------------------------------------------
|
||||
|
||||
// pvpRoom seats two players and returns them with the opening position, so a
|
||||
// rematch test can get to a finished game without restating the setup.
|
||||
func pvpRoom(t *testing.T, url string) (host, guest *testClient, start *noituv1.GameStarted) {
|
||||
t.Helper()
|
||||
|
||||
host = dial(t, url)
|
||||
host.hello("Chủ phòng")
|
||||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||||
code := host.await("room_created").GetRoomCreated().GetRoomCode()
|
||||
|
||||
guest = dial(t, url)
|
||||
guest.hello("Khách")
|
||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
|
||||
start = host.await("game_started").GetGameStarted()
|
||||
guest.await("game_started")
|
||||
return host, guest, start
|
||||
}
|
||||
|
||||
func (c *testClient) requestRematch() {
|
||||
c.t.Helper()
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_RequestRematch{
|
||||
RequestRematch: &noituv1.RequestRematch{},
|
||||
}})
|
||||
}
|
||||
|
||||
// resignAndSettle ends the game and drains the offer that follows, returning
|
||||
// the rematch state each player was shown.
|
||||
func resignAndSettle(t *testing.T, host, guest *testClient) (hostState, guestState *noituv1.RematchState) {
|
||||
t.Helper()
|
||||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
||||
host.await("game_over")
|
||||
guest.await("game_over")
|
||||
return host.await("rematch_state").GetRematchState(),
|
||||
guest.await("rematch_state").GetRematchState()
|
||||
}
|
||||
|
||||
// TestRematchOfferFollowsAFinishedPvPGame checks the room outlives its game.
|
||||
// Before rematch existed the room goroutine returned the moment the engine was
|
||||
// over, so there was nothing left to ask.
|
||||
func TestRematchOfferFollowsAFinishedPvPGame(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, _ := pvpRoom(t, url)
|
||||
|
||||
hostState, guestState := resignAndSettle(t, host, guest)
|
||||
|
||||
for name, s := range map[string]*noituv1.RematchState{"host": hostState, "guest": guestState} {
|
||||
if s.GetIAccepted() || s.GetOpponentAccepted() {
|
||||
t.Errorf("%s: the opening offer should have nobody accepted yet, got %+v", name, s)
|
||||
}
|
||||
if s.GetExpiresInMs() == 0 {
|
||||
t.Errorf("%s: the offer must carry the time left to answer", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRematchStateIsRenderedPerRecipient is the guard against the two booleans
|
||||
// being swapped, which would show a player their opponent's answer as theirs.
|
||||
func TestRematchStateIsRenderedPerRecipient(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, _ := pvpRoom(t, url)
|
||||
resignAndSettle(t, host, guest)
|
||||
|
||||
host.requestRematch()
|
||||
|
||||
hostState := host.await("rematch_state").GetRematchState()
|
||||
guestState := guest.await("rematch_state").GetRematchState()
|
||||
|
||||
if !hostState.GetIAccepted() || hostState.GetOpponentAccepted() {
|
||||
t.Errorf("the asker should see only their own acceptance, got %+v", hostState)
|
||||
}
|
||||
if guestState.GetIAccepted() || !guestState.GetOpponentAccepted() {
|
||||
t.Errorf("the other player should see only the opponent's acceptance, got %+v", guestState)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRematchStartsANewGameWhenBothAccept covers the whole point of the
|
||||
// feature, and the two properties a fresh game has to have.
|
||||
func TestRematchStartsANewGameWhenBothAccept(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, first := pvpRoom(t, url)
|
||||
resignAndSettle(t, host, guest)
|
||||
|
||||
host.requestRematch()
|
||||
host.await("rematch_state")
|
||||
guest.requestRematch()
|
||||
|
||||
second := host.await("game_started").GetGameStarted()
|
||||
guest.await("game_started")
|
||||
|
||||
if second.GetTurnSeq() <= first.GetTurnSeq() {
|
||||
t.Errorf("turn_seq must keep rising across a rematch: %d then %d, so a submission "+
|
||||
"still in flight from the first game could be applied to the second",
|
||||
first.GetTurnSeq(), second.GetTurnSeq())
|
||||
}
|
||||
if second.GetOpeningWord() == "" {
|
||||
t.Error("a rematch needs its own opening word")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRematchIsRefusedWhileTheGameIsLive keeps the offer from being a way to
|
||||
// abandon a game in progress.
|
||||
func TestRematchIsRefusedWhileTheGameIsLive(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, _, _ := pvpRoom(t, url)
|
||||
|
||||
host.requestRematch()
|
||||
|
||||
if got := host.await("error").GetError().GetCode(); got != "no_rematch_offered" {
|
||||
t.Errorf("asking mid-game returned %q, want no_rematch_offered", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeavingDeclinesTheRematch: there is no decline message, so the socket
|
||||
// closing has to be the one, and the other player must be told rather than
|
||||
// left watching a countdown that cannot resolve.
|
||||
func TestLeavingDeclinesTheRematch(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, _ := pvpRoom(t, url)
|
||||
resignAndSettle(t, host, guest)
|
||||
|
||||
_ = guest.conn.Close(websocket.StatusNormalClosure, "")
|
||||
|
||||
left := host.await("opponent_left").GetOpponentLeft()
|
||||
if left.GetCanReconnect() {
|
||||
t.Error("a player who left during the rematch offer is not coming back")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotRoomDoesNotOfferARematch: a bot has nothing to negotiate, and the
|
||||
// client simply asks for another game. Keeping the room alive would leave one
|
||||
// goroutine and one engine per finished bot game.
|
||||
func TestBotRoomDoesNotOfferARematch(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
|
||||
c := dial(t, url)
|
||||
c.hello("Người chơi")
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartBotGame{
|
||||
StartBotGame: &noituv1.StartBotGame{Difficulty: noituv1.Difficulty_DIFFICULTY_EASY},
|
||||
}})
|
||||
c.await("game_started")
|
||||
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
||||
c.await("game_over")
|
||||
|
||||
// game_already_over is the room reporting that it has stopped reading,
|
||||
// which is the evidence wanted here: the goroutine and engine are gone
|
||||
// rather than parked waiting for an answer no bot can give.
|
||||
c.requestRematch()
|
||||
if got := c.await("error").GetError().GetCode(); got != "game_already_over" {
|
||||
t.Errorf("a finished bot room answered %q, want it to be gone", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRematchOfferExpires bounds how long a room outlives its game.
|
||||
func TestRematchOfferExpires(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{RematchFor: 150 * time.Millisecond})
|
||||
host, guest, _ := pvpRoom(t, url)
|
||||
resignAndSettle(t, host, guest)
|
||||
|
||||
// Only one side asks, so the offer can only end by running out.
|
||||
host.requestRematch()
|
||||
host.await("rematch_state")
|
||||
|
||||
if got := host.await("opponent_left").GetOpponentLeft(); got.GetCanReconnect() {
|
||||
t.Error("an expired offer is final, not a reconnect window")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRematchIsOfferedAfterATimeout is the regression for a rematch that could
|
||||
// only follow some endings. The offer was opened from the message arm of the
|
||||
// room loop, so the turn clock — the most common way a game actually ends —
|
||||
// closed the room with nothing to accept, while the client still showed the
|
||||
// button.
|
||||
func TestRematchIsOfferedAfterATimeout(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{TurnLimit: 200 * time.Millisecond})
|
||||
host, guest, _ := pvpRoom(t, url)
|
||||
|
||||
// Neither player moves, so the only thing that can end this is the clock.
|
||||
host.await("game_over")
|
||||
guest.await("game_over")
|
||||
|
||||
if got := host.await("rematch_state").GetRematchState(); got.GetExpiresInMs() == 0 {
|
||||
t.Error("a game that ended on the clock should still offer a rematch")
|
||||
}
|
||||
guest.await("rematch_state")
|
||||
|
||||
host.requestRematch()
|
||||
host.await("rematch_state")
|
||||
guest.requestRematch()
|
||||
|
||||
host.await("game_started")
|
||||
guest.await("game_started")
|
||||
}
|
||||
|
||||
// TestRefusedResumeLeavesTheLiveGameAlone is the regression for a resume that
|
||||
// retired the connection it was replacing before the room had agreed to the
|
||||
// swap. During a rematch offer the room refuses, so a second connection
|
||||
// presenting the same token used to disconnect the player who was still there
|
||||
// and take the room down with them.
|
||||
func TestRefusedResumeLeavesTheLiveGameAlone(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
|
||||
host := dial(t, url)
|
||||
welcome := host.hello("Chủ phòng")
|
||||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||||
code := host.await("room_created").GetRoomCreated().GetRoomCode()
|
||||
|
||||
guest := dial(t, url)
|
||||
guest.hello("Khách")
|
||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
host.await("game_started")
|
||||
guest.await("game_started")
|
||||
|
||||
resignAndSettle(t, host, guest)
|
||||
|
||||
// A duplicated tab carries the same token and tries to reclaim the seat.
|
||||
second := dial(t, url)
|
||||
second.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{
|
||||
ProtocolVersion: ProtocolVersion,
|
||||
Nickname: "Chủ phòng",
|
||||
ResumeToken: welcome.GetResumeToken(),
|
||||
}}})
|
||||
second.await("welcome")
|
||||
|
||||
if got := second.await("error").GetError().GetCode(); got != "game_already_over" {
|
||||
t.Errorf("resume into a finished game returned %q", got)
|
||||
}
|
||||
|
||||
// The original connection is untouched: the offer it is holding still
|
||||
// works, which it would not if the room had closed underneath it.
|
||||
host.requestRematch()
|
||||
if got := host.await("rematch_state"); !got.GetRematchState().GetIAccepted() {
|
||||
t.Error("the player who never left should still be able to accept the rematch")
|
||||
}
|
||||
guest.requestRematch()
|
||||
host.await("game_started")
|
||||
}
|
||||
|
||||
// TestOneConnectionCannotStrandRooms is the regression for rooms that outlived
|
||||
// the only connection that could ever end them. attach overwrote the session's
|
||||
// room pointer and nothing told the old room, so it parked in select forever
|
||||
// holding a goroutine and a room code.
|
||||
func TestOneConnectionCannotStrandRooms(t *testing.T) {
|
||||
api, url := newTestServer(t, chainDict(), Config{})
|
||||
|
||||
c := dial(t, url)
|
||||
c.hello("Người chơi")
|
||||
|
||||
const rooms = 4
|
||||
for range rooms {
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||||
c.await("room_created")
|
||||
}
|
||||
|
||||
_ = c.conn.Close(websocket.StatusNormalClosure, "")
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
api.hub.mu.Lock()
|
||||
left := len(api.hub.rooms)
|
||||
api.hub.mu.Unlock()
|
||||
|
||||
if left == 0 {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%d of %d rooms outlived the only connection that was ever in them", left, rooms)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRematchRequestsAreRateLimited: every accepted request is broadcast to
|
||||
// both seats, so an unbounded one lets a player fill the opponent's outbox
|
||||
// until the server closes their session for falling behind.
|
||||
func TestRematchRequestsAreRateLimited(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, _ := pvpRoom(t, url)
|
||||
resignAndSettle(t, host, guest)
|
||||
|
||||
for range submitBurst + 5 {
|
||||
host.requestRematch()
|
||||
}
|
||||
|
||||
// The limiter answers before the room does, so a refusal has to appear in
|
||||
// the stream rather than an unbroken run of rematch states.
|
||||
for range 30 {
|
||||
if payloadCase(host.recv()) == "error" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("a burst of rematch requests was never refused")
|
||||
}
|
||||
Reference in new issue
Block a user