mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-14 02:16:25 +00:00
feat(online): make a room a lobby with an owner and a ready-up
A room used to be a wrapper around one game: joining started it, and the room died with it unless both players accepted a rematch inside thirty seconds. It is now a lobby that outlives its games. Whoever created the room owns it and the other seat is the guest. The guest readies and the owner starts; the owner has no readiness of their own, because starting is the same statement. A finished game returns both to the lobby, where the next one is agreed exactly as the last was — the readiness that started a game is spent with it. A guest takes their readiness back before leaving, which is deliberate friction: a player the owner is waiting on should have to say so before walking away. The owner can free the seat of a guest who is not ready, and not of one who is — readiness is a commitment, not an inconvenience. An owner who leaves hands the room to whoever is left, unreadied, because they are the one who starts now. Something has to bound a room that outlives its games: the last player out closes it, as does ten minutes in a lobby nobody started a game in. A dropped connection is still not a player leaving — the seat is held for the reconnect window in the lobby as well as mid-game, so a refresh no longer costs somebody their room, and a resume lands in the lobby it left. The rematch handshake is retired, and RoomCreated and RoomJoined go with it. All three described part of what RoomState now describes in full, and three messages for one lobby is three ways for a client to hold a view of it the server never had. One snapshot per recipient, broadcast from the one place that knows an input is finished, so no handler can forget to send it.
This commit is contained in:
1 parent
ef638e52fd
commit
d481a093ec
39 files changed
+2352
-1215
No files matched your search
Generated
+406
-300
File diff suppressed because it is too large.
Load diff
@@ -59,18 +59,6 @@ func welcomeMsg(sessionID, resumeToken, nickname string) *noituv1.ServerMessage
|
||||
}}}
|
||||
}
|
||||
|
||||
func roomCreatedMsg(code string) *noituv1.ServerMessage {
|
||||
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RoomCreated{
|
||||
RoomCreated: &noituv1.RoomCreated{RoomCode: code},
|
||||
}}
|
||||
}
|
||||
|
||||
func roomJoinedMsg(code, opponent string) *noituv1.ServerMessage {
|
||||
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RoomJoined{
|
||||
RoomJoined: &noituv1.RoomJoined{RoomCode: code, OpponentName: opponent},
|
||||
}}
|
||||
}
|
||||
|
||||
func moveRejectedMsg(reason noituv1.RejectReason, word string, turnSeq uint32) *noituv1.ServerMessage {
|
||||
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_MoveRejected{
|
||||
MoveRejected: &noituv1.MoveRejected{Reason: reason, Word: word, TurnSeq: turnSeq},
|
||||
|
||||
@@ -38,9 +38,9 @@ type hub struct {
|
||||
ctx context.Context
|
||||
dict Dictionary
|
||||
|
||||
turnLimit time.Duration
|
||||
graceFor time.Duration
|
||||
rematchAfter time.Duration
|
||||
turnLimit time.Duration
|
||||
graceFor time.Duration
|
||||
idleFor time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
rooms map[string]*room
|
||||
@@ -49,16 +49,16 @@ type hub struct {
|
||||
joinLimiter *keyedLimiter
|
||||
}
|
||||
|
||||
func newHub(ctx context.Context, dict Dictionary, turnLimit, graceFor, rematchAfter time.Duration) *hub {
|
||||
func newHub(ctx context.Context, dict Dictionary, turnLimit, graceFor, idleFor time.Duration) *hub {
|
||||
return &hub{
|
||||
ctx: ctx,
|
||||
dict: dict,
|
||||
turnLimit: turnLimit,
|
||||
graceFor: graceFor,
|
||||
rematchAfter: rematchAfter,
|
||||
rooms: map[string]*room{},
|
||||
sessions: map[string]*session{},
|
||||
joinLimiter: newKeyedLimiter(joinsPerSecond, joinBurst, limiterIdleFor),
|
||||
ctx: ctx,
|
||||
dict: dict,
|
||||
turnLimit: turnLimit,
|
||||
graceFor: graceFor,
|
||||
idleFor: idleFor,
|
||||
rooms: map[string]*room{},
|
||||
sessions: map[string]*session{},
|
||||
joinLimiter: newKeyedLimiter(joinsPerSecond, joinBurst, limiterIdleFor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ func (h *hub) newRegisteredRoom() (*room, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := newRoom(h, code, h.turnLimit, h.graceFor, h.rematchAfter)
|
||||
r := newRoom(h, code, h.turnLimit, h.graceFor, h.idleFor)
|
||||
|
||||
h.mu.Lock()
|
||||
h.rooms[code] = r
|
||||
|
||||
@@ -34,13 +34,21 @@ func startPvP(t *testing.T, url string) (host, guest *testClient, code string) {
|
||||
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()
|
||||
code = host.await("room_state").GetRoomState().GetRoomCode()
|
||||
|
||||
guest = dial(t, url)
|
||||
guest.hello("Khách")
|
||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
host.await("room_state")
|
||||
guest.await("room_state")
|
||||
|
||||
// The lobby is where a game is agreed now: the guest readies and the owner
|
||||
// starts it.
|
||||
guest.setReady(true)
|
||||
host.await("room_state")
|
||||
host.startGame()
|
||||
host.await("game_started")
|
||||
guest.await("game_started")
|
||||
return host, guest, code
|
||||
@@ -114,7 +122,7 @@ func TestCannotJoinYourOwnRoom(t *testing.T) {
|
||||
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()
|
||||
code := host.await("room_state").GetRoomState().GetRoomCode()
|
||||
|
||||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
@@ -164,7 +172,7 @@ func TestAbandonedRoomIsEvicted(t *testing.T) {
|
||||
host := dial(t, url)
|
||||
host.hello("Chủ phòng")
|
||||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||||
host.await("room_created")
|
||||
host.await("room_state")
|
||||
|
||||
_ = host.conn.Close(websocket.StatusGoingAway, "")
|
||||
|
||||
@@ -286,7 +294,7 @@ func TestOpponentNeverSeesAnUnsanitizedNickname(t *testing.T) {
|
||||
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()
|
||||
code := host.await("room_state").GetRoomState().GetRoomCode()
|
||||
|
||||
hostile := " Kẻ" + string(nul) + " xấu" + string(zeroWidthSpace) + string(bidiOverride) +
|
||||
" " + strings.Repeat("z", 40)
|
||||
@@ -296,7 +304,7 @@ func TestOpponentNeverSeesAnUnsanitizedNickname(t *testing.T) {
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
|
||||
shown := host.await("room_joined").GetRoomJoined().GetOpponentName()
|
||||
shown := host.await("room_state").GetRoomState().GetOpponentName()
|
||||
for _, r := range []rune{nul, zeroWidthSpace, bidiOverride} {
|
||||
if strings.ContainsRune(shown, r) {
|
||||
t.Errorf("opponent name %q still carries the invisible rune %U", shown, r)
|
||||
@@ -343,10 +351,11 @@ func TestStaleRejectionCarriesTheServerSequence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResumeAfterGameEndedSaysSo. GraceFor is longer than TurnLimit, so a
|
||||
// player who drops on their own turn loses before the grace window closes.
|
||||
// Their reconnect must be told that, not left holding a Welcome and silence.
|
||||
func TestResumeAfterGameEndedSaysSo(t *testing.T) {
|
||||
// TestResumeAfterGameEndedLandsInTheLobby. GraceFor is longer than TurnLimit,
|
||||
// so a player who drops on their own turn loses before the grace window
|
||||
// closes. Their reconnect must land somewhere real - the lobby the room went
|
||||
// back to - rather than holding a Welcome and silence.
|
||||
func TestResumeAfterGameEndedLandsInTheLobby(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{
|
||||
TurnLimit: 200 * time.Millisecond,
|
||||
GraceFor: 5 * time.Second,
|
||||
@@ -355,13 +364,14 @@ func TestResumeAfterGameEndedSaysSo(t *testing.T) {
|
||||
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()
|
||||
code := host.await("room_state").GetRoomState().GetRoomCode()
|
||||
|
||||
guest := dial(t, url)
|
||||
guest.hello("Khách")
|
||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
readyAndStart(t, host, guest)
|
||||
host.await("game_started")
|
||||
guest.await("game_started")
|
||||
|
||||
@@ -377,8 +387,12 @@ func TestResumeAfterGameEndedSaysSo(t *testing.T) {
|
||||
}}})
|
||||
back.await("welcome")
|
||||
|
||||
if got := back.await("error").GetError().GetCode(); got != "game_already_over" {
|
||||
t.Errorf("resume into a finished game returned %q", got)
|
||||
state := back.await("room_state").GetRoomState()
|
||||
if state.GetRoomCode() != code {
|
||||
t.Errorf("resumed into room %q, want %q", state.GetRoomCode(), code)
|
||||
}
|
||||
if !state.GetOpponentPresent() {
|
||||
t.Errorf("the player who stayed is missing from the resumed lobby: %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+402
-205
@@ -31,10 +31,15 @@ const maxSuggestions = 3
|
||||
// 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
|
||||
// defaultIdleWindow is how long a lobby nobody starts a game in stays open
|
||||
// when nothing else is configured.
|
||||
//
|
||||
// A room now outlives its games, so something has to bound it: without this
|
||||
// one open tab holds a goroutine and a room code for the life of the process.
|
||||
// Long enough to read an invite and talk about it, short enough that abandoned
|
||||
// rooms do not accumulate. A running game needs no such bound — the turn clock
|
||||
// already ends it.
|
||||
const defaultIdleWindow = 10 * time.Minute
|
||||
|
||||
// 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
|
||||
@@ -69,10 +74,27 @@ type submitInput struct {
|
||||
turnSeq uint32
|
||||
}
|
||||
|
||||
// rematchInput is one player asking to play the same room again.
|
||||
type rematchInput struct {
|
||||
// lobbyAction is one thing a player does to the room rather than to a game.
|
||||
type lobbyAction uint8
|
||||
|
||||
const (
|
||||
lobbyReady lobbyAction = iota
|
||||
lobbyStart
|
||||
lobbyKick
|
||||
lobbyLeave
|
||||
)
|
||||
|
||||
// lobbyInput is one lobby action. They share a type because they share every
|
||||
// authorization step — the seat, the room's mode, and whether a game is
|
||||
// running — and splitting them would mean four copies of those checks.
|
||||
type lobbyInput struct {
|
||||
sess *session
|
||||
player game.PlayerID
|
||||
action lobbyAction
|
||||
// ready is the value a lobbyReady is setting. Explicit rather than a
|
||||
// toggle: a toggle applied to a state the client is a frame behind on sets
|
||||
// the opposite of what the player clicked.
|
||||
ready bool
|
||||
}
|
||||
|
||||
type resignInput struct {
|
||||
@@ -110,9 +132,10 @@ 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
|
||||
// ready is this seat's declaration that it wants the next game to start.
|
||||
// Only ever set on the guest's seat: the owner's readiness is StartGame
|
||||
// itself. Cleared whenever a game begins, so every game is agreed again.
|
||||
ready bool
|
||||
}
|
||||
|
||||
// room owns one game.
|
||||
@@ -125,17 +148,23 @@ 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
|
||||
rematchAfter time.Duration
|
||||
hub *hub
|
||||
dict Dictionary
|
||||
engine *game.Engine
|
||||
opening string
|
||||
strategy bot.Strategy
|
||||
turnLimit time.Duration
|
||||
graceFor time.Duration
|
||||
idleFor time.Duration
|
||||
|
||||
seats [2]*seat
|
||||
|
||||
// owner is the seat that may start a game and free the other one. It is a
|
||||
// field rather than "seats[0]" because the role outlives the player who
|
||||
// held it: an owner who leaves hands it to whoever is still here, and the
|
||||
// seat they vacate is then filled by an ordinary guest.
|
||||
owner game.PlayerID
|
||||
|
||||
// turnSeq increments on every turn change. A client stamps its submission
|
||||
// with the sequence it was answering, so a move that crosses the deadline
|
||||
// is identifiable rather than silently applied to the next turn.
|
||||
@@ -146,10 +175,12 @@ type room struct {
|
||||
// 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
|
||||
// lobbyChanged marks that something a player can see about the room's
|
||||
// occupants has changed: a seat filled or freed, a readiness set, an owner
|
||||
// promoted, a game finished. The run loop turns it into exactly one
|
||||
// RoomState broadcast per input, which is why no handler has to remember
|
||||
// to send one.
|
||||
lobbyChanged bool
|
||||
}
|
||||
|
||||
// Dictionary is everything the transport layer needs from the wordlist: the
|
||||
@@ -164,21 +195,21 @@ type Dictionary interface {
|
||||
RandomOpeningWord(minOutDegree int) (string, error)
|
||||
}
|
||||
|
||||
func newRoom(h *hub, code string, turnLimit, graceFor, rematchAfter time.Duration) *room {
|
||||
if rematchAfter <= 0 {
|
||||
rematchAfter = defaultRematchWindow
|
||||
func newRoom(h *hub, code string, turnLimit, graceFor, idleFor time.Duration) *room {
|
||||
if idleFor <= 0 {
|
||||
idleFor = defaultIdleWindow
|
||||
}
|
||||
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,
|
||||
rematchAfter: rematchAfter,
|
||||
code: code,
|
||||
inputs: make(chan any, roomInputCap),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
hub: h,
|
||||
dict: h.dict,
|
||||
turnLimit: turnLimit,
|
||||
graceFor: graceFor,
|
||||
idleFor: idleFor,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +248,7 @@ func (r *room) run() {
|
||||
defer r.cancel()
|
||||
defer r.hub.evict(r.code)
|
||||
|
||||
var turnTimer, graceTimer, rematchTimer *time.Timer
|
||||
var turnTimer, graceTimer, idleTimer *time.Timer
|
||||
stop := func(t *time.Timer) {
|
||||
if t != nil {
|
||||
t.Stop()
|
||||
@@ -226,7 +257,7 @@ func (r *room) run() {
|
||||
defer func() {
|
||||
stop(turnTimer)
|
||||
stop(graceTimer)
|
||||
stop(rematchTimer)
|
||||
stop(idleTimer)
|
||||
}()
|
||||
|
||||
// resetTurnTimer rebuilds the deadline timer after anything that changes
|
||||
@@ -242,16 +273,28 @@ func (r *room) run() {
|
||||
turnTimer = time.NewTimer(time.Until(r.engine.Deadline()))
|
||||
}
|
||||
|
||||
// resetIdleTimer restarts the lobby's own deadline. It runs only while no
|
||||
// game does: a game is bounded by the turn clock, and a room that is being
|
||||
// played in is not idle.
|
||||
resetIdleTimer := func() {
|
||||
stop(idleTimer)
|
||||
idleTimer = nil
|
||||
if r.strategy != nil || !r.inLobby() {
|
||||
return
|
||||
}
|
||||
idleTimer = time.NewTimer(r.idleFor)
|
||||
}
|
||||
|
||||
for {
|
||||
var turnC, graceC, rematchC <-chan time.Time
|
||||
var turnC, graceC, idleC <-chan time.Time
|
||||
if turnTimer != nil {
|
||||
turnC = turnTimer.C
|
||||
}
|
||||
if graceTimer != nil {
|
||||
graceC = graceTimer.C
|
||||
}
|
||||
if rematchTimer != nil {
|
||||
rematchC = rematchTimer.C
|
||||
if idleTimer != nil {
|
||||
idleC = idleTimer.C
|
||||
}
|
||||
|
||||
select {
|
||||
@@ -274,6 +317,9 @@ func (r *room) run() {
|
||||
case botMoveInput:
|
||||
r.handleBotMove(m)
|
||||
resetTurnTimer()
|
||||
case lobbyInput:
|
||||
r.handleLobby(m)
|
||||
resetTurnTimer()
|
||||
case resignInput:
|
||||
if !r.occupies(m.sess, m.player) {
|
||||
m.sess.send(errorMsg("not_your_seat"))
|
||||
@@ -284,21 +330,15 @@ func (r *room) run() {
|
||||
}
|
||||
resetTurnTimer()
|
||||
case disconnectInput:
|
||||
// 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.
|
||||
// A dropped connection is not a player leaving: the seat is
|
||||
// held for the reconnect window whether a game is running or
|
||||
// the room is sitting in its lobby, so a refresh does not cost
|
||||
// somebody their room.
|
||||
//
|
||||
// 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 {
|
||||
// that would evict a seat its new socket is sitting in.
|
||||
if r.handleDisconnect(m) {
|
||||
stop(graceTimer)
|
||||
graceTimer = time.NewTimer(r.graceFor)
|
||||
}
|
||||
@@ -308,11 +348,6 @@ func (r *room) run() {
|
||||
stop(graceTimer)
|
||||
graceTimer = nil
|
||||
resetTurnTimer()
|
||||
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:
|
||||
@@ -325,38 +360,51 @@ func (r *room) run() {
|
||||
resetTurnTimer()
|
||||
|
||||
case <-graceC:
|
||||
r.endForAbandonment()
|
||||
return
|
||||
graceTimer = nil
|
||||
r.handleGraceExpiry()
|
||||
resetTurnTimer()
|
||||
|
||||
case <-rematchC:
|
||||
// Nobody, or only one of them, asked in time.
|
||||
r.abandonRematch()
|
||||
case <-idleC:
|
||||
// A lobby nobody started a game in. Whoever is still sitting in it
|
||||
// is told why it closed rather than watching their buttons stop
|
||||
// working.
|
||||
r.broadcastError("room_idle_closed")
|
||||
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)
|
||||
// One broadcast per input, from the one place that knows the input is
|
||||
// finished. A kick, a grace window running out and a game ending all
|
||||
// leave the room in the same state — a lobby — and this is where that
|
||||
// state goes out.
|
||||
if r.strategy == nil && r.lobbyChanged {
|
||||
r.lobbyChanged = false
|
||||
r.broadcastRoomState()
|
||||
}
|
||||
if !r.offeringRematch() {
|
||||
stop(rematchTimer)
|
||||
rematchTimer = nil
|
||||
|
||||
// A bot room is its game: there is no lobby to return to and nobody to
|
||||
// wait for, so it closes with the last move.
|
||||
if r.strategy != nil && r.engine != nil && r.engine.Over() {
|
||||
return
|
||||
}
|
||||
// Everyone has left, or the last reconnect window ran out. Nothing is
|
||||
// coming that could fill the room again — a joiner needs a code the
|
||||
// hub is about to forget.
|
||||
if !r.occupied() {
|
||||
return
|
||||
}
|
||||
resetIdleTimer()
|
||||
}
|
||||
}
|
||||
|
||||
// handleCreate seats the room's creator and waits for an opponent.
|
||||
// handleCreate seats the room's creator, who owns it, and opens the lobby.
|
||||
//
|
||||
// The code goes out in the RoomState the run loop broadcasts, so a client can
|
||||
// never be handed a code before the seat behind it exists.
|
||||
func (r *room) handleCreate(m createInput) {
|
||||
r.seats[0] = &seat{id: "p1", nickname: m.sess.nickname(), sess: m.sess}
|
||||
r.owner = "p1"
|
||||
m.sess.attach(r, "p1")
|
||||
m.sess.send(roomCreatedMsg(r.code))
|
||||
r.lobbyChanged = true
|
||||
}
|
||||
|
||||
// handleStartBot seats a bot opposite the player and begins immediately.
|
||||
@@ -371,6 +419,7 @@ func (r *room) handleStartBot(m startBotInput) {
|
||||
r.strategy = strategy
|
||||
r.seats[0] = &seat{id: "p1", nickname: m.sess.nickname(), sess: m.sess}
|
||||
r.seats[1] = &seat{id: botPlayerID, nickname: "Máy"}
|
||||
r.owner = "p1"
|
||||
m.sess.attach(r, "p1")
|
||||
|
||||
if err := r.beginGame(); err != nil {
|
||||
@@ -380,37 +429,130 @@ func (r *room) handleStartBot(m startBotInput) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleJoin seats the second human and starts the game.
|
||||
// handleJoin seats a second human in the lobby. It no longer starts anything:
|
||||
// the owner does that, once this player says they are ready.
|
||||
//
|
||||
// The seat is bound here, on the room goroutine, and only on success. Binding
|
||||
// it in the hub before this decision would leave a refused joiner still
|
||||
// holding seat "p2", and every later Submit or Resign it sent would be applied
|
||||
// to the real player sitting there.
|
||||
// holding a seat, and every later Submit or Resign it sent would be applied to
|
||||
// the real player sitting there.
|
||||
func (r *room) handleJoin(m joinInput) {
|
||||
if r.seats[0] == nil || r.seats[1] != nil {
|
||||
free := r.freeSeat()
|
||||
if free < 0 || !r.occupied() {
|
||||
m.sess.send(errorMsg("room_full"))
|
||||
return
|
||||
}
|
||||
if r.seats[0].sess == m.sess {
|
||||
m.sess.send(errorMsg("cannot_join_own_room"))
|
||||
// A game in progress fills both seats, so this only catches a room whose
|
||||
// seat was freed by the very disconnect that ended the game — for the
|
||||
// moment before the room notices.
|
||||
if !r.inLobby() {
|
||||
m.sess.send(errorMsg("room_full"))
|
||||
return
|
||||
}
|
||||
r.seats[1] = &seat{
|
||||
id: "p2",
|
||||
nickname: distinguish(m.sess.nickname(), r.seats[0].nickname),
|
||||
sess: m.sess,
|
||||
}
|
||||
m.sess.attach(r, "p2")
|
||||
|
||||
for i, s := range r.seats {
|
||||
if s.sess != nil {
|
||||
s.sess.send(roomJoinedMsg(r.code, r.seats[1-i].nickname))
|
||||
for _, s := range r.seats {
|
||||
if s != nil && s.sess == m.sess {
|
||||
m.sess.send(errorMsg("cannot_join_own_room"))
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := r.beginGame(); err != nil {
|
||||
slog.Error("could not start pvp game", "room", r.code, "err", err)
|
||||
r.broadcastError("game_start_failed")
|
||||
r.cancel()
|
||||
|
||||
id := seatIDs[free]
|
||||
r.seats[free] = &seat{
|
||||
id: id,
|
||||
nickname: distinguish(m.sess.nickname(), r.otherNickname(id)),
|
||||
sess: m.sess,
|
||||
}
|
||||
m.sess.attach(r, string(id))
|
||||
r.lobbyChanged = true
|
||||
}
|
||||
|
||||
// handleLobby applies one lobby action.
|
||||
//
|
||||
// Every refusal answers with a reason. A lobby button that silently does
|
||||
// nothing is indistinguishable from one that is broken, and the player cannot
|
||||
// see the state that refused them.
|
||||
func (r *room) handleLobby(m lobbyInput) {
|
||||
if !r.occupies(m.sess, m.player) {
|
||||
m.sess.send(errorMsg("not_your_seat"))
|
||||
return
|
||||
}
|
||||
if r.strategy != nil {
|
||||
// A bot room has no lobby: one player, no readiness, nobody to kick.
|
||||
m.sess.send(errorMsg("not_in_a_room"))
|
||||
return
|
||||
}
|
||||
if !r.inLobby() {
|
||||
m.sess.send(errorMsg("game_in_progress"))
|
||||
return
|
||||
}
|
||||
|
||||
mine := r.seatOf(m.player)
|
||||
isOwner := m.player == r.owner
|
||||
|
||||
switch m.action {
|
||||
case lobbyReady:
|
||||
if isOwner {
|
||||
// The owner's readiness is StartGame. A flag of their own would
|
||||
// only be something they had to set before every single start.
|
||||
m.sess.send(errorMsg("owner_needs_no_ready"))
|
||||
return
|
||||
}
|
||||
mine.ready = m.ready
|
||||
r.lobbyChanged = true
|
||||
|
||||
case lobbyStart:
|
||||
if !isOwner {
|
||||
m.sess.send(errorMsg("not_the_owner"))
|
||||
return
|
||||
}
|
||||
guest := r.guestSeat()
|
||||
switch {
|
||||
case guest == nil:
|
||||
m.sess.send(errorMsg("need_two_players"))
|
||||
return
|
||||
case guest.sess == nil:
|
||||
m.sess.send(errorMsg("opponent_offline"))
|
||||
return
|
||||
case !guest.ready:
|
||||
m.sess.send(errorMsg("not_everyone_ready"))
|
||||
return
|
||||
}
|
||||
if err := r.beginGame(); err != nil {
|
||||
slog.Error("could not start pvp game", "room", r.code, "err", err)
|
||||
r.broadcastError("game_start_failed")
|
||||
}
|
||||
|
||||
case lobbyKick:
|
||||
if !isOwner {
|
||||
m.sess.send(errorMsg("not_the_owner"))
|
||||
return
|
||||
}
|
||||
guest := r.guestSeat()
|
||||
if guest == nil {
|
||||
m.sess.send(errorMsg("no_one_to_kick"))
|
||||
return
|
||||
}
|
||||
// Readiness is a commitment, and the owner does not get to overrule
|
||||
// one: a guest who is ready is waiting on the owner, not in the way.
|
||||
if guest.ready {
|
||||
m.sess.send(errorMsg("player_is_ready"))
|
||||
return
|
||||
}
|
||||
if guest.sess != nil {
|
||||
guest.sess.send(errorMsg("kicked"))
|
||||
}
|
||||
r.vacate(guest)
|
||||
r.lobbyChanged = true
|
||||
|
||||
case lobbyLeave:
|
||||
// Unreadying first is deliberate friction: a player the other one is
|
||||
// waiting on should have to take that back before walking away.
|
||||
if mine.ready {
|
||||
m.sess.send(errorMsg("must_unready_first"))
|
||||
return
|
||||
}
|
||||
r.vacate(mine)
|
||||
r.lobbyChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,10 +582,11 @@ func (r *room) beginGame() error {
|
||||
// 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++
|
||||
// Every game is agreed on its own. The readiness that started this one is
|
||||
// spent, so the lobby they come back to asks again.
|
||||
for _, s := range r.seats {
|
||||
s.wantsRematch = false
|
||||
s.ready = false
|
||||
}
|
||||
r.rematchUntil = time.Time{}
|
||||
|
||||
for _, s := range r.seats {
|
||||
r.sendGameStarted(s)
|
||||
@@ -616,6 +759,9 @@ func (r *room) broadcastGameOver() {
|
||||
}
|
||||
s.sess.send(r.gameOverFor(state, s.id, EndReason(state.EndReason)))
|
||||
}
|
||||
// A finished game is a return to the lobby, and the run loop reports the
|
||||
// state they are returning to.
|
||||
r.lobbyChanged = true
|
||||
}
|
||||
|
||||
// gameOverFor renders a finished game for one seat.
|
||||
@@ -643,45 +789,50 @@ func (r *room) gameOverFor(state game.State, id game.PlayerID, reason noituv1.Ga
|
||||
}}
|
||||
}
|
||||
|
||||
// handleDisconnect holds the seat open, reporting whether a grace window
|
||||
// should now run.
|
||||
// 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) {
|
||||
// handleDisconnect holds the seat open and reports whether the notice applied.
|
||||
//
|
||||
// A dropped connection is not a player leaving. The seat is kept for the
|
||||
// reconnect window whether a game is running or the room is sitting in its
|
||||
// lobby, so refreshing the page does not cost somebody the room they are in.
|
||||
func (r *room) handleDisconnect(m disconnectInput) 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, false
|
||||
return false
|
||||
}
|
||||
s.sess = nil
|
||||
|
||||
// Both sides gone: nobody is left to win, so there is nothing to hold the
|
||||
// room open for.
|
||||
// A second seat dropping means nobody is here: during a game there is
|
||||
// nobody left to win, and in a lobby nobody left to play. The room ends
|
||||
// rather than waiting out a window with no winner to declare.
|
||||
if r.disconnected != nil && r.disconnected != s {
|
||||
r.cancel()
|
||||
return true, false
|
||||
return false
|
||||
}
|
||||
r.disconnected = s
|
||||
r.lobbyChanged = true
|
||||
|
||||
// Before the game starts there is no turn timer and no opponent, so no
|
||||
// clock can ever end this room. Without this it would sit in select
|
||||
// forever, holding a goroutine and a room code for a game nobody is in.
|
||||
if r.engine == nil {
|
||||
r.cancel()
|
||||
return true, false
|
||||
// A player mid-game is told their opponent may be coming back, with how
|
||||
// long they have. In a lobby the same fact is part of the room's state and
|
||||
// travels with the rest of it, so there is nothing extra to send.
|
||||
live := r.engine != nil && !r.engine.Over()
|
||||
if other := r.opponentSeat(s.id); live && other != nil && other.sess != nil {
|
||||
other.sess.send(opponentLeftMsg(true, uint32(r.graceFor.Milliseconds())))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
live = !r.engine.Over()
|
||||
if other := r.opponentSeat(s.id); other != nil && other.sess != nil {
|
||||
// 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())))
|
||||
// handleGraceExpiry decides what a reconnect window running out means.
|
||||
func (r *room) handleGraceExpiry() {
|
||||
if r.disconnected == nil {
|
||||
return
|
||||
}
|
||||
return true, live
|
||||
// A live game is awarded first: once the seat is gone there is no opponent
|
||||
// left to award it against.
|
||||
r.endForAbandonment()
|
||||
r.vacate(r.disconnected)
|
||||
r.lobbyChanged = true
|
||||
}
|
||||
|
||||
// handleResume rebinds a seat to a new connection and replays the position.
|
||||
@@ -695,12 +846,6 @@ 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
|
||||
@@ -718,17 +863,14 @@ 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))
|
||||
}
|
||||
// Both players need the room's state again: this one to render the lobby
|
||||
// it came back to, the other to stop watching a disconnect banner for
|
||||
// somebody who is already back. The run loop sends it to both.
|
||||
r.lobbyChanged = true
|
||||
|
||||
// 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 {
|
||||
s.sess.send(roomCreatedMsg(r.code))
|
||||
// Resumed between games, or before the first one. The lobby state above is
|
||||
// the whole answer; there is no position to replay.
|
||||
if r.inLobby() {
|
||||
return
|
||||
}
|
||||
r.sendGameStarted(s)
|
||||
@@ -750,7 +892,9 @@ func (r *room) handleResume(m resumeInput) {
|
||||
}
|
||||
}
|
||||
|
||||
// endForAbandonment awards the game to whoever stayed.
|
||||
// endForAbandonment awards a live game to whoever stayed. The room itself
|
||||
// survives: the winner is still sitting in it, and it is theirs to hand on or
|
||||
// leave.
|
||||
func (r *room) endForAbandonment() {
|
||||
if r.engine == nil || r.engine.Over() || r.disconnected == nil {
|
||||
return
|
||||
@@ -767,89 +911,142 @@ func (r *room) endForAbandonment() {
|
||||
}
|
||||
s.sess.send(r.gameOverFor(state, s.id, noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT))
|
||||
}
|
||||
r.lobbyChanged = true
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// inLobby reports whether the room is between games. Everything a lobby
|
||||
// allows is refused while a game is running, and the engine is the authority
|
||||
// on that.
|
||||
func (r *room) inLobby() bool { return r.engine == nil || r.engine.Over() }
|
||||
|
||||
// occupied reports whether anybody still holds a seat, including a player
|
||||
// inside their reconnect window. An empty room has nothing left to wait for.
|
||||
func (r *room) occupied() bool {
|
||||
for _, s := range r.seats {
|
||||
if s != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// freeSeat returns the index a joiner would take, or -1 when the room is full.
|
||||
func (r *room) freeSeat() int {
|
||||
for i, s := range r.seats {
|
||||
if s == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// seatIDs are the two engine seat names, indexed by position. An id says which
|
||||
// seat a player is in and nothing about their role: an owner who leaves hands
|
||||
// that on, and the seat they vacate is refilled by an ordinary guest.
|
||||
var seatIDs = [2]game.PlayerID{"p1", "p2"}
|
||||
|
||||
func (r *room) ownerSeat() *seat { return r.seatOf(r.owner) }
|
||||
|
||||
// guestSeat is the seat that is not the owner's, or nil when nobody else is
|
||||
// here.
|
||||
func (r *room) guestSeat() *seat {
|
||||
for _, s := range r.seats {
|
||||
if s != nil && s.id != r.owner {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// otherNickname is the name already taken in this room, so a joiner can be
|
||||
// distinguished from it.
|
||||
func (r *room) otherNickname(mine game.PlayerID) string {
|
||||
for _, s := range r.seats {
|
||||
if s != nil && s.id != mine {
|
||||
return s.nickname
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// canStart reports whether StartGame would be accepted. The server answers
|
||||
// this rather than the client because it owns every condition that feeds it.
|
||||
func (r *room) canStart() bool {
|
||||
if r.strategy != nil || !r.inLobby() {
|
||||
return false
|
||||
}
|
||||
for _, s := range r.seats {
|
||||
if s == nil || s.sess == nil {
|
||||
return false
|
||||
owner, guest := r.ownerSeat(), r.guestSeat()
|
||||
return owner != nil && owner.sess != nil &&
|
||||
guest != nil && guest.sess != nil && guest.ready
|
||||
}
|
||||
|
||||
// vacate frees a seat for good - the player left, was kicked, or never came
|
||||
// back - and hands the room on when the seat was the owner's.
|
||||
func (r *room) vacate(s *seat) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.sess != nil {
|
||||
// The connection stays open; it is simply no longer in this room, so
|
||||
// anything else it sends here is refused rather than applied to a seat
|
||||
// somebody else may now be sitting in.
|
||||
s.sess.release(r)
|
||||
s.sess = nil
|
||||
}
|
||||
for i, existing := range r.seats {
|
||||
if existing == s {
|
||||
r.seats[i] = nil
|
||||
}
|
||||
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.disconnected == s {
|
||||
r.disconnected = nil
|
||||
}
|
||||
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()
|
||||
if r.owner == s.id {
|
||||
r.promote()
|
||||
}
|
||||
}
|
||||
|
||||
// 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()))
|
||||
// promote hands the room to whoever is left.
|
||||
func (r *room) promote() {
|
||||
for _, s := range r.seats {
|
||||
if s != nil {
|
||||
r.owner = s.id
|
||||
// The new owner starts games, and starting is their readiness. A
|
||||
// flag they set as a guest would sit there meaning nothing.
|
||||
s.ready = false
|
||||
return
|
||||
}
|
||||
}
|
||||
r.owner = ""
|
||||
}
|
||||
|
||||
// broadcastRoomState sends the whole lobby to each occupant.
|
||||
//
|
||||
// Built per recipient because every field in it is relative to who is being
|
||||
// told: their role, their readiness, and the other player. One snapshot rather
|
||||
// than a stream of deltas is what lets a client that missed a frame - or has
|
||||
// just reconnected - be correct again from the next one.
|
||||
func (r *room) broadcastRoomState() {
|
||||
canStart := r.canStart()
|
||||
|
||||
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
|
||||
other := r.seats[1-i]
|
||||
state := &noituv1.RoomState{
|
||||
RoomCode: r.code,
|
||||
IAmOwner: s.id == r.owner,
|
||||
CanStart: canStart,
|
||||
IAmReady: s.ready,
|
||||
OpponentPresent: other != nil,
|
||||
}
|
||||
s.sess.send(opponentLeftMsg(false, 0))
|
||||
if other != nil {
|
||||
state.OpponentName = other.nickname
|
||||
state.OpponentReady = other.ready
|
||||
state.OpponentConnected = other.sess != nil
|
||||
}
|
||||
s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RoomState{RoomState: state}})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +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
|
||||
// IdleFor is how long a room sits in its lobby with no game started before
|
||||
// it closes. Zero falls back to a built-in default.
|
||||
IdleFor 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.
|
||||
@@ -45,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, cfg.RematchFor),
|
||||
hub: newHub(ctx, dict, cfg.TurnLimit, cfg.GraceFor, cfg.IdleFor),
|
||||
mux: http.NewServeMux(),
|
||||
cancel: cancel,
|
||||
cfg: cfg,
|
||||
|
||||
@@ -157,6 +157,20 @@ func (s *session) attach(r *room, seatName string) {
|
||||
}
|
||||
}
|
||||
|
||||
// release forgets a room this connection is no longer seated in, because it
|
||||
// left or was kicked. The connection itself stays open.
|
||||
//
|
||||
// Guarded by identity: a release from a room the connection has already moved
|
||||
// on from must not detach it from the one it is sitting in now.
|
||||
func (s *session) release(r *room) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.room == r {
|
||||
s.room = nil
|
||||
s.playerID = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) currentRoom() (*room, game.PlayerID) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -401,23 +415,17 @@ 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_SetReady:
|
||||
s.toRoom(lobbyInput{sess: s, action: lobbyReady, ready: p.SetReady.GetReady()})
|
||||
|
||||
case *noituv1.ClientMessage_StartGame:
|
||||
s.toRoom(lobbyInput{sess: s, action: lobbyStart})
|
||||
|
||||
case *noituv1.ClientMessage_KickPlayer:
|
||||
s.toRoom(lobbyInput{sess: s, action: lobbyKick})
|
||||
|
||||
case *noituv1.ClientMessage_LeaveRoom:
|
||||
s.toRoom(lobbyInput{sess: s, action: lobbyLeave})
|
||||
|
||||
case *noituv1.ClientMessage_Ping:
|
||||
s.send(pongMsg(p.Ping.GetClientTimeMs(), time.Now().UnixMilli()))
|
||||
@@ -425,6 +433,28 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// toRoom forwards one lobby action to the room this connection is seated in.
|
||||
//
|
||||
// Rate-limited like a submission: every accepted action is broadcast to both
|
||||
// seats, so an unbounded one lets a player flood the other's outbox until
|
||||
// their session is closed for falling behind. A dropped action would leave a
|
||||
// button that did nothing and no reason why, so every failure answers.
|
||||
func (s *session) toRoom(in lobbyInput) {
|
||||
if !s.submitLimiter.allow(time.Now()) {
|
||||
s.send(errorMsg("too_fast"))
|
||||
return
|
||||
}
|
||||
r, id := s.currentRoom()
|
||||
if r == nil {
|
||||
s.send(errorMsg("not_in_a_room"))
|
||||
return
|
||||
}
|
||||
in.player = id
|
||||
if !r.send(in) {
|
||||
s.send(errorMsg("not_in_a_room"))
|
||||
}
|
||||
}
|
||||
|
||||
// handleHello completes the handshake, resuming a prior game when the client
|
||||
// presents a token that is still live.
|
||||
func (s *session) handleHello(h *noituv1.Hello) error {
|
||||
|
||||
@@ -47,9 +47,12 @@ func clientVariants() map[string]*noituv1.ClientMessage {
|
||||
"client_ping": {Payload: &noituv1.ClientMessage_Ping{Ping: &noituv1.Ping{
|
||||
ClientTimeMs: 1756998000123,
|
||||
}}},
|
||||
"client_request_rematch": {Payload: &noituv1.ClientMessage_RequestRematch{
|
||||
RequestRematch: &noituv1.RequestRematch{},
|
||||
}},
|
||||
"client_set_ready": {Payload: &noituv1.ClientMessage_SetReady{SetReady: &noituv1.SetReady{
|
||||
Ready: true,
|
||||
}}},
|
||||
"client_start_game": {Payload: &noituv1.ClientMessage_StartGame{StartGame: &noituv1.StartGame{}}},
|
||||
"client_kick_player": {Payload: &noituv1.ClientMessage_KickPlayer{KickPlayer: &noituv1.KickPlayer{}}},
|
||||
"client_leave_room": {Payload: &noituv1.ClientMessage_LeaveRoom{LeaveRoom: &noituv1.LeaveRoom{}}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,13 +65,6 @@ func serverVariants() map[string]*noituv1.ServerMessage {
|
||||
ProtocolVersion: 1,
|
||||
AcceptedNickname: "Người chơi ẩn danh",
|
||||
}}},
|
||||
"server_room_created": {Payload: &noituv1.ServerMessage_RoomCreated{RoomCreated: &noituv1.RoomCreated{
|
||||
RoomCode: "K7QX",
|
||||
}}},
|
||||
"server_room_joined": {Payload: &noituv1.ServerMessage_RoomJoined{RoomJoined: &noituv1.RoomJoined{
|
||||
RoomCode: "K7QX",
|
||||
OpponentName: "Thuý",
|
||||
}}},
|
||||
"server_game_started": {Payload: &noituv1.ServerMessage_GameStarted{GameStarted: &noituv1.GameStarted{
|
||||
OpeningWord: "hòa bình",
|
||||
CurrentSyllable: "bình",
|
||||
@@ -122,13 +118,18 @@ func serverVariants() map[string]*noituv1.ServerMessage {
|
||||
// Asymmetric on purpose: equal booleans would not catch the two fields
|
||||
// being swapped, which is exactly the mistake that shows one player
|
||||
// their opponent's answer as their own.
|
||||
"server_rematch_state": {Payload: &noituv1.ServerMessage_RematchState{
|
||||
RematchState: &noituv1.RematchState{
|
||||
IAccepted: true,
|
||||
OpponentAccepted: false,
|
||||
ExpiresInMs: 27500,
|
||||
},
|
||||
}},
|
||||
"server_room_state": {Payload: &noituv1.ServerMessage_RoomState{RoomState: &noituv1.RoomState{
|
||||
RoomCode: "K7QX",
|
||||
// An owner looking at a guest who is here, ready, and connected:
|
||||
// the one combination in which every boolean is load-bearing.
|
||||
IAmOwner: true,
|
||||
CanStart: true,
|
||||
IAmReady: false,
|
||||
OpponentPresent: true,
|
||||
OpponentName: "Khách mời",
|
||||
OpponentReady: true,
|
||||
OpponentConnected: true,
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+430
-170
@@ -112,8 +112,10 @@ 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
|
||||
// Long enough that no test loses its lobby to the idle clock. The one
|
||||
// test that exercises that clock sets its own.
|
||||
if cfg.IdleFor == 0 {
|
||||
cfg.IdleFor = 30 * time.Second
|
||||
}
|
||||
|
||||
api := NewServer(ctx, dict, cfg)
|
||||
@@ -187,10 +189,6 @@ func payloadCase(m *noituv1.ServerMessage) string {
|
||||
switch m.GetPayload().(type) {
|
||||
case *noituv1.ServerMessage_Welcome:
|
||||
return "welcome"
|
||||
case *noituv1.ServerMessage_RoomCreated:
|
||||
return "room_created"
|
||||
case *noituv1.ServerMessage_RoomJoined:
|
||||
return "room_joined"
|
||||
case *noituv1.ServerMessage_GameStarted:
|
||||
return "game_started"
|
||||
case *noituv1.ServerMessage_TurnUpdate:
|
||||
@@ -205,8 +203,8 @@ func payloadCase(m *noituv1.ServerMessage) string {
|
||||
return "error"
|
||||
case *noituv1.ServerMessage_Pong:
|
||||
return "pong"
|
||||
case *noituv1.ServerMessage_RematchState:
|
||||
return "rematch_state"
|
||||
case *noituv1.ServerMessage_RoomState:
|
||||
return "room_state"
|
||||
}
|
||||
// Named rather than empty: a missing arm here makes every await for that
|
||||
// message time out with nothing to say about why.
|
||||
@@ -286,13 +284,14 @@ func TestPvPGameAlternatesTurns(t *testing.T) {
|
||||
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()
|
||||
code := host.await("room_state").GetRoomState().GetRoomCode()
|
||||
|
||||
guest := dial(t, url)
|
||||
guest.hello("Khách")
|
||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
readyAndStart(t, host, guest)
|
||||
|
||||
hostStart := host.await("game_started").GetGameStarted()
|
||||
guestStart := guest.await("game_started").GetGameStarted()
|
||||
@@ -336,13 +335,14 @@ func TestTurnTimeoutEndsGameServerSide(t *testing.T) {
|
||||
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()
|
||||
code := host.await("room_state").GetRoomState().GetRoomCode()
|
||||
|
||||
guest := dial(t, url)
|
||||
guest.hello("Khách")
|
||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
readyAndStart(t, host, guest)
|
||||
|
||||
host.await("game_started")
|
||||
guest.await("game_started")
|
||||
@@ -405,13 +405,14 @@ func TestReplayingAWordIsRejected(t *testing.T) {
|
||||
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()
|
||||
code := host.await("room_state").GetRoomState().GetRoomCode()
|
||||
|
||||
guest := dial(t, url)
|
||||
guest.hello("Khách")
|
||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
readyAndStart(t, host, guest)
|
||||
hostStart := host.await("game_started").GetGameStarted()
|
||||
guest.await("game_started")
|
||||
|
||||
@@ -455,13 +456,14 @@ func TestResumeWithinGraceRestoresGame(t *testing.T) {
|
||||
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()
|
||||
code := host.await("room_state").GetRoomState().GetRoomCode()
|
||||
|
||||
guest := dial(t, url)
|
||||
guest.hello("Khách")
|
||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
readyAndStart(t, host, guest)
|
||||
host.await("game_started")
|
||||
guest.await("game_started")
|
||||
|
||||
@@ -496,13 +498,14 @@ func TestGraceExpiryAwardsTheGame(t *testing.T) {
|
||||
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()
|
||||
code := host.await("room_state").GetRoomState().GetRoomCode()
|
||||
|
||||
guest := dial(t, url)
|
||||
guest.hello("Khách")
|
||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
readyAndStart(t, host, guest)
|
||||
host.await("game_started")
|
||||
guest.await("game_started")
|
||||
|
||||
@@ -920,143 +923,422 @@ func settle() {
|
||||
runtime.GC()
|
||||
}
|
||||
|
||||
// --- rematch ---------------------------------------------------------------
|
||||
// --- the lobby -------------------------------------------------------------
|
||||
|
||||
// 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.
|
||||
// pvpRoom seats two players and plays them into a game, so a test about what
|
||||
// happens next does not restate the whole handshake.
|
||||
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},
|
||||
}})
|
||||
host, guest, _ = pvpLobby(t, url)
|
||||
guest.setReady(true)
|
||||
host.await("room_state")
|
||||
host.startGame()
|
||||
|
||||
start = host.await("game_started").GetGameStarted()
|
||||
guest.await("game_started")
|
||||
return host, guest, start
|
||||
}
|
||||
|
||||
func (c *testClient) requestRematch() {
|
||||
// pvpLobby seats two players and stops there: the room exists, nobody is ready
|
||||
// and no game has been started.
|
||||
func pvpLobby(t *testing.T, url string) (host, guest *testClient, code string) {
|
||||
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_state").GetRoomState().GetRoomCode()
|
||||
|
||||
guest = dial(t, url)
|
||||
guest.hello("Khách")
|
||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
host.await("room_state")
|
||||
guest.await("room_state")
|
||||
return host, guest, code
|
||||
}
|
||||
|
||||
// readyAndStart takes a seated pair from their lobby into a game, which is
|
||||
// what every test that is about the game itself needs to get past.
|
||||
func readyAndStart(t *testing.T, host, guest *testClient) {
|
||||
t.Helper()
|
||||
host.await("room_state")
|
||||
guest.await("room_state")
|
||||
guest.setReady(true)
|
||||
host.await("room_state")
|
||||
host.startGame()
|
||||
}
|
||||
|
||||
func (c *testClient) setReady(ready bool) {
|
||||
c.t.Helper()
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_RequestRematch{
|
||||
RequestRematch: &noituv1.RequestRematch{},
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_SetReady{
|
||||
SetReady: &noituv1.SetReady{Ready: ready},
|
||||
}})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func (c *testClient) startGame() {
|
||||
c.t.Helper()
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartGame{StartGame: &noituv1.StartGame{}}})
|
||||
}
|
||||
|
||||
func (c *testClient) kickPlayer() {
|
||||
c.t.Helper()
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_KickPlayer{KickPlayer: &noituv1.KickPlayer{}}})
|
||||
}
|
||||
|
||||
func (c *testClient) leaveRoom() {
|
||||
c.t.Helper()
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_LeaveRoom{LeaveRoom: &noituv1.LeaveRoom{}}})
|
||||
}
|
||||
|
||||
// resignAndSettle ends the game and returns the lobby each player lands back
|
||||
// in.
|
||||
func resignAndSettle(t *testing.T, host, guest *testClient) (hostState, guestState *noituv1.RoomState) {
|
||||
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()
|
||||
return host.await("room_state").GetRoomState(),
|
||||
guest.await("room_state").GetRoomState()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// TestLobbyOpensWithNobodyReady is the room a joiner lands in: it exists, the
|
||||
// owner is known, and nothing has started.
|
||||
func TestLobbyOpensWithNobodyReady(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, _ := pvpRoom(t, url)
|
||||
|
||||
hostState, guestState := resignAndSettle(t, host, guest)
|
||||
// Set up by hand rather than through pvpLobby: this test is about the
|
||||
// frames the handshake produces, and the helper consumes them.
|
||||
host := dial(t, url)
|
||||
host.hello("Chủ phòng")
|
||||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||||
code := host.await("room_state").GetRoomState().GetRoomCode()
|
||||
|
||||
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)
|
||||
}
|
||||
guest := dial(t, url)
|
||||
guest.hello("Khách")
|
||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
|
||||
hostState := host.await("room_state").GetRoomState()
|
||||
guestState := guest.await("room_state").GetRoomState()
|
||||
|
||||
if !hostState.GetIAmOwner() {
|
||||
t.Error("the player who created the room does not own it")
|
||||
}
|
||||
if guestState.GetIAmOwner() {
|
||||
t.Error("the player who joined was made owner")
|
||||
}
|
||||
if hostState.GetCanStart() || guestState.GetCanStart() {
|
||||
t.Error("a game can start with nobody ready")
|
||||
}
|
||||
if !hostState.GetOpponentPresent() || hostState.GetOpponentName() == "" {
|
||||
t.Errorf("the owner cannot see who joined: %+v", hostState)
|
||||
}
|
||||
// Nothing starts on its own. Joining used to be the start signal, and a
|
||||
// player who joined to look at the room found themselves on the clock.
|
||||
silentFor(t, guest, "game_started", 250*time.Millisecond)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// TestReadyIsRenderedPerRecipient is the guard against the two flags being
|
||||
// swapped, which would show a player their opponent's readiness as their own.
|
||||
func TestReadyIsRenderedPerRecipient(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, _ := pvpRoom(t, url)
|
||||
resignAndSettle(t, host, guest)
|
||||
host, guest, _ := pvpLobby(t, url)
|
||||
|
||||
host.requestRematch()
|
||||
guest.setReady(true)
|
||||
|
||||
hostState := host.await("rematch_state").GetRematchState()
|
||||
guestState := guest.await("rematch_state").GetRematchState()
|
||||
guestState := guest.await("room_state").GetRoomState()
|
||||
hostState := host.await("room_state").GetRoomState()
|
||||
|
||||
if !hostState.GetIAccepted() || hostState.GetOpponentAccepted() {
|
||||
t.Errorf("the asker should see only their own acceptance, got %+v", hostState)
|
||||
if !guestState.GetIAmReady() || guestState.GetOpponentReady() {
|
||||
t.Errorf("the guest should see only their own readiness, got %+v", guestState)
|
||||
}
|
||||
if guestState.GetIAccepted() || !guestState.GetOpponentAccepted() {
|
||||
t.Errorf("the other player should see only the opponent's acceptance, got %+v", guestState)
|
||||
if hostState.GetIAmReady() || !hostState.GetOpponentReady() {
|
||||
t.Errorf("the owner should see only the guest's readiness, got %+v", hostState)
|
||||
}
|
||||
if !hostState.GetCanStart() {
|
||||
t.Error("the owner cannot start a game their guest is ready for")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRematchStartsANewGameWhenBothAccept covers the whole point of the
|
||||
// feature, and the two properties a fresh game has to have.
|
||||
func TestRematchStartsANewGameWhenBothAccept(t *testing.T) {
|
||||
// TestOwnerHasNoReadinessOfTheirOwn: Start is the owner's readiness, and a
|
||||
// second flag they would have to set first buys nothing.
|
||||
func TestOwnerHasNoReadinessOfTheirOwn(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, _, _ := pvpLobby(t, url)
|
||||
|
||||
host.setReady(true)
|
||||
|
||||
if got := host.await("error").GetError().GetCode(); got != "owner_needs_no_ready" {
|
||||
t.Errorf("the owner readying returned %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartIsRefusedUntilTheGuestIsReady covers each way a start is not yet a
|
||||
// game.
|
||||
func TestStartIsRefusedUntilTheGuestIsReady(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
|
||||
host := dial(t, url)
|
||||
host.hello("Chủ phòng")
|
||||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||||
code := host.await("room_state").GetRoomState().GetRoomCode()
|
||||
|
||||
// Alone in the room.
|
||||
host.startGame()
|
||||
if got := host.await("error").GetError().GetCode(); got != "need_two_players" {
|
||||
t.Errorf("starting alone returned %q, want need_two_players", got)
|
||||
}
|
||||
|
||||
guest := dial(t, url)
|
||||
guest.hello("Khách")
|
||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
host.await("room_state")
|
||||
guest.await("room_state")
|
||||
|
||||
// Seated, but not ready.
|
||||
host.startGame()
|
||||
if got := host.await("error").GetError().GetCode(); got != "not_everyone_ready" {
|
||||
t.Errorf("starting with an unready guest returned %q, want not_everyone_ready", got)
|
||||
}
|
||||
|
||||
// A guest who readies and takes it back is not ready either.
|
||||
guest.setReady(true)
|
||||
host.await("room_state")
|
||||
guest.setReady(false)
|
||||
host.await("room_state")
|
||||
host.startGame()
|
||||
if got := host.await("error").GetError().GetCode(); got != "not_everyone_ready" {
|
||||
t.Errorf("starting after the guest unreadied returned %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnlyTheOwnerStartsAndKicks: the guest holds a room code, and a code is
|
||||
// pasted into group chats by design.
|
||||
func TestOnlyTheOwnerStartsAndKicks(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, _ := pvpLobby(t, url)
|
||||
_ = host
|
||||
|
||||
guest.startGame()
|
||||
if got := guest.await("error").GetError().GetCode(); got != "not_the_owner" {
|
||||
t.Errorf("a guest starting the game returned %q, want not_the_owner", got)
|
||||
}
|
||||
guest.kickPlayer()
|
||||
if got := guest.await("error").GetError().GetCode(); got != "not_the_owner" {
|
||||
t.Errorf("a guest kicking returned %q, want not_the_owner", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextGameNeedsAFreshReady is the whole replay flow: a finished game
|
||||
// returns both players to the lobby, and the readiness that started the last
|
||||
// one is spent.
|
||||
func TestNextGameNeedsAFreshReady(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()
|
||||
hostState, guestState := resignAndSettle(t, host, guest)
|
||||
if hostState.GetOpponentReady() || guestState.GetIAmReady() {
|
||||
t.Error("the readiness that started the last game survived it")
|
||||
}
|
||||
if hostState.GetCanStart() {
|
||||
t.Error("the owner can start a game nobody has readied for")
|
||||
}
|
||||
|
||||
host.startGame()
|
||||
if got := host.await("error").GetError().GetCode(); got != "not_everyone_ready" {
|
||||
t.Errorf("starting a second game without a fresh ready returned %q", got)
|
||||
}
|
||||
|
||||
guest.setReady(true)
|
||||
host.await("room_state")
|
||||
host.startGame()
|
||||
|
||||
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",
|
||||
t.Errorf("turn_seq must keep rising across games: %d then %d, so a submission "+
|
||||
"still in flight from the first could be applied to the second",
|
||||
first.GetTurnSeq(), second.GetTurnSeq())
|
||||
}
|
||||
if second.GetOpeningWord() == "" {
|
||||
t.Error("a rematch needs its own opening word")
|
||||
t.Error("the second game 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) {
|
||||
// TestLobbyActionsAreRefusedDuringAGame keeps the lobby from being a way out
|
||||
// of a game in progress.
|
||||
func TestLobbyActionsAreRefusedDuringAGame(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")
|
||||
guest.setReady(false)
|
||||
if got := guest.await("error").GetError().GetCode(); got != "game_in_progress" {
|
||||
t.Errorf("unreadying mid-game returned %q, want game_in_progress", got)
|
||||
}
|
||||
host.kickPlayer()
|
||||
if got := host.await("error").GetError().GetCode(); got != "game_in_progress" {
|
||||
t.Errorf("kicking mid-game returned %q, want game_in_progress", got)
|
||||
}
|
||||
guest.leaveRoom()
|
||||
if got := guest.await("error").GetError().GetCode(); got != "game_in_progress" {
|
||||
t.Errorf("leaving mid-game returned %q, want game_in_progress", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// TestLeavingNeedsAnUnreadyFirst is the friction the lobby is meant to have: a
|
||||
// player the owner is waiting on has to take that back before walking away.
|
||||
func TestLeavingNeedsAnUnreadyFirst(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, _ := pvpLobby(t, url)
|
||||
|
||||
guest.setReady(true)
|
||||
guest.await("room_state")
|
||||
host.await("room_state")
|
||||
|
||||
guest.leaveRoom()
|
||||
if got := guest.await("error").GetError().GetCode(); got != "must_unready_first" {
|
||||
t.Errorf("leaving while ready returned %q, want must_unready_first", got)
|
||||
}
|
||||
|
||||
guest.setReady(false)
|
||||
guest.await("room_state")
|
||||
host.await("room_state")
|
||||
guest.leaveRoom()
|
||||
|
||||
// The room survives: the owner is still in it, now on their own.
|
||||
alone := host.await("room_state").GetRoomState()
|
||||
if alone.GetOpponentPresent() {
|
||||
t.Errorf("the owner still sees a guest who left: %+v", alone)
|
||||
}
|
||||
if !alone.GetIAmOwner() || alone.GetCanStart() {
|
||||
t.Errorf("the room the owner is left with is wrong: %+v", alone)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKickFreesAnUnreadySeatOnly: readiness is a commitment, and the owner
|
||||
// does not get to overrule one.
|
||||
func TestKickFreesAnUnreadySeatOnly(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, code := pvpLobby(t, url)
|
||||
|
||||
guest.setReady(true)
|
||||
host.await("room_state")
|
||||
host.kickPlayer()
|
||||
if got := host.await("error").GetError().GetCode(); got != "player_is_ready" {
|
||||
t.Errorf("kicking a ready guest returned %q, want player_is_ready", got)
|
||||
}
|
||||
|
||||
guest.setReady(false)
|
||||
host.await("room_state")
|
||||
host.kickPlayer()
|
||||
|
||||
if got := guest.await("error").GetError().GetCode(); got != "kicked" {
|
||||
t.Errorf("the kicked player was told %q", got)
|
||||
}
|
||||
if got := host.await("room_state").GetRoomState(); got.GetOpponentPresent() {
|
||||
t.Errorf("the kicked seat is still occupied: %+v", got)
|
||||
}
|
||||
|
||||
// The seat is free, and a kick is not a ban.
|
||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
if got := guest.await("room_state").GetRoomState(); !got.GetOpponentPresent() {
|
||||
t.Errorf("a kicked player could not come back: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLeavingPromotesTheOtherPlayer: the role outlives the player who
|
||||
// held it, or the room would be one nobody can start.
|
||||
func TestOwnerLeavingPromotesTheOtherPlayer(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, code := pvpLobby(t, url)
|
||||
|
||||
host.leaveRoom()
|
||||
|
||||
promoted := guest.await("room_state").GetRoomState()
|
||||
if !promoted.GetIAmOwner() {
|
||||
t.Errorf("the player left behind was not promoted: %+v", promoted)
|
||||
}
|
||||
if promoted.GetOpponentPresent() {
|
||||
t.Errorf("the owner who left is still shown as present: %+v", promoted)
|
||||
}
|
||||
|
||||
// And the promotion is real: the new owner can start a game with the next
|
||||
// person to walk in.
|
||||
third := dial(t, url)
|
||||
third.hello("Người mới")
|
||||
third.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
guest.await("room_state")
|
||||
third.await("room_state")
|
||||
third.setReady(true)
|
||||
guest.await("room_state")
|
||||
guest.startGame()
|
||||
|
||||
guest.await("game_started")
|
||||
third.await("game_started")
|
||||
}
|
||||
|
||||
// TestPromotedOwnerLosesTheirReadiness: their readiness is Start now, and a
|
||||
// flag left set from being a guest would mean nothing.
|
||||
func TestPromotedOwnerLosesTheirReadiness(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, _ := pvpLobby(t, url)
|
||||
|
||||
guest.setReady(true)
|
||||
host.await("room_state")
|
||||
// Drained on both sides, so the state read below is the promotion and not
|
||||
// the readiness that preceded it.
|
||||
guest.await("room_state")
|
||||
host.leaveRoom()
|
||||
|
||||
promoted := guest.await("room_state").GetRoomState()
|
||||
if promoted.GetIAmReady() {
|
||||
t.Errorf("the promoted owner is still carrying a guest's readiness: %+v", promoted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLastPlayerOutClosesTheRoom bounds the code and the goroutine: nothing is
|
||||
// coming that could fill a room whose code the hub is about to forget.
|
||||
func TestLastPlayerOutClosesTheRoom(t *testing.T) {
|
||||
api, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, _ := pvpLobby(t, url)
|
||||
|
||||
guest.leaveRoom()
|
||||
host.await("room_state")
|
||||
host.leaveRoom()
|
||||
|
||||
awaitNoRooms(t, api, "a room nobody is in")
|
||||
}
|
||||
|
||||
// TestIdleLobbyCloses bounds a room nobody starts a game in. One open tab
|
||||
// would otherwise hold a code and a goroutine for the life of the process.
|
||||
func TestIdleLobbyCloses(t *testing.T) {
|
||||
api, url := newTestServer(t, chainDict(), Config{IdleFor: 150 * time.Millisecond})
|
||||
|
||||
host := dial(t, url)
|
||||
host.hello("Chủ phòng")
|
||||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||||
host.await("room_state")
|
||||
|
||||
if got := host.await("error").GetError().GetCode(); got != "room_idle_closed" {
|
||||
t.Errorf("an idle room closed with %q", got)
|
||||
}
|
||||
awaitNoRooms(t, api, "an idle room")
|
||||
}
|
||||
|
||||
// TestBotRoomHasNoLobby: a bot room is its game. Keeping it open would leave
|
||||
// one goroutine and one engine per finished bot game.
|
||||
func TestBotRoomHasNoLobby(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
|
||||
c := dial(t, url)
|
||||
@@ -1069,80 +1351,57 @@ func TestBotRoomDoesNotOfferARematch(t *testing.T) {
|
||||
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" {
|
||||
// not_in_a_room is the session reporting that the room has gone: the
|
||||
// goroutine and engine are released rather than parked in a lobby no bot
|
||||
// can ready for.
|
||||
c.setReady(true)
|
||||
if got := c.await("error").GetError().GetCode(); got != "not_in_a_room" {
|
||||
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)
|
||||
// awaitNoRooms waits for the hub to forget every room it holds.
|
||||
func awaitNoRooms(t *testing.T, api *Server, what string) {
|
||||
t.Helper()
|
||||
|
||||
// Only one side asks, so the offer can only end by running out.
|
||||
host.requestRematch()
|
||||
host.await("rematch_state")
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
api.hub.mu.Lock()
|
||||
left := len(api.hub.rooms)
|
||||
api.hub.mu.Unlock()
|
||||
|
||||
if got := host.await("opponent_left").GetOpponentLeft(); got.GetCanReconnect() {
|
||||
t.Error("an expired offer is final, not a reconnect window")
|
||||
if left == 0 {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%s was never evicted from the hub", what)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// TestResumeReclaimsALobbySeat: a room outlives its games now, so a player who
|
||||
// refreshes between them has a lobby to come back to rather than a refusal.
|
||||
func TestResumeReclaimsALobbySeat(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()
|
||||
code := host.await("room_state").GetRoomState().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")
|
||||
host.await("room_state")
|
||||
guest.await("room_state")
|
||||
|
||||
resignAndSettle(t, host, guest)
|
||||
// The owner's tab reloads: same token, new socket.
|
||||
_ = host.conn.Close(websocket.StatusAbnormalClosure, "")
|
||||
guest.await("room_state")
|
||||
|
||||
// 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,
|
||||
@@ -1151,18 +1410,20 @@ func TestRefusedResumeLeavesTheLiveGameAlone(t *testing.T) {
|
||||
}}})
|
||||
second.await("welcome")
|
||||
|
||||
if got := second.await("error").GetError().GetCode(); got != "game_already_over" {
|
||||
t.Errorf("resume into a finished game returned %q", got)
|
||||
back := second.await("room_state").GetRoomState()
|
||||
if !back.GetIAmOwner() {
|
||||
t.Errorf("the owner came back as a guest: %+v", back)
|
||||
}
|
||||
if !back.GetOpponentPresent() || back.GetRoomCode() != code {
|
||||
t.Errorf("the resumed lobby is not the one they left: %+v", back)
|
||||
}
|
||||
|
||||
// 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")
|
||||
// And the room still works from both sides.
|
||||
guest.setReady(true)
|
||||
second.await("room_state")
|
||||
second.startGame()
|
||||
second.await("game_started")
|
||||
guest.await("game_started")
|
||||
}
|
||||
|
||||
// TestOneConnectionCannotStrandRooms is the regression for rooms that outlived
|
||||
@@ -1178,7 +1439,7 @@ func TestOneConnectionCannotStrandRooms(t *testing.T) {
|
||||
const rooms = 4
|
||||
for range rooms {
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||||
c.await("room_created")
|
||||
c.await("room_state")
|
||||
}
|
||||
|
||||
_ = c.conn.Close(websocket.StatusNormalClosure, "")
|
||||
@@ -1199,24 +1460,23 @@ func TestOneConnectionCannotStrandRooms(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// TestLobbyActionsAreRateLimited: every accepted action 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 TestLobbyActionsAreRateLimited(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, _ := pvpRoom(t, url)
|
||||
resignAndSettle(t, host, guest)
|
||||
_, guest, _ := pvpLobby(t, url)
|
||||
|
||||
for range submitBurst + 5 {
|
||||
host.requestRematch()
|
||||
for i := range submitBurst + 5 {
|
||||
guest.setReady(i%2 == 0)
|
||||
}
|
||||
|
||||
// The limiter answers before the room does, so a refusal has to appear in
|
||||
// the stream rather than an unbroken run of rematch states.
|
||||
// the stream rather than an unbroken run of room states.
|
||||
for range 30 {
|
||||
if payloadCase(host.recv()) == "error" {
|
||||
if payloadCase(guest.recv()) == "error" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("a burst of rematch requests was never refused")
|
||||
t.Error("a burst of lobby actions was never refused")
|
||||
}
|
||||
Reference in new issue
Block a user