Files
noitu/server/internal/wsapi/room_lobby.go
T
tiennm99 55abdb36c1 refactor(wsapi): dedupe routing and seating, fix ordering races
Route room inputs through one toRoom helper, iterate live seats with
connected(), and share seating and reconnect-window code. Declare every
error code once in errcodes.go, which the web vocabulary test now reads.

Fixes:
- a kicked player is released before being told, so their next action
  is answered not_in_a_room
- a rejected word is counted before move_rejected is sent
- room code draw and registration share one lock hold
- two concurrent resumes with one token can no longer both take a seat
- multiplayer tests ready guests one at a time instead of racing

Behaviour changes:
- opening or joining another room while a game is running is refused
  with already_in_a_game
- resigning in the lobby answers game_not_started
2026-09-28 15:19:59 +07:00

355 lines
11 KiB
Go

package wsapi
import (
"log/slog"
"time"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
"github.com/tiennm99dev/noitu/server/internal/game"
)
// Seating and the lobby between games: who is in the room, whether they may
// start, and what happens when one of them leaves or is kicked.
// 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) {
s := r.takeSeat(0, m.sess.nickname(), m.sess)
r.owner = s.id
r.autoStart = m.autoStart
r.lobbyChanged = true
if s.sess.ctx.Err() != nil {
r.holdSeat(s)
return
}
// Deliberately sent to a brand-new room's creator, where it is always
// empty: it is what replaces the conversation a client may still be
// holding from a room it was in before this one.
r.sendChatHistory(s)
}
// handleJoin seats another human in the lobby. It does not start anything: the
// owner does that, once everybody has said 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 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) {
free := r.freeSeat()
if free < 0 || !r.occupied() {
metrics.joinsRefused.Add("room_full", 1)
m.sess.send(errorMsg(codeRoomFull))
return
}
// A room can have a free seat and still be mid-game — four people can
// start a game three of them are in. Arriving in the middle of one is not
// something to seat somebody for: they would have no words, no score, and
// no way to be told what they had missed.
if !r.inLobby() {
m.sess.send(errorMsg(codeGameInProgress))
return
}
for s := range r.connected() {
if s.sess == m.sess {
m.sess.send(errorMsg(codeCannotJoinOwnRoom))
return
}
}
s := r.takeSeat(free, distinguish(m.sess.nickname(), r.takenNicknames()), m.sess)
r.lobbyChanged = true
if s.sess.ctx.Err() != nil {
r.holdSeat(s)
return
}
r.sendChatHistory(s)
// A quick match seats both players itself rather than waiting on
// readiness and StartGame — there is no owner here to press it, only two
// strangers who both already asked to be matched. The lobby is shown
// first, with both seats filled, so the wait ends on an ordinary room a
// beat before GameStarted rather than jumping straight into one with no
// seating frame behind it.
if r.autoStart && r.seatedCount() >= minPlayers && r.allConnected() {
if r.hub.isDraining() {
// The second seat filled after the drain decision. There is no
// owner here to answer with server_restarting the way lobbyStart
// does, so both seats are told directly; the lobby view they are
// left in still shows each other, via lobbyChanged below.
r.broadcastError(codeServerRestarting)
return
}
r.autoStart = false
r.lobbyChanged = false
r.broadcastRoomState()
if err := r.beginGame(); err != nil {
slog.Error("could not start quick-matched game", "room", r.code, "err", err)
r.broadcastError(codeGameStartFailed)
}
}
}
// 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(codeNotYourSeat))
return
}
if r.strategy != nil {
// A bot room has no lobby: one player, no readiness, nobody to kick.
m.sess.send(errorMsg(codeNotInARoom))
return
}
// Leaving is the exception: a player may want out of a game it is not
// their turn in, and resigning is not open to them then. Readying,
// starting and kicking all belong to a room between games.
if !r.inLobby() && m.action != lobbyLeave {
m.sess.send(errorMsg(codeGameInProgress))
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(codeOwnerNeedsNoReady))
return
}
mine.ready = m.ready
r.lobbyChanged = true
case lobbyStart:
if !isOwner {
m.sess.send(errorMsg(codeNotTheOwner))
return
}
switch {
case r.hub.isDraining():
// newRegisteredRoom already refuses a brand-new room once draining
// starts; this lobby existed before that point, and starting its
// game now would raise hub.liveGames after the drain decided how
// long to wait for exactly that number to reach zero.
m.sess.send(errorMsg(codeServerRestarting))
return
case r.seatedCount() < minPlayers:
m.sess.send(errorMsg(codeNeedMorePlayers))
return
case !r.allConnected():
m.sess.send(errorMsg(codePlayerOffline))
return
case !r.guestsReady():
m.sess.send(errorMsg(codeNotEveryoneReady))
return
}
if err := r.beginGame(); err != nil {
slog.Error("could not start pvp game", "room", r.code, "err", err)
r.broadcastError(codeGameStartFailed)
}
case lobbyKick:
if !isOwner {
m.sess.send(errorMsg(codeNotTheOwner))
return
}
target := r.seatOf(m.target)
switch {
case target == nil:
m.sess.send(errorMsg(codeNoOneToKick))
return
case target == mine:
// Leaving is what an owner who wants out does, and it hands the
// room on. Kicking yourself would drop the seat and the role
// together while the others were still sitting here.
m.sess.send(errorMsg(codeCannotKickSelf))
return
case target.ready:
// Readiness is a commitment, and the owner does not get to
// overrule one: a player who is ready is waiting on the owner,
// not in the way.
m.sess.send(errorMsg(codePlayerIsReady))
return
}
// Released before being told, not after. The notice is what prompts
// the client to act on being out, and an action that overtook the
// release would still find the connection bound here and be refused
// as somebody else's seat rather than as no room at all.
kicked := target.sess
r.vacate(target)
if kicked != nil {
kicked.send(errorMsg(codeKicked))
}
r.lobbyChanged = true
case lobbyLeave:
if r.inLobby() {
// 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(codeMustUnreadyFirst))
return
}
} else {
// Out of a running game, which is the same thing to everybody else
// as a reconnect window running out: somebody left. The engine
// goes first, while the seat is still here to be named in what is
// broadcast about it.
before := r.mark()
r.eliminateAbsent(mine, time.Now())
r.applyEliminations(before)
}
r.vacate(mine)
r.lobbyChanged = true
}
}
// guestsReady reports whether every seat but the owner's has said yes. The
// owner's readiness is StartGame itself, which is why they are not counted.
func (r *room) guestsReady() bool {
for _, s := range r.seats {
if s != nil && s.id != r.owner && !s.ready {
return false
}
}
return true
}
// takenNicknames is every name already in this room, so a joiner can be told
// apart from all of them.
func (r *room) takenNicknames() []string {
names := make([]string, 0, maxPlayers)
for _, s := range r.seats {
if s != nil {
names = append(names, s.nickname)
}
}
return names
}
// 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
}
return r.seatedCount() >= minPlayers && r.allConnected() && r.guestsReady()
}
// 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
}
}
// The words stay; the attribution goes. Both fields, not just the id: a
// retained name lets the next person to ask for that nickname inherit
// these messages, because distinguish only compares against the seat that
// is occupied.
scrubbed := false
for i := range r.chat {
if r.chat[i].author == s.id {
r.chat[i].author = ""
r.chat[i].name = ""
scrubbed = true
}
}
// Clearing the store is only half of it: the player who stayed is holding
// frames that still carry the departed name, and RoomState carries no
// chat. Without this re-sync they keep that attribution until they happen
// to reload — long enough for somebody to join under the same nickname and
// inherit a stranger's words.
if scrubbed {
// The loop above has already emptied this seat out of r.seats, so what
// is left is exactly the players who need correcting.
for _, other := range r.seats {
r.sendChatHistory(other)
}
}
if r.owner == s.id {
r.promote()
}
}
// 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 room to each occupant.
//
// Built per recipient because the field that matters most in it — which of
// these players is you — is relative to who is being told. 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 s := range r.connected() {
s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_RoomState{
RoomState: &noituv1.RoomState{
RoomCode: r.code,
CanStart: canStart,
Players: r.playerSlots(s.id),
MaxPlayers: maxPlayers,
MinPlayers: minPlayers,
GraceMs: uint32(r.graceFor.Milliseconds()),
},
}})
}
}
// playerSlots renders the seating for one recipient, in seat order. That is
// the order they will play in, but not who plays first: the lead is drawn when
// the game starts, and the table sent with it is the one in turn order.
func (r *room) playerSlots(me game.PlayerID) []*noituv1.PlayerSlot {
slots := make([]*noituv1.PlayerSlot, 0, maxPlayers)
for _, s := range r.seats {
if s == nil {
continue
}
slots = append(slots, &noituv1.PlayerSlot{
PlayerId: string(s.id),
Name: s.nickname,
IsMe: s.id == me,
IsOwner: s.id == r.owner,
Ready: s.ready,
Connected: s.sess != nil,
Wins: s.wins,
})
}
return slots
}