mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
The hub keeps a FIFO of waiting sessions; a second QuickMatch pops the first, opens a room via the existing createInput/joinInput path, and marks it to begin its own first game once both seats are connected in the lobby. CancelQuickMatch, a dropped connection, and entering a room by code all remove a session from the queue. Counts queued, cancelled and matched pairings in metrics.
1755 lines
58 KiB
Go
1755 lines
58 KiB
Go
package wsapi
|
|
|
|
import (
|
|
"context"
|
|
"iter"
|
|
"log/slog"
|
|
"math/rand/v2"
|
|
"slices"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
|
"github.com/tiennm99dev/noitu/server/internal/bot"
|
|
"github.com/tiennm99dev/noitu/server/internal/dictionary"
|
|
"github.com/tiennm99dev/noitu/server/internal/game"
|
|
"github.com/tiennm99dev/noitu/server/internal/vietnamese"
|
|
)
|
|
|
|
// roomModeBot and roomModePvP are the two values a room's mode ever takes.
|
|
// They double as the label under which every mode-keyed metric and the
|
|
// word_rejected log line group their counts, so a reader checking one against
|
|
// the other is checking against the same string everywhere.
|
|
const (
|
|
roomModeBot = "bot"
|
|
roomModePvP = "pvp"
|
|
)
|
|
|
|
// botPlayerID is the seat the bot occupies. It is a normal player to the
|
|
// engine, which is the whole point: the bot's moves go through the same
|
|
// validation as a human's, so there is one rule implementation rather than two.
|
|
const botPlayerID game.PlayerID = "bot"
|
|
|
|
// maxPlayers is how many seats a room has, and minPlayers how many it takes
|
|
// to start one. Both are sent to the client in RoomState rather than compiled
|
|
// into it, so the lobby draws whatever the server allows and widening a room
|
|
// is a server change alone.
|
|
const (
|
|
maxPlayers = 4
|
|
minPlayers = 2
|
|
)
|
|
|
|
// minOpeningOutDegree keeps the first word from being a dead end. Opening on a
|
|
// syllable with two continuations makes for a game that ends before it starts.
|
|
const minOpeningOutDegree = 20
|
|
|
|
// maxSuggestions is how many of the words still playable a losing player is
|
|
// shown. Enough to see what the position wanted, few enough that it reads as
|
|
// a hint rather than a dump of the dictionary.
|
|
const maxSuggestions = 3
|
|
|
|
// chatHistoryLimit is how many messages a room keeps, and the same window the
|
|
// client holds. Enough to catch up on after a reload, few enough that a room
|
|
// that lives all day cannot grow.
|
|
const chatHistoryLimit = 20
|
|
|
|
// maxChatRunes caps one message, counted in runes for the reason
|
|
// maxNicknameRunes is.
|
|
const maxChatRunes = 200
|
|
|
|
// maxWordRunes caps a submitted word before the engine sees it. The longest
|
|
// dictionary entries are well under this, so it bounds abuse without ever
|
|
// deciding a real move.
|
|
const maxWordRunes = 64
|
|
|
|
// maxChatMarks caps mark stacking in a message, as maxNicknameMarks does for a
|
|
// name. A message is ten times longer, so the same stack does ten times more
|
|
// damage.
|
|
const maxChatMarks = 2
|
|
|
|
// roomInputCap buffers the room's inbox. A sender that finds it full is either
|
|
// flooding past the rate limiter or racing a room that is shutting down;
|
|
// neither is worth blocking a session goroutine for.
|
|
const roomInputCap = 32
|
|
|
|
// 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
|
|
// lock: the room goroutine is its only reader.
|
|
|
|
// createInput and startBotInput seat the first player. Seating is a message
|
|
// rather than a direct write so that every touch of room state — seats and
|
|
// engine alike — happens on the room goroutine, which makes the ownership
|
|
// invariant provable by reading run() rather than by reasoning about which
|
|
// writes happened before `go r.run()`.
|
|
type createInput struct {
|
|
sess *session
|
|
// autoStart marks a room a quick match opened rather than a player asking
|
|
// for a code: once both seats are filled and connected, the room begins
|
|
// its own first game instead of waiting on readiness and StartGame.
|
|
autoStart bool
|
|
}
|
|
|
|
type startBotInput struct {
|
|
sess *session
|
|
difficulty bot.Difficulty
|
|
}
|
|
|
|
type joinInput struct {
|
|
sess *session
|
|
}
|
|
|
|
// submitInput and resignInput carry the connection that sent them, not just
|
|
// the seat it claims. A room code is a shared secret — it is pasted into group
|
|
// chats by design — so holding one must not be enough to act as a player who
|
|
// is already seated.
|
|
type submitInput struct {
|
|
sess *session
|
|
player game.PlayerID
|
|
word string
|
|
turnSeq uint32
|
|
}
|
|
|
|
// 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
|
|
// target is the seat a lobbyKick names. A room holds up to four people, so
|
|
// "the other one" stopped being an answer.
|
|
target game.PlayerID
|
|
}
|
|
|
|
// chatInput is one line of text from a seated player. It carries the
|
|
// connection, not just the seat it claims, for the same reason submitInput
|
|
// does: a room code is a shared secret, and a connection the room has retired
|
|
// must not be able to speak as the seat it used to hold.
|
|
type chatInput struct {
|
|
sess *session
|
|
player game.PlayerID
|
|
text string
|
|
}
|
|
|
|
type resignInput struct {
|
|
sess *session
|
|
player game.PlayerID
|
|
}
|
|
|
|
type disconnectInput struct {
|
|
player game.PlayerID
|
|
// sess identifies which connection dropped. A player who already
|
|
// reconnected has a different session, and that stale notice must not
|
|
// evict the seat the new connection just took.
|
|
sess *session
|
|
}
|
|
|
|
type resumeInput struct {
|
|
player game.PlayerID
|
|
sess *session
|
|
// prior is the connection being replaced. The room retires it only once it
|
|
// has decided the resume is allowed, because closing it on a refusal would
|
|
// end the very game the client was trying to rejoin.
|
|
prior *session
|
|
}
|
|
|
|
type botMoveInput struct {
|
|
word string
|
|
err error
|
|
// turnSeq the bot was thinking about. If the game moved on — a resign
|
|
// landed while it thought — the move is stale and dropped.
|
|
turnSeq uint32
|
|
}
|
|
|
|
// seat is one side of a game.
|
|
type seat struct {
|
|
id game.PlayerID
|
|
nickname string
|
|
sess *session // nil for the bot, or while a human is disconnected
|
|
// 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
|
|
// chatFrom is where the room's conversation stood when this seat was
|
|
// filled. A replay starts there, which is what keeps a stranger who walks
|
|
// in with the code from being handed what the last two people said.
|
|
chatFrom uint64
|
|
// wins counts the games this seat has taken since it was filled. A room
|
|
// outlives its games, so a running tally has to live on something that
|
|
// does too; the seat is the shortest-lived thing that still spans them,
|
|
// and vacating it is exactly when the tally stops meaning one player.
|
|
wins uint32
|
|
// graceUntil is when this seat stops being held for the player who dropped
|
|
// out of it, and zero while they are connected. Per seat rather than per
|
|
// room because any number of them can be waiting at once.
|
|
graceUntil time.Time
|
|
}
|
|
|
|
// chatEntry is one line of the room's conversation.
|
|
type chatEntry struct {
|
|
// seq is this message's place in the room's whole conversation, compared
|
|
// against a seat's chatFrom to decide what that player may be replayed.
|
|
seq uint64
|
|
// author and name are cleared together when the seat is vacated: the words
|
|
// stay, the attribution does not. Keeping the name would let the next
|
|
// person to request that nickname inherit a stranger's messages, since
|
|
// distinguish only compares against the seat that is currently occupied.
|
|
author game.PlayerID
|
|
name string
|
|
text string
|
|
at time.Time
|
|
}
|
|
|
|
// room owns one game.
|
|
//
|
|
// Every field below is touched only by the room goroutine after start. The
|
|
// exceptions are inputs and ctx, which exist precisely to be used from outside.
|
|
type room struct {
|
|
code string
|
|
inputs chan any
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
|
|
hub *hub
|
|
dict Dictionary
|
|
engine *game.Engine
|
|
opening string
|
|
strategy bot.Strategy
|
|
turnLimit time.Duration
|
|
graceFor time.Duration
|
|
idleFor time.Duration
|
|
|
|
// mode is roomModeBot or roomModePvP, fixed at creation. It is the label
|
|
// every mode-keyed metric and the word_rejected log line use, kept as its
|
|
// own field rather than re-derived from strategy == nil so the hub can set
|
|
// it before the room goroutine has seated anyone or built an engine.
|
|
mode string
|
|
|
|
// liveCounted mirrors whether this room's game is the one hub.liveGames is
|
|
// currently counting. Atomic rather than plain, because drain reads
|
|
// hub.liveGameCount() from outside the room goroutine while this flips on
|
|
// the goroutine itself; the CompareAndSwap in run's teardown is what
|
|
// guarantees exactly one hub.gameFinished() per hub.gameStarted() even when
|
|
// the room is cancelled mid-game instead of finishing normally.
|
|
liveCounted atomic.Bool
|
|
|
|
// autoStart marks a room opened by a quick match. Once both seats are
|
|
// filled and connected it begins its own first game — see handleJoin —
|
|
// and is cleared right there, so every later game in the room is agreed
|
|
// with readiness and StartGame like any other.
|
|
autoStart bool
|
|
|
|
seats [maxPlayers]*seat
|
|
|
|
// 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.
|
|
turnSeq uint32
|
|
|
|
// outWire overrides how one player's elimination is reported, for the
|
|
// cases the engine cannot know about. A reconnect window running out is
|
|
// the only one: to the engine that is a resignation, and to the other
|
|
// players it is somebody who left.
|
|
outWire map[game.PlayerID]noituv1.GameEndReason
|
|
|
|
// chat is the room's recent conversation, oldest first, capped at
|
|
// chatHistoryLimit. It belongs to the room, so it outlives each game and
|
|
// dies only with the room itself.
|
|
chat []chatEntry
|
|
// chatSeq counts every message the room has accepted, ever. It keeps
|
|
// rising as the history is trimmed, which is what makes a seat's chatFrom
|
|
// meaningful after the entry it pointed at has been dropped.
|
|
chatSeq uint64
|
|
|
|
// 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
|
|
// engine's own contract, a way to pick an opening, and the meanings a word
|
|
// travels to the client with. The engine never sees a meaning; only the room
|
|
// attaches them, where it renders a word for a recipient.
|
|
//
|
|
// An interface rather than *dictionary.Store so a test can play a whole game
|
|
// against a hand-built graph of a dozen words, where the expected outcome is
|
|
// something a reader can verify by eye. *dictionary.Store satisfies it as
|
|
// written.
|
|
type Dictionary interface {
|
|
game.Dictionary
|
|
RandomOpeningWord(minOutDegree int) (string, error)
|
|
// Meanings returns a canonical word's senses in order, nil for none.
|
|
Meanings(word string) []dictionary.Sense
|
|
}
|
|
|
|
func newRoom(h *hub, code string, turnLimit, graceFor, idleFor time.Duration, mode string) *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,
|
|
idleFor: idleFor,
|
|
mode: mode,
|
|
}
|
|
}
|
|
|
|
// send hands a message to the room without ever blocking the caller.
|
|
//
|
|
// A session goroutine must not be able to stall on a room: that would let one
|
|
// wedged game hold a connection open with no way out. A dropped message is
|
|
// recoverable — the client retries or the game times out — while a deadlock is
|
|
// not.
|
|
func (r *room) send(msg any) bool {
|
|
// Check for a finished room first, on its own. Folding this into the
|
|
// select below would make it a coin flip: the buffered channel and the
|
|
// done channel are both ready, so select picks at random and half the
|
|
// sends into a dead room report success. The caller then believes the
|
|
// message is on its way to a goroutine that stopped reading, and whoever
|
|
// was waiting for the reply waits forever.
|
|
select {
|
|
case <-r.ctx.Done():
|
|
return false
|
|
default:
|
|
}
|
|
|
|
select {
|
|
case r.inputs <- msg:
|
|
return true
|
|
case <-r.ctx.Done():
|
|
return false
|
|
default:
|
|
slog.Warn("room inbox full, dropping message", "room", r.code)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// run is the room goroutine. It is the only place the engine is touched.
|
|
func (r *room) run() {
|
|
defer r.cancel()
|
|
defer r.hub.evict(r.code)
|
|
defer metrics.roomsLive.Add(r.mode, -1)
|
|
// Catches a room cancelled with a game still running — drain forcing the
|
|
// last stragglers closed, or a shutdown mid-game — which never reaches
|
|
// broadcastGameOver's own decrement.
|
|
defer func() {
|
|
if r.liveCounted.CompareAndSwap(true, false) {
|
|
r.hub.gameFinished()
|
|
}
|
|
}()
|
|
// Whatever ended the room — everybody leaving, the idle window, a server
|
|
// shutdown — the connections still seated in it must stop pointing here.
|
|
// A session that keeps a dead room would answer every later action with
|
|
// "not in a room" and could never be seated anywhere else cleanly.
|
|
defer r.detachAll()
|
|
|
|
var turnTimer, graceTimer, idleTimer *time.Timer
|
|
stop := func(t *time.Timer) {
|
|
if t != nil {
|
|
t.Stop()
|
|
}
|
|
}
|
|
defer func() {
|
|
stop(turnTimer)
|
|
stop(graceTimer)
|
|
stop(idleTimer)
|
|
}()
|
|
|
|
// resetTurnTimer rebuilds the deadline timer after anything that changes
|
|
// whose turn it is. Recreating rather than resetting sidesteps the drain
|
|
// problem entirely: a stopped timer's stale fire can never reach the
|
|
// select because that channel is no longer the one being read.
|
|
resetTurnTimer := func() {
|
|
stop(turnTimer)
|
|
turnTimer = nil
|
|
if r.engine == nil || r.engine.Over() {
|
|
return
|
|
}
|
|
turnTimer = time.NewTimer(time.Until(r.engine.Deadline()))
|
|
}
|
|
|
|
// resetGraceTimer arms one timer for the earliest reconnect window still
|
|
// open. Several seats can be waiting at once, and a timer each would be a
|
|
// timer per player to stop, drain and reason about; one wakeup at the
|
|
// nearest deadline settles every window that has passed by the time it
|
|
// fires.
|
|
resetGraceTimer := func() {
|
|
stop(graceTimer)
|
|
graceTimer = nil
|
|
next, waiting := r.nextGraceExpiry()
|
|
if !waiting {
|
|
return
|
|
}
|
|
graceTimer = time.NewTimer(time.Until(next))
|
|
}
|
|
|
|
// 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 {
|
|
// Reset by every input except chat: talking is not playing, and a room
|
|
// must not be holdable open forever by typing into it once a minute.
|
|
idleActivity := true
|
|
|
|
var turnC, graceC, idleC <-chan time.Time
|
|
if turnTimer != nil {
|
|
turnC = turnTimer.C
|
|
}
|
|
if graceTimer != nil {
|
|
graceC = graceTimer.C
|
|
}
|
|
if idleTimer != nil {
|
|
idleC = idleTimer.C
|
|
}
|
|
|
|
select {
|
|
case <-r.ctx.Done():
|
|
return
|
|
|
|
case msg := <-r.inputs:
|
|
switch m := msg.(type) {
|
|
case createInput:
|
|
r.handleCreate(m)
|
|
case startBotInput:
|
|
r.handleStartBot(m)
|
|
case joinInput:
|
|
r.handleJoin(m)
|
|
case submitInput:
|
|
r.handleSubmit(m)
|
|
case botMoveInput:
|
|
r.handleBotMove(m)
|
|
case lobbyInput:
|
|
r.handleLobby(m)
|
|
case chatInput:
|
|
r.handleChat(m)
|
|
idleActivity = false
|
|
case resignInput:
|
|
r.handleResign(m)
|
|
case disconnectInput:
|
|
// 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.
|
|
r.handleDisconnect(m)
|
|
case resumeInput:
|
|
r.handleResume(m)
|
|
}
|
|
// Every input can move the turn, open or close a reconnect window,
|
|
// or both — an elimination does all of it at once. Recomputing both
|
|
// timers here rather than in each arm is what keeps a new input
|
|
// type from silently forgetting one.
|
|
resetTurnTimer()
|
|
resetGraceTimer()
|
|
|
|
case <-turnC:
|
|
// The timer and every message land on the same select, so a move
|
|
// that arrives at the deadline is either strictly before or
|
|
// strictly after it. There is no window where both apply.
|
|
if r.engine != nil {
|
|
before := r.mark()
|
|
if r.engine.Timeout(time.Now()) {
|
|
r.applyEliminations(before)
|
|
}
|
|
}
|
|
resetTurnTimer()
|
|
|
|
case <-graceC:
|
|
graceTimer = nil
|
|
r.handleGraceExpiry()
|
|
resetTurnTimer()
|
|
resetGraceTimer()
|
|
|
|
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
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if idleActivity {
|
|
resetIdleTimer()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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, chatFrom: r.chatSeq}
|
|
r.owner = "p1"
|
|
r.autoStart = m.autoStart
|
|
m.sess.attach(r, "p1")
|
|
r.lobbyChanged = true
|
|
// A quick match already popped this session off the pairing queue before
|
|
// sending it here, but a plain CreateRoom might still be seating somebody
|
|
// who was also waiting in it from another attempt — one dequeue serves
|
|
// both room-entry paths.
|
|
r.hub.cancelQuickMatch(m.sess)
|
|
// Deliberately sent to a brand-new room's creator, where it is always
|
|
// empty: it is what replaces the conversation a client may still be
|
|
// holding from a room it was in before this one.
|
|
r.sendChatHistory(r.seats[0])
|
|
}
|
|
|
|
// handleResign is one player giving up on their own turn. The seat, not the
|
|
// claimed id, is the authority, as everywhere a connection acts on a room.
|
|
//
|
|
// Only the player to act may give up. Giving up is a move — it is what is
|
|
// played instead of a word — and a seat that could spend it while somebody
|
|
// else was thinking would be deciding the turn of a player who had not
|
|
// finished theirs. Somebody who wants out of a game they are not on turn in
|
|
// leaves the room instead, which handleLobby answers.
|
|
func (r *room) handleResign(m resignInput) {
|
|
if !r.occupies(m.sess, m.player) {
|
|
m.sess.send(errorMsg("not_your_seat"))
|
|
return
|
|
}
|
|
if r.engine == nil || r.engine.Over() {
|
|
return
|
|
}
|
|
if r.engine.Turn() != m.player {
|
|
m.sess.send(errorMsg("not_your_turn"))
|
|
return
|
|
}
|
|
before := r.mark()
|
|
if r.engine.Resign(m.player, time.Now()) {
|
|
r.applyEliminations(before)
|
|
}
|
|
}
|
|
|
|
// handleStartBot seats a bot opposite the player and begins immediately.
|
|
func (r *room) handleStartBot(m startBotInput) {
|
|
strategy, err := bot.New(m.difficulty, rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())))
|
|
if err != nil {
|
|
m.sess.send(errorMsg("room_start_failed"))
|
|
r.cancel()
|
|
return
|
|
}
|
|
|
|
r.strategy = strategy
|
|
r.seats[0] = &seat{id: "p1", nickname: m.sess.nickname(), sess: m.sess, chatFrom: r.chatSeq}
|
|
r.seats[1] = &seat{id: botPlayerID, nickname: "Máy"}
|
|
r.owner = "p1"
|
|
m.sess.attach(r, "p1")
|
|
r.hub.cancelQuickMatch(m.sess)
|
|
|
|
if err := r.beginGame(); err != nil {
|
|
slog.Error("could not start bot game", "room", r.code, "err", err)
|
|
m.sess.send(errorMsg("game_start_failed"))
|
|
r.cancel()
|
|
}
|
|
}
|
|
|
|
// 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("room_full"))
|
|
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("game_in_progress"))
|
|
return
|
|
}
|
|
for _, s := range r.seats {
|
|
if s != nil && s.sess == m.sess {
|
|
m.sess.send(errorMsg("cannot_join_own_room"))
|
|
return
|
|
}
|
|
}
|
|
|
|
id := seatIDs[free]
|
|
r.seats[free] = &seat{
|
|
id: id,
|
|
nickname: distinguish(m.sess.nickname(), r.takenNicknames(id)),
|
|
sess: m.sess,
|
|
// Seated now, so the conversation up to this point is not theirs to
|
|
// read. A room code is pasted into group chats by design.
|
|
chatFrom: r.chatSeq,
|
|
}
|
|
m.sess.attach(r, string(id))
|
|
r.lobbyChanged = true
|
|
r.hub.cancelQuickMatch(m.sess)
|
|
r.sendChatHistory(r.seats[free])
|
|
|
|
// A quick match seats both players itself rather than waiting on
|
|
// readiness and StartGame — there is no owner here to press it, only two
|
|
// strangers who both already asked to be matched. The lobby is shown
|
|
// first, with both seats filled, so the wait ends on an ordinary room a
|
|
// beat before GameStarted rather than jumping straight into one with no
|
|
// seating frame behind it.
|
|
if r.autoStart && r.seatedCount() >= minPlayers && r.allConnected() {
|
|
r.autoStart = false
|
|
r.lobbyChanged = false
|
|
r.broadcastRoomState()
|
|
if err := r.beginGame(); err != nil {
|
|
slog.Error("could not start quick-matched game", "room", r.code, "err", err)
|
|
r.broadcastError("game_start_failed")
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleLobby applies one lobby action.
|
|
//
|
|
// 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
|
|
}
|
|
// 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("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
|
|
}
|
|
switch {
|
|
case r.seatedCount() < minPlayers:
|
|
m.sess.send(errorMsg("need_more_players"))
|
|
return
|
|
case !r.allConnected():
|
|
m.sess.send(errorMsg("player_offline"))
|
|
return
|
|
case !r.guestsReady():
|
|
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
|
|
}
|
|
target := r.seatOf(m.target)
|
|
switch {
|
|
case target == nil:
|
|
m.sess.send(errorMsg("no_one_to_kick"))
|
|
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("cannot_kick_self"))
|
|
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("player_is_ready"))
|
|
return
|
|
}
|
|
if target.sess != nil {
|
|
target.sess.send(errorMsg("kicked"))
|
|
}
|
|
r.vacate(target)
|
|
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("must_unready_first"))
|
|
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
|
|
}
|
|
}
|
|
|
|
// occupies reports whether this connection is the one seated at p.
|
|
//
|
|
// The seat, not the claimed id, is the authority: a session that was never
|
|
// seated here — or was replaced by a reconnect — must not be able to act.
|
|
func (r *room) occupies(sess *session, p game.PlayerID) bool {
|
|
s := r.seatOf(p)
|
|
return s != nil && s.sess != nil && s.sess == sess
|
|
}
|
|
|
|
// beginGame builds the engine and tells both seats the game is on.
|
|
func (r *room) beginGame() error {
|
|
opening, err := r.dict.RandomOpeningWord(minOpeningOutDegree)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Seat order is turn order, so a player's place at the table is the place
|
|
// they took in the lobby and nothing has to be shuffled or announced.
|
|
ids := make([]game.PlayerID, 0, maxPlayers)
|
|
for _, s := range r.seats {
|
|
if s != nil {
|
|
ids = append(ids, s.id)
|
|
}
|
|
}
|
|
// Who leads is drawn rather than owned. Opening the game is an advantage —
|
|
// the first player picks from a whole syllable, everyone after them plays
|
|
// what is left of it — and giving it to whoever happened to create the
|
|
// room would make the same person favourite in every game of a series.
|
|
//
|
|
// Rotating rather than shuffling keeps the table intact: everybody still
|
|
// plays in the order they sat down, the cycle just starts somewhere else.
|
|
// A bot room is left alone; it has no table to be fair about, and the
|
|
// human opens.
|
|
if r.strategy == nil {
|
|
lead := rand.IntN(len(ids))
|
|
ids = slices.Concat(ids[lead:], ids[:lead])
|
|
}
|
|
|
|
engine, err := game.New(r.dict, ids, opening, r.turnLimit, time.Now())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.engine = engine
|
|
r.opening = opening
|
|
// Fresh per game: an override from the last one would describe a player
|
|
// who has since come back and is playing this one.
|
|
r.outWire = make(map[game.PlayerID]noituv1.GameEndReason, len(ids))
|
|
// Never restarts at 1. A rematch reuses the same connections, so a
|
|
// submission still in flight from the previous game would otherwise be
|
|
// able to match a turn in this one and be applied to it.
|
|
r.turnSeq++
|
|
// 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 {
|
|
if s != nil {
|
|
s.ready = false
|
|
}
|
|
}
|
|
|
|
metrics.gamesStarted.Add(r.mode, 1)
|
|
r.hub.gameStarted()
|
|
r.liveCounted.Store(true)
|
|
|
|
state := r.engine.Snapshot()
|
|
for _, s := range r.seats {
|
|
r.sendGameStarted(s, state)
|
|
}
|
|
r.maybeScheduleBot()
|
|
return nil
|
|
}
|
|
|
|
// sendGameStarted renders the opening position for one seat. my_turn and is_me
|
|
// are per-recipient, which is why this is built per seat rather than broadcast.
|
|
func (r *room) sendGameStarted(s *seat, state game.State) {
|
|
if s == nil || s.sess == nil {
|
|
return
|
|
}
|
|
s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameStarted{
|
|
GameStarted: &noituv1.GameStarted{
|
|
OpeningWord: r.opening,
|
|
OpeningMeanings: Senses(r.dict.Meanings(r.opening)),
|
|
CurrentSyllable: state.Current,
|
|
MyTurn: state.Turn == s.id,
|
|
DeadlineUnixMs: state.Deadline.UnixMilli(),
|
|
TurnSeq: r.turnSeq,
|
|
TurnLimitMs: uint32(r.turnLimit.Milliseconds()),
|
|
Players: r.scoreRows(r.engine.Players(), state, s.id, nil),
|
|
TurnPlayerId: string(state.Turn),
|
|
},
|
|
}})
|
|
}
|
|
|
|
// handleSubmit runs one human move through the engine.
|
|
func (r *room) handleSubmit(m submitInput) {
|
|
if !r.occupies(m.sess, m.player) {
|
|
m.sess.send(errorMsg("not_your_seat"))
|
|
return
|
|
}
|
|
if r.engine == nil {
|
|
r.sendTo(m.player, errorMsg("game_not_started"))
|
|
return
|
|
}
|
|
|
|
// A submission stamped with an old turn is answering a position that no
|
|
// longer exists — a double-submit, or a word typed as the clock ran out.
|
|
// Applying it to the current turn would play a word the player never
|
|
// chose for this position.
|
|
// The rejection carries the server's sequence, not the client's stale one,
|
|
// so the client can resynchronise from the refusal instead of having to
|
|
// wait for the next turn update to discover where the game actually is.
|
|
metrics.wordsSubmitted.Add(1)
|
|
|
|
if m.turnSeq != r.turnSeq {
|
|
r.sendTo(m.player, moveRejectedMsg(noituv1.RejectReason_REJECT_REASON_NOT_YOUR_TURN, m.word, r.turnSeq))
|
|
r.recordRejection(game.ReasonNotYourTurn, m.word)
|
|
return
|
|
}
|
|
|
|
// The typed text is echoed back to every seat as PlayedWord.typed, so it
|
|
// crosses the same trust boundary a chat line does and gets the same
|
|
// filter. The engine's own normalization only lowercases and collapses
|
|
// whitespace; it does not drop format characters.
|
|
word := sanitizeText(m.word, maxWordRunes, maxNicknameMarks)
|
|
|
|
before := r.mark()
|
|
move, reason := r.engine.Submit(m.player, word, time.Now())
|
|
if reason != game.ReasonNone {
|
|
r.sendTo(m.player, moveRejectedMsg(RejectReason(reason), word, m.turnSeq))
|
|
r.recordRejection(reason, word)
|
|
// A rejection for an expired turn also took this player out of the
|
|
// game, and everybody has to be told which.
|
|
r.applyEliminations(before)
|
|
return
|
|
}
|
|
metrics.wordsAccepted.Add(1)
|
|
|
|
// An accepted move never ends a game: a dead end is left for whoever
|
|
// inherits it, which is what Submit's own comment explains.
|
|
r.turnSeq++
|
|
r.broadcastTurn(&move)
|
|
r.maybeScheduleBot()
|
|
}
|
|
|
|
// recordRejection counts one rejected submission and logs it at Info.
|
|
//
|
|
// This is the corpus feedback loop the improvement report calls the input to
|
|
// every decision about the dictionary: which words players actually type that
|
|
// the game does not accept, and why. The word logged is never the raw typed
|
|
// text — it is normalized the same way the engine would have matched it
|
|
// (NFC, lowercase, single-spaced) and capped, so the line is useful for corpus
|
|
// review without ever logging what a player literally typed into the box.
|
|
func (r *room) recordRejection(reason game.RejectReason, raw string) {
|
|
metrics.wordsRejected.Add(reason.String(), 1)
|
|
|
|
// sanitizeText first: raw may be the untouched client payload (the
|
|
// not-your-turn path never reaches the sanitizer below it in
|
|
// handleSubmit), and Normalize alone does not drop control or format
|
|
// characters.
|
|
word, _, err := vietnamese.Normalize(sanitizeText(raw, maxWordRunes, maxNicknameMarks))
|
|
if err != nil {
|
|
word = ""
|
|
}
|
|
if runes := []rune(word); len(runes) > maxWordRunes {
|
|
word = string(runes[:maxWordRunes])
|
|
}
|
|
|
|
slog.Info("word_rejected",
|
|
"reason", reason.String(),
|
|
"word", word,
|
|
"link", r.engine.Current(),
|
|
"mode", r.mode,
|
|
"room", r.code,
|
|
)
|
|
}
|
|
|
|
// handleBotMove applies what the worker chose.
|
|
func (r *room) handleBotMove(m botMoveInput) {
|
|
if r.engine == nil || r.engine.Over() {
|
|
return
|
|
}
|
|
// The position moved on while it was thinking; the chosen word answers a
|
|
// board that no longer exists.
|
|
if m.turnSeq != r.turnSeq || r.engine.Turn() != botPlayerID {
|
|
return
|
|
}
|
|
metrics.botMoves.Add(r.strategy.Difficulty().String(), 1)
|
|
|
|
now := time.Now()
|
|
before := r.mark()
|
|
|
|
if m.err != nil {
|
|
// The bot has nothing to play. A human in this position keeps their
|
|
// turn and loses it to the clock; the bot has no clock to spend, so
|
|
// the position is settled now and reported for what it is rather than
|
|
// as a resignation it never chose.
|
|
if !r.engine.NoMove(now) {
|
|
r.engine.Resign(botPlayerID, now)
|
|
}
|
|
r.applyEliminations(before)
|
|
return
|
|
}
|
|
|
|
move, reason := r.engine.Submit(botPlayerID, m.word, now)
|
|
if reason != game.ReasonNone {
|
|
// The bot searched the same dictionary the engine validates against,
|
|
// so this means the two disagree — a bug worth seeing, not a move to
|
|
// retry.
|
|
slog.Error("bot move rejected by engine", "room", r.code, "word", m.word, "reason", reason.String())
|
|
r.engine.Resign(botPlayerID, now)
|
|
r.applyEliminations(before)
|
|
return
|
|
}
|
|
|
|
r.turnSeq++
|
|
r.broadcastTurn(&move)
|
|
}
|
|
|
|
// maybeScheduleBot starts the bot thinking if it is now its turn.
|
|
func (r *room) maybeScheduleBot() {
|
|
if r.strategy == nil || r.engine.Over() || r.engine.Turn() != botPlayerID {
|
|
return
|
|
}
|
|
|
|
// The board is frozen here, on the room goroutine, before the worker
|
|
// exists. Handing the worker the live engine instead would race every
|
|
// resign and disconnect the room processes while the bot thinks — and
|
|
// bot.Board.Used reads engine state, so the race would be real, not
|
|
// theoretical.
|
|
board := freezeBoard(r.engine, r.opening)
|
|
seq := r.turnSeq
|
|
strategy := r.strategy
|
|
|
|
go func() {
|
|
word, err := strategy.Choose(board)
|
|
|
|
// The pause is a courtesy to the player, so it must not outlive the
|
|
// room: a bot still sleeping after everyone left is a goroutine leak
|
|
// per abandoned game.
|
|
select {
|
|
case <-time.After(strategy.ThinkingDelay()):
|
|
case <-r.ctx.Done():
|
|
return
|
|
}
|
|
r.send(botMoveInput{word: word, err: err, turnSeq: seq})
|
|
}()
|
|
}
|
|
|
|
// broadcastTurn sends the position to every seat, rendered for each.
|
|
//
|
|
// move is nil when the turn moved without a word being played, which is what
|
|
// an elimination does: the syllable and the used set survive the player who
|
|
// could not answer them, and everybody still needs the new deadline and the
|
|
// new player to act.
|
|
func (r *room) broadcastTurn(move *game.Move) {
|
|
state := r.engine.Snapshot()
|
|
meanings := r.moveMeanings(move)
|
|
for _, s := range r.seats {
|
|
r.sendTurnUpdate(s, state, move, meanings)
|
|
}
|
|
}
|
|
|
|
// moveMeanings looks up a played word's senses once per move; they are the
|
|
// same for every recipient. nil for no move.
|
|
func (r *room) moveMeanings(move *game.Move) []dictionary.Sense {
|
|
if move == nil {
|
|
return nil
|
|
}
|
|
return r.dict.Meanings(move.Word)
|
|
}
|
|
|
|
// sendTurnUpdate renders one position for one seat. by_me, my_turn and is_me
|
|
// are all per-recipient, which is why there is no single shared frame; the
|
|
// move's meanings are not, and arrive looked up.
|
|
func (r *room) sendTurnUpdate(s *seat, state game.State, move *game.Move, meanings []dictionary.Sense) {
|
|
if s == nil || s.sess == nil {
|
|
return
|
|
}
|
|
update := &noituv1.TurnUpdate{
|
|
CurrentSyllable: state.Current,
|
|
MyTurn: state.Turn == s.id,
|
|
DeadlineUnixMs: state.Deadline.UnixMilli(),
|
|
TurnSeq: r.turnSeq,
|
|
ChainLength: uint32(state.ChainLength),
|
|
Players: r.scoreRows(r.engine.Players(), state, s.id, nil),
|
|
TurnPlayerId: string(state.Turn),
|
|
}
|
|
if move != nil {
|
|
update.Played = PlayedWord(*move, move.Player == s.id, meanings)
|
|
}
|
|
s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_TurnUpdate{TurnUpdate: update}})
|
|
}
|
|
|
|
// inputMark is what the game looked like before an input: how many players
|
|
// were out, and who was to act. Remembered across the input so
|
|
// applyEliminations can tell that input's doing from what was already true,
|
|
// and whether it moved the turn.
|
|
type inputMark struct {
|
|
out int
|
|
turn game.PlayerID
|
|
}
|
|
|
|
// mark reads the current game, or the zero mark when there is no game.
|
|
func (r *room) mark() inputMark {
|
|
if r.engine == nil {
|
|
return inputMark{}
|
|
}
|
|
return inputMark{out: r.engine.EliminatedCount(), turn: r.engine.Turn()}
|
|
}
|
|
|
|
// applyEliminations reports everybody the last input knocked out, then whatever
|
|
// the game became: finished, or one turn further on.
|
|
//
|
|
// Every path that takes a player out of a game ends here — a timeout, a
|
|
// resignation, a bot with nothing to play, a reconnect window running out — so
|
|
// there is one place that decides what the room says about it.
|
|
func (r *room) applyEliminations(before inputMark) {
|
|
if r.engine == nil {
|
|
return
|
|
}
|
|
state := r.engine.Snapshot()
|
|
if len(state.Eliminated) == before.out {
|
|
return
|
|
}
|
|
|
|
// An elimination does not move the position, so one lookup describes it
|
|
// for everybody who went out on this input.
|
|
suggestions := r.engine.Suggestions(maxSuggestions)
|
|
for _, id := range state.Eliminated[before.out:] {
|
|
r.broadcastElimination(id, suggestions)
|
|
}
|
|
|
|
if r.engine.Over() {
|
|
r.broadcastGameOver(state)
|
|
return
|
|
}
|
|
// A new turn nobody played into, and the sequence moves with it: a
|
|
// submission already in flight was answering the position the player who
|
|
// just went out was looking at.
|
|
//
|
|
// It moves only when the turn does. Somebody forfeiting out of turn — a
|
|
// player who left the room, or whose reconnect window ran out — leaves the
|
|
// syllable, the deadline and the player to act exactly as they were, so
|
|
// the word that player is already sending still answers the board it was
|
|
// typed for. Bumping the sequence there would refuse it for something
|
|
// somebody else did.
|
|
if state.Turn != before.turn {
|
|
r.turnSeq++
|
|
}
|
|
r.broadcastTurn(nil)
|
|
}
|
|
|
|
// broadcastElimination tells the room one player is out.
|
|
//
|
|
// The suggestions go only to that player. They are what the position still had
|
|
// to offer, and the people who could still answer it are not the ones who
|
|
// needed to be told — an empty list is the answer for whoever was stuck, and
|
|
// noise for everybody else.
|
|
func (r *room) broadcastElimination(id game.PlayerID, suggestions []string) {
|
|
name := ""
|
|
if out := r.seatOf(id); out != nil {
|
|
name = out.nickname
|
|
}
|
|
reason := r.wireEndReason(id)
|
|
metrics.eliminations.Add(reason.String(), 1)
|
|
|
|
for _, s := range r.seats {
|
|
if s == nil || s.sess == nil {
|
|
continue
|
|
}
|
|
msg := &noituv1.PlayerEliminated{
|
|
PlayerId: string(id),
|
|
Name: name,
|
|
IsMe: s.id == id,
|
|
Reason: reason,
|
|
}
|
|
if s.id == id {
|
|
msg.Suggestions = suggestions
|
|
}
|
|
s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_PlayerEliminated{
|
|
PlayerEliminated: msg,
|
|
}})
|
|
}
|
|
}
|
|
|
|
// wireEndReason says how one player left the game.
|
|
//
|
|
// The engine's answer, unless the room overrode it: a reconnect window running
|
|
// out is a resignation to the engine, because that is the only shape it has
|
|
// for a player who stops playing, and somebody who left to everybody in the
|
|
// room.
|
|
func (r *room) wireEndReason(p game.PlayerID) noituv1.GameEndReason {
|
|
if code, overridden := r.outWire[p]; overridden {
|
|
return code
|
|
}
|
|
return EndReason(r.engine.OutReason(p))
|
|
}
|
|
|
|
// broadcastGameOver reports the result from each seat's point of view.
|
|
func (r *room) broadcastGameOver(state game.State) {
|
|
metrics.gamesFinished.Add(r.mode, 1)
|
|
if r.liveCounted.CompareAndSwap(true, false) {
|
|
r.hub.gameFinished()
|
|
}
|
|
|
|
// The reason the game ended is the reason the last player went out, which
|
|
// with two seats is the only elimination there was.
|
|
reason := noituv1.GameEndReason_GAME_END_REASON_UNSPECIFIED
|
|
if n := len(state.Eliminated); n > 0 {
|
|
reason = r.wireEndReason(state.Eliminated[n-1])
|
|
}
|
|
|
|
// Credited before anything is sent, so the RoomState the run loop
|
|
// broadcasts after a finished game already carries the game just won.
|
|
if s := r.seatOf(state.Winner); s != nil {
|
|
s.wins++
|
|
}
|
|
|
|
ranks := make(map[game.PlayerID]int, len(state.Standings))
|
|
order := make([]game.PlayerID, 0, len(state.Standings))
|
|
for _, standing := range state.Standings {
|
|
ranks[standing.Player] = standing.Rank
|
|
order = append(order, standing.Player)
|
|
}
|
|
|
|
for _, s := range r.seats {
|
|
if s == nil || s.sess == nil {
|
|
continue
|
|
}
|
|
s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_GameOver{
|
|
GameOver: &noituv1.GameOver{
|
|
IWon: state.Winner == s.id,
|
|
Reason: reason,
|
|
ChainLength: uint32(state.ChainLength),
|
|
Standings: r.scoreRows(order, state, s.id, ranks),
|
|
},
|
|
}})
|
|
}
|
|
// A finished game is a return to the lobby, and the run loop reports the
|
|
// state they are returning to.
|
|
r.lobbyChanged = true
|
|
}
|
|
|
|
// scoreRows renders the players table for one recipient.
|
|
//
|
|
// order is the sequence to report them in — turn order while a game runs,
|
|
// finishing order once one has ended — and ranks is empty until there is a
|
|
// result, which is what makes a rank of zero mean "still playing" rather than
|
|
// needing a field of its own to say so.
|
|
func (r *room) scoreRows(order []game.PlayerID, state game.State, me game.PlayerID, ranks map[game.PlayerID]int) []*noituv1.PlayerScore {
|
|
rows := make([]*noituv1.PlayerScore, 0, len(order))
|
|
for _, id := range order {
|
|
row := &noituv1.PlayerScore{
|
|
PlayerId: string(id),
|
|
IsMe: id == me,
|
|
Score: uint32(state.Scores[id]),
|
|
// A player the engine no longer knows is a seat that was vacated
|
|
// mid-game, which only happens to somebody already out.
|
|
Eliminated: !state.Alive[id],
|
|
// The bot has no socket to lose, so it is never the one keeping
|
|
// the room waiting.
|
|
Connected: id == botPlayerID,
|
|
Rank: uint32(ranks[id]),
|
|
}
|
|
if s := r.seatOf(id); s != nil {
|
|
row.Name = s.nickname
|
|
row.Connected = row.Connected || s.sess != nil
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// handleDisconnect holds the seat open for the player who dropped out of it.
|
|
//
|
|
// 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.
|
|
//
|
|
// The turn clock is deliberately not paused. A player who drops on their own
|
|
// turn loses it the way anybody else would; the window decides only whether
|
|
// they are still in the game afterwards.
|
|
func (r *room) handleDisconnect(m disconnectInput) {
|
|
s := r.seatOf(m.player)
|
|
// A stale notice from a connection the player already replaced. Acting on
|
|
// it would evict the seat the new socket is sitting in.
|
|
if s == nil || s.sess == nil || s.sess != m.sess {
|
|
return
|
|
}
|
|
s.sess = nil
|
|
s.graceUntil = time.Now().Add(r.graceFor)
|
|
// Presence is part of the room's state, and the run loop is what sends it.
|
|
// There is nothing extra to say to the players who are still here.
|
|
r.lobbyChanged = true
|
|
}
|
|
|
|
// nextGraceExpiry is the earliest reconnect window still open.
|
|
func (r *room) nextGraceExpiry() (time.Time, bool) {
|
|
var next time.Time
|
|
for _, s := range r.seats {
|
|
if s == nil || s.sess != nil || s.graceUntil.IsZero() {
|
|
continue
|
|
}
|
|
if next.IsZero() || s.graceUntil.Before(next) {
|
|
next = s.graceUntil
|
|
}
|
|
}
|
|
return next, !next.IsZero()
|
|
}
|
|
|
|
// handleGraceExpiry frees every seat whose reconnect window has run out.
|
|
//
|
|
// The engine goes first, while the seats are still here to be named: once one
|
|
// is vacated there is nobody left to attribute the elimination to, and the
|
|
// players who stayed would be told that somebody with no name went out.
|
|
func (r *room) handleGraceExpiry() {
|
|
now := time.Now()
|
|
|
|
var expired []*seat
|
|
for _, s := range r.seats {
|
|
if s == nil || s.sess != nil || s.graceUntil.IsZero() || s.graceUntil.After(now) {
|
|
continue
|
|
}
|
|
expired = append(expired, s)
|
|
}
|
|
if len(expired) == 0 {
|
|
return
|
|
}
|
|
|
|
before := r.mark()
|
|
for _, s := range expired {
|
|
r.eliminateAbsent(s, now)
|
|
}
|
|
r.applyEliminations(before)
|
|
|
|
for _, s := range expired {
|
|
r.vacate(s)
|
|
}
|
|
r.lobbyChanged = true
|
|
}
|
|
|
|
// eliminateAbsent takes a seat out of a live game once nobody is coming back
|
|
// to it.
|
|
//
|
|
// The engine is told this is a resignation, because that is the only shape it
|
|
// has for a player who stops playing. What the room reports is the transport
|
|
// fact instead: from everybody else's side this is somebody who left, not
|
|
// somebody who chose to give up.
|
|
func (r *room) eliminateAbsent(s *seat, now time.Time) {
|
|
if r.engine == nil || r.engine.Over() || !r.engine.Alive(s.id) {
|
|
return
|
|
}
|
|
r.outWire[s.id] = noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT
|
|
r.engine.Resign(s.id, now)
|
|
}
|
|
|
|
// handleResume rebinds a seat to a new connection and replays the position.
|
|
//
|
|
// The replay is built from the engine, never from stored copies of past
|
|
// messages: a recorded stream can drift from the real state, and the resumed
|
|
// client would then be shown a board the server does not believe in.
|
|
func (r *room) handleResume(m resumeInput) {
|
|
s := r.seatOf(m.player)
|
|
if s == nil {
|
|
m.sess.send(errorMsg("session_not_resumable"))
|
|
return
|
|
}
|
|
|
|
// Accepted. Only now is the old connection finished: its token is spent and
|
|
// its socket is either gone or about to be, and leaving it registered would
|
|
// let a third connection claim the same seat.
|
|
metrics.resumesSucceeded.Add(1)
|
|
m.sess.attach(r, string(m.player))
|
|
if m.prior != nil {
|
|
m.sess.hub.unregister(m.prior.resumeToken)
|
|
m.prior.close()
|
|
}
|
|
s.sess = m.sess
|
|
s.graceUntil = time.Time{}
|
|
// The seat keeps the name it was given. Re-reading it from the new
|
|
// connection would let a reconnect rename a player mid-game, including
|
|
// into somebody else's name.
|
|
|
|
// Everybody needs the room's state again: this player to render the lobby
|
|
// they came back to, the rest to stop watching a disconnect banner for
|
|
// somebody who is already back. The run loop sends it to all of them.
|
|
r.lobbyChanged = true
|
|
|
|
// Before the lobby return below, not after it: a refresh in the lobby is
|
|
// the commonest resume there is, and it is exactly the one that would miss
|
|
// a replay hung off the end of this function.
|
|
r.sendChatHistory(s)
|
|
|
|
// 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
|
|
}
|
|
state := r.engine.Snapshot()
|
|
r.sendGameStarted(s, state)
|
|
if len(state.History) > 0 {
|
|
last := state.History[len(state.History)-1]
|
|
r.sendTurnUpdate(s, state, &last, r.moveMeanings(&last))
|
|
}
|
|
}
|
|
|
|
// handleChat delivers one line of text to everybody in the room.
|
|
func (r *room) handleChat(m chatInput) {
|
|
// The seat, not the claimed id. A connection the room has already retired
|
|
// - kicked, or replaced by a reconnect - can still have a frame in flight,
|
|
// and by the time the room drains it that seat may belong to somebody else.
|
|
if !r.occupies(m.sess, m.player) {
|
|
m.sess.send(errorMsg("not_your_seat"))
|
|
return
|
|
}
|
|
// A bot room has no conversation. Checked here rather than in the session,
|
|
// because r.strategy is room-goroutine state.
|
|
if r.strategy != nil {
|
|
m.sess.send(errorMsg("not_in_a_room"))
|
|
return
|
|
}
|
|
|
|
text := sanitizeText(m.text, maxChatRunes, maxChatMarks)
|
|
// Nothing usable survived. There is no message to refuse and nobody to
|
|
// tell: the client will not enable its send button for input that reduces
|
|
// to this, so anything reaching here typed nothing.
|
|
if text == "" {
|
|
return
|
|
}
|
|
|
|
from := r.seatOf(m.player)
|
|
r.chatSeq++
|
|
entry := chatEntry{
|
|
seq: r.chatSeq,
|
|
author: from.id,
|
|
name: from.nickname,
|
|
text: text,
|
|
at: time.Now(),
|
|
}
|
|
r.chat = append(r.chat, entry)
|
|
if len(r.chat) > chatHistoryLimit {
|
|
r.chat = r.chat[len(r.chat)-chatHistoryLimit:]
|
|
}
|
|
metrics.chatLines.Add(1)
|
|
|
|
for _, s := range r.seats {
|
|
if s == nil || s.sess == nil {
|
|
continue
|
|
}
|
|
// Best effort: a chat frame is dropped rather than allowed to close a
|
|
// session whose outbox is full. Losing a line is recoverable - the
|
|
// next replay carries it - and closing a session costs its owner the
|
|
// game.
|
|
s.sess.trySend(chatMessageFor(entry, s.id))
|
|
}
|
|
}
|
|
|
|
// sendChatHistory replays one seat's slice of the conversation.
|
|
//
|
|
// Scoped by the seat's chatFrom: a player is shown what was said while they
|
|
// were sitting there and nothing else. Sent from the handler, so it reaches the
|
|
// client before that input's RoomState - the client must not depend on the
|
|
// order, and does not, because a history replaces its panel wholesale.
|
|
func (r *room) sendChatHistory(s *seat) {
|
|
if s == nil || s.sess == nil || r.strategy != nil {
|
|
return
|
|
}
|
|
|
|
messages := make([]*noituv1.ChatMessage, 0, len(r.chat))
|
|
for _, entry := range r.chat {
|
|
if entry.seq <= s.chatFrom {
|
|
continue
|
|
}
|
|
messages = append(messages, chatMessageFor(entry, s.id).GetChatMessage())
|
|
}
|
|
|
|
// send, not trySend: this is the frame that corrects a client's whole
|
|
// panel, including the empty one that clears a conversation carried in
|
|
// from another room. A dropped line recovers on the next replay; a dropped
|
|
// replay has nothing behind it.
|
|
s.sess.send(&noituv1.ServerMessage{Payload: &noituv1.ServerMessage_ChatHistory{
|
|
ChatHistory: &noituv1.ChatHistory{Messages: messages},
|
|
}})
|
|
}
|
|
|
|
// chatMessageFor renders one entry from one seat's point of view.
|
|
//
|
|
// An entry whose author has been cleared belongs to nobody: it is from_me for
|
|
// neither player and carries no name, so the seat's next occupant is not shown
|
|
// a stranger's words as their own and the player who stayed cannot have them
|
|
// reattributed to whoever arrives next.
|
|
func chatMessageFor(entry chatEntry, id game.PlayerID) *noituv1.ServerMessage {
|
|
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_ChatMessage{
|
|
ChatMessage: &noituv1.ChatMessage{
|
|
FromMe: entry.author != "" && entry.author == id,
|
|
// Empty together with the name for a vacated seat: a line nobody
|
|
// owns must not be coloured as somebody's either.
|
|
PlayerId: string(entry.author),
|
|
Author: entry.name,
|
|
Text: entry.text,
|
|
SentUnixMs: entry.at.UnixMilli(),
|
|
},
|
|
}}
|
|
}
|
|
|
|
// 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 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 = [maxPlayers]game.PlayerID{"p1", "p2", "p3", "p4"}
|
|
|
|
// seatedCount is how many seats are held, including by players inside their
|
|
// reconnect window.
|
|
func (r *room) seatedCount() int {
|
|
n := 0
|
|
for _, s := range r.seats {
|
|
if s != nil {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// allConnected reports whether every seated player has a socket. A game cannot
|
|
// start without one, because the first thing it does is deal everybody a turn.
|
|
func (r *room) allConnected() bool {
|
|
for _, s := range r.seats {
|
|
if s != nil && s.sess == nil {
|
|
return false
|
|
}
|
|
}
|
|
return 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 except one seat's own, so
|
|
// a joiner can be told apart from all of them.
|
|
func (r *room) takenNicknames(except game.PlayerID) []string {
|
|
names := make([]string, 0, maxPlayers)
|
|
for _, s := range r.seats {
|
|
if s != nil && s.id != except {
|
|
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()
|
|
}
|
|
}
|
|
|
|
// detachAll releases every connection still bound to this room as it exits.
|
|
func (r *room) detachAll() {
|
|
for _, s := range r.seats {
|
|
if s != nil && s.sess != nil {
|
|
s.sess.release(r)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.seats {
|
|
if s == nil || s.sess == nil {
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
|
|
func (r *room) broadcastError(code string) {
|
|
for _, s := range r.seats {
|
|
if s != nil && s.sess != nil {
|
|
s.sess.send(errorMsg(code))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *room) sendTo(p game.PlayerID, msg *noituv1.ServerMessage) {
|
|
if s := r.seatOf(p); s != nil && s.sess != nil {
|
|
s.sess.send(msg)
|
|
}
|
|
}
|
|
|
|
func (r *room) seatOf(p game.PlayerID) *seat {
|
|
for _, s := range r.seats {
|
|
if s != nil && s.id == p {
|
|
return s
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// frozenBoard is an immutable position for a bot worker to search.
|
|
//
|
|
// It satisfies bot.Board without holding the engine. The dictionary is safe to
|
|
// share — the store loads once at Open and is read-only thereafter — but the
|
|
// used set is engine state, so it is copied.
|
|
type frozenBoard struct {
|
|
legal []string
|
|
used map[string]struct{}
|
|
dict game.Dictionary
|
|
}
|
|
|
|
func freezeBoard(e *game.Engine, opening string) *frozenBoard {
|
|
state := e.Snapshot()
|
|
|
|
// History omits the opening word, but the engine counts it as played. A
|
|
// board that disagreed would let the bot pick a word the engine then
|
|
// rejects as already used.
|
|
used := make(map[string]struct{}, len(state.History)+1)
|
|
used[opening] = struct{}{}
|
|
for _, m := range state.History {
|
|
used[m.Word] = struct{}{}
|
|
}
|
|
|
|
return &frozenBoard{legal: e.LegalMoves(), used: used, dict: e.Dict()}
|
|
}
|
|
|
|
func (b *frozenBoard) LegalMoves() []string { return b.legal }
|
|
|
|
func (b *frozenBoard) Used(word string) bool {
|
|
_, ok := b.used[word]
|
|
return ok
|
|
}
|
|
|
|
func (b *frozenBoard) WordsStartingWith(syllable string) iter.Seq[string] {
|
|
return b.dict.WordsStartingWith(syllable)
|
|
}
|
|
|
|
func (b *frozenBoard) LastSyllable(word string) (string, bool) {
|
|
return b.dict.LastSyllable(word)
|
|
}
|