feat(online)!: seat two to four players in a room

A room holds up to four people and needs two to start. Both numbers are
server constants sent to the client in RoomState, so the lobby draws
whatever the server allows and widening a room is a server change alone.

Failing a turn eliminates that player rather than ending the game. The
syllable and the used words survive them, the turn passes to whoever is
next, and the last player standing wins. Two seats is that same rule seen
from close up, which is why there is one implementation of it and not two.

A dead end still costs the first player to face it their own clock, as
before: they get their turn, and lose it. Everyone behind them has already
seen that board, so they go out together rather than each sitting out a turn
limit they cannot use — which leaves the player who closed the position
standing, the same outcome two players get.

A player who is knocked out keeps their seat. They watch the rest of the
game, chat included, with only the word input gone, and everybody lands back
in the same lobby when it ends. The result screen is the whole table, ranked
by who outlasted whom, with each score reported beside the place rather than
deciding it.

The turn clock is deliberately not paused for a seat that has dropped, so a
player who loses their connection on their own turn loses it the way anybody
else would. Their reconnect window decides only whether they are still in the
game afterwards. Any number of windows can be open at once, settled by one
timer armed for the nearest of them.

Starting waits for every guest, not merely the first: a room of four that
began on one yes would have dealt three people a turn they never agreed to.
Kicking names a seat and is still refused on a player who is ready, and on
the owner's own — leaving is what an owner who wants out does, and it hands
the room on. Joining stays a lobby thing: a room with a game running turns a
latecomer away even with seats going spare, because there is no way to hand
somebody a game already in progress.

BREAKING CHANGE: RoomState, TurnUpdate and GameOver lose the fields that
could only ever describe a second player, OpponentLeft is retired in favour
of presence on RoomState, and suggestions move to the new PlayerEliminated —
they describe the position that beat a player, which by the end of a longer
game is nobody else's position. ProtocolVersion goes to 2, so a client built
against 1 is refused with a readable error rather than decoding a frame that
now means something else.
This commit is contained in:
tiennm99 committed 2026-09-08 10:56:14 +07:00
1 parent 0b507c3537
commit 4e2e9e1433
54 files changed
+3692 -1129

No files matched your search

+165 -30
View File
@@ -26,14 +26,29 @@ const (
maxPointsPerWord = 100
)
// Engine holds one game.
// Engine holds one game of two or more players.
//
// A player who fails their turn is eliminated and the rest play on from the
// same syllable; the game ends when one of them is left. Two seats is that
// same rule seen from close up, which is why there is one implementation of it
// and not two.
//
// Not safe for concurrent use. Exactly one goroutine owns an Engine — in the
// server that is the room goroutine, which serializes every input through a
// single channel.
type Engine struct {
dict Dictionary
players []PlayerID
dict Dictionary
players []PlayerID
// alive is parallel to players. Eliminating somebody clears their flag
// rather than dropping them from the slice: their score, their words and
// their place in turn order all have to survive them.
alive []bool
aliveN int
// outOrder is who went out, first out first, and outReason is why. Between
// them they are the whole final table — a rank is a position in this list
// read backwards, so nothing has to be recomputed to report one.
outOrder []PlayerID
outReason map[PlayerID]EndReason
used map[string]struct{}
current string
turnIndex int
@@ -81,13 +96,17 @@ func New(dict Dictionary, players []PlayerID, opening string, turnLimit time.Dur
e := &Engine{
dict: dict,
players: append([]PlayerID{}, players...),
alive: make([]bool, len(players)),
aliveN: len(players),
outReason: make(map[PlayerID]EndReason, len(players)),
used: map[string]struct{}{canonical: {}},
current: last,
turnLimit: turnLimit,
deadline: now.Add(turnLimit),
scores: make(map[PlayerID]int, len(players)),
}
for _, p := range players {
for i, p := range players {
e.alive[i] = true
e.scores[p] = 0
}
@@ -106,9 +125,22 @@ func New(dict Dictionary, players []PlayerID, opening string, turnLimit time.Dur
// the same word graph that Submit validates against.
func (e *Engine) Dict() Dictionary { return e.dict }
// Turn reports whose move it is.
// Turn reports whose move it is. Always somebody still in the game, and once
// the last elimination has landed it is the winner.
func (e *Engine) Turn() PlayerID { return e.players[e.turnIndex] }
// Players reports the seats in turn order, eliminated ones included.
func (e *Engine) Players() []PlayerID { return append([]PlayerID{}, e.players...) }
// Alive reports whether a player is still in the game.
func (e *Engine) Alive(p PlayerID) bool {
i := e.indexOf(p)
return i >= 0 && e.alive[i]
}
// Score reports one player's points.
func (e *Engine) Score(p PlayerID) int { return e.scores[p] }
// Current reports the syllable the next word must start with.
func (e *Engine) Current() string { return e.current }
@@ -145,7 +177,7 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas
return Move{}, ReasonNotYourTurn
}
if e.IsExpired(now) {
e.expire()
e.expire(now)
return Move{}, ReasonTimeout
}
@@ -191,7 +223,7 @@ func (e *Engine) Submit(p PlayerID, raw string, now time.Time) (Move, RejectReas
e.history = append(e.history, move)
e.scores[p] += move.Points
e.current = last
e.turnIndex = (e.turnIndex + 1) % len(e.players)
e.advance()
e.deadline = now.Add(e.turnLimit)
// A dead end is deliberately not the end of the game. Ending it here would
@@ -255,67 +287,162 @@ func (e *Engine) IsExpired(now time.Time) bool {
return now.After(e.deadline)
}
// Timeout ends the game against the player whose turn expired. The caller
// drives this from its own timer; the engine never reads the clock itself.
// Timeout eliminates the player whose turn expired. The caller drives this
// from its own timer; the engine never reads the clock itself.
//
// It reports that something happened, not that the game ended: past two seats
// a timeout usually just moves the turn on. Over answers the other question.
func (e *Engine) Timeout(now time.Time) bool {
if e.over || !e.IsExpired(now) {
return false
}
e.expire()
e.expire(now)
return true
}
// NoMove ends the game against the player to act when the position leaves
// them nothing to play, without waiting for their clock to run out.
// NoMove eliminates the player to act when the position leaves them nothing to
// play, without waiting for their clock to run out.
//
// Only for a player who has no clock to wait for — the bot answers the moment
// it has searched, and making it sit out a turn limit it cannot use would
// stall the room. A human keeps their turn: see Submit.
func (e *Engine) NoMove() bool {
func (e *Engine) NoMove(now time.Time) bool {
if e.over || e.HasLegalMove() {
return false
}
e.finish(e.opponentOf(e.Turn()), EndNoLegalMove)
e.expire(now)
return true
}
// expire ends the game against the player to act, whose turn has run out.
// expire ends the current turn against the player holding it.
//
// A player who never had a word to play did not run out of thinking time:
// there was nothing to think about, and reporting a timeout would blame them
// for a position nobody could have answered.
func (e *Engine) expire() {
func (e *Engine) expire(now time.Time) {
reason := EndTimeout
if !e.HasLegalMove() {
reason = EndNoLegalMove
}
e.finish(e.opponentOf(e.Turn()), reason)
e.eliminate(e.Turn(), reason)
e.settle()
if !e.over {
e.deadline = now.Add(e.turnLimit)
}
}
// Resign ends the game against the player who gave up.
func (e *Engine) Resign(p PlayerID) bool {
// settle clears out everybody a dead end leaves with nothing.
//
// The first player to face one still loses it on their own clock — they get
// their turn, for the reason Submit gives. Everyone behind them has already
// seen that board, so making each of them sit out a full turn limit they
// cannot use would add minutes of nothing to a game that is already decided.
// Going out together instead leaves the player who closed the position
// standing, which is exactly what two players get.
func (e *Engine) settle() {
for !e.over && !e.HasLegalMove() {
e.eliminate(e.Turn(), EndNoLegalMove)
}
}
// Resign eliminates the player who gave up.
//
// It works out of turn: past two seats a player may want out while somebody
// else is thinking, and holding them to a turn they have already given up on
// is not a rule worth having. The clock restarts only when the resignation
// actually moved the turn on, so leaving out of turn cannot hand the player to
// act more time than they had.
func (e *Engine) Resign(p PlayerID, now time.Time) bool {
if e.over {
return false
}
e.finish(e.opponentOf(p), EndResigned)
before := e.Turn()
if !e.eliminate(p, EndResigned) {
return false
}
e.settle()
if !e.over && e.Turn() != before {
e.deadline = now.Add(e.turnLimit)
}
return true
}
func (e *Engine) finish(winner PlayerID, reason EndReason) {
e.over = true
e.winner = winner
// eliminate takes one player out and ends the game when one is left.
//
// The seat stays in players. An eliminated player keeps their score and the
// words they played, and the transport layer still has them to render — being
// out of the game is not being out of the room.
func (e *Engine) eliminate(p PlayerID, reason EndReason) bool {
i := e.indexOf(p)
if i < 0 || !e.alive[i] {
return false
}
e.alive[i] = false
e.aliveN--
e.outOrder = append(e.outOrder, p)
e.outReason[p] = reason
// The game-level reason is the latest elimination's, which with two seats
// is the only one there ever was.
e.endReason = reason
if e.turnIndex == i {
e.advance()
}
if e.aliveN <= 1 {
e.over = true
e.winner = e.players[e.turnIndex]
}
return true
}
// opponentOf returns the other player. With more than two seats it returns the
// next one, which keeps the two-player case exact and the rest sane.
func (e *Engine) opponentOf(p PlayerID) PlayerID {
for i, candidate := range e.players {
if candidate == p {
return e.players[(i+1)%len(e.players)]
// advance moves the turn to the next player still in the game.
func (e *Engine) advance() {
for range e.players {
e.turnIndex = (e.turnIndex + 1) % len(e.players)
if e.alive[e.turnIndex] {
return
}
}
return p
}
func (e *Engine) indexOf(p PlayerID) int {
for i, candidate := range e.players {
if candidate == p {
return i
}
}
return -1
}
// EliminatedCount is how many players have gone out. A caller that remembers
// it across an input can tell exactly who that input knocked out.
func (e *Engine) EliminatedCount() int { return len(e.outOrder) }
// OutReason reports how a player left the game, and EndNone for one who has
// not. The transport layer needs it per player: with several seats, "why the
// game ended" and "why this player went out" stop being the same question.
func (e *Engine) OutReason(p PlayerID) EndReason { return e.outReason[p] }
// Standings is the final table, best first. Meaningless while the game is in
// play, for the same reason Winner is.
func (e *Engine) Standings() []Standing {
out := make([]Standing, 0, len(e.players))
if e.winner != "" {
out = append(out, Standing{Player: e.winner, Score: e.scores[e.winner], Rank: 1, Reason: EndNone})
}
// Read backwards: outlasting somebody is what beats them, so of the players
// who went out the last one to go placed highest.
for i := len(e.outOrder) - 1; i >= 0; i-- {
p := e.outOrder[i]
out = append(out, Standing{
Player: p,
Score: e.scores[p],
Rank: len(out) + 1,
Reason: e.outReason[p],
})
}
return out
}
// Used reports whether a canonical word has already been played.
@@ -331,15 +458,23 @@ func (e *Engine) Snapshot() State {
scores[p] = s
}
alive := make(map[PlayerID]bool, len(e.players))
for i, p := range e.players {
alive[p] = e.alive[i]
}
return State{
Current: e.current,
Turn: e.Turn(),
Deadline: e.deadline,
History: append([]Move{}, e.history...),
Scores: scores,
Alive: alive,
Eliminated: append([]PlayerID{}, e.outOrder...),
ChainLength: e.ChainLength(),
Over: e.over,
Winner: e.winner,
EndReason: e.endReason,
Standings: e.Standings(),
}
}
+6 -6
View File
@@ -353,7 +353,7 @@ func TestDeadEndEndsOnTheClockAsNoLegalMove(t *testing.T) {
func TestNoMoveEndsADeadEndImmediately(t *testing.T) {
e := deadEndGame(t)
if !e.NoMove() {
if !e.NoMove(t0) {
t.Fatal("NoMove = false in a position with nothing to play")
}
if e.Winner() != bob {
@@ -363,7 +363,7 @@ func TestNoMoveEndsADeadEndImmediately(t *testing.T) {
t.Errorf("EndReason = %s, want %s", got, EndNoLegalMove)
}
if e.NoMove() {
if e.NoMove(t0) {
t.Error("NoMove ended an already finished game a second time")
}
}
@@ -373,7 +373,7 @@ func TestNoMoveEndsADeadEndImmediately(t *testing.T) {
func TestNoMoveRefusesAPlayablePosition(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
if e.NoMove() {
if e.NoMove(t0) {
t.Error("NoMove = true with legal moves available")
}
if e.Over() {
@@ -461,7 +461,7 @@ func TestTimeoutAwardsOpponent(t *testing.T) {
func TestResign(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
if !e.Resign(alice) {
if !e.Resign(alice, t0) {
t.Fatal("Resign returned false")
}
if e.Winner() != bob {
@@ -470,7 +470,7 @@ func TestResign(t *testing.T) {
if e.Snapshot().EndReason != EndResigned {
t.Errorf("EndReason = %s, want %s", e.Snapshot().EndReason, EndResigned)
}
if e.Resign(bob) {
if e.Resign(bob, t0) {
t.Error("Resign succeeded on a finished game")
}
}
@@ -479,7 +479,7 @@ func TestResign(t *testing.T) {
// player as copy.
func TestSubmitAfterGameOver(t *testing.T) {
e := newGame(t, standardDict(), "ngôn ngữ")
e.Resign(alice)
e.Resign(alice, t0)
if _, r := e.Submit(bob, "ngữ pháp", t0); r != ReasonGameOver {
t.Errorf("Submit after the game ended = %s, want %s", r, ReasonGameOver)
+235
View File
@@ -0,0 +1,235 @@
package game
import (
"slices"
"testing"
"time"
)
// The rules that only exist past two seats: a failed turn takes one player out
// rather than ending the game, the rest carry on from the same syllable, and
// the last one standing wins.
const (
carol = PlayerID("carol")
dave = PlayerID("dave")
)
// fourPlayers opens a game on a chain long enough that nobody runs out of words
// by accident.
func fourPlayers(t *testing.T) *Engine {
t.Helper()
e, err := New(standardDict(), []PlayerID{alice, bob, carol, dave}, "ngôn ngữ", 20*time.Second, t0)
if err != nil {
t.Fatalf("New: %v", err)
}
return e
}
func TestTimeoutEliminatesAndPlaysOn(t *testing.T) {
e := fourPlayers(t)
syllable := e.Current()
if !e.Timeout(t0.Add(21 * time.Second)) {
t.Fatal("Timeout did not fire after the deadline")
}
if e.Over() {
t.Error("a four-player game ended on the first timeout")
}
if e.Alive(alice) {
t.Error("the player whose turn expired is still in the game")
}
if e.Turn() != bob {
t.Errorf("Turn = %q, want %q", e.Turn(), bob)
}
// The position survives the player: whoever inherits the turn answers the
// same syllable, against the same used set.
if e.Current() != syllable {
t.Errorf("Current = %q, want %q — an elimination is not a move", e.Current(), syllable)
}
// And they get a full turn to do it in, rather than the remains of one
// somebody else spent.
if want := t0.Add(41 * time.Second); !e.Deadline().Equal(want) {
t.Errorf("Deadline = %v, want %v", e.Deadline(), want)
}
}
func TestTheLastPlayerStandingWins(t *testing.T) {
e := fourPlayers(t)
// Three timeouts, each a turn limit after the last.
for i, out := range []PlayerID{alice, bob, carol} {
at := t0.Add(time.Duration(i+1) * 21 * time.Second)
if !e.Timeout(at) {
t.Fatalf("timeout %d did not fire", i)
}
if e.Alive(out) {
t.Errorf("%q survived their own timeout", out)
}
}
if !e.Over() {
t.Fatal("the game did not end with one player left")
}
if e.Winner() != dave {
t.Errorf("Winner = %q, want %q", e.Winner(), dave)
}
}
// Standings rank by who outlasted whom. A player who scored more and went out
// earlier still places below one who was there at the end.
func TestStandingsRankByFinishingOrder(t *testing.T) {
e := fourPlayers(t)
// Alice banks a word before losing her next turn, so she outscores
// everybody and still finishes last.
if _, r := e.Submit(alice, "ngữ pháp", t0); r != ReasonNone {
t.Fatalf("Submit: %s", r)
}
for i := range 3 {
if !e.Timeout(t0.Add(time.Duration(i+1) * 21 * time.Second)) {
t.Fatalf("timeout %d did not fire", i)
}
}
got := e.Standings()
if len(got) != 4 {
t.Fatalf("Standings has %d rows, want 4", len(got))
}
// bob, carol, dave time out in that order, leaving alice.
want := []PlayerID{alice, dave, carol, bob}
for i, standing := range got {
if standing.Player != want[i] {
t.Errorf("rank %d is %q, want %q", i+1, standing.Player, want[i])
}
if standing.Rank != i+1 {
t.Errorf("%q has rank %d at position %d", standing.Player, standing.Rank, i+1)
}
}
if got[0].Reason != EndNone {
t.Errorf("the winner left the game for reason %s, want %s", got[0].Reason, EndNone)
}
if got[1].Reason != EndTimeout {
t.Errorf("an eliminated player's reason = %s, want %s", got[1].Reason, EndTimeout)
}
}
// A dead end must not cost every remaining player a full turn clock each. The
// first one to face it loses it on their own time, as in a two-player game;
// everybody behind them has already seen that board and goes out with them,
// which leaves the player who closed the position standing.
func TestADeadEndSettlesInOneTurnNotThree(t *testing.T) {
d := newDict("a b", "b c", "c d")
e, err := New(d, []PlayerID{alice, bob, carol, dave}, "a b", 20*time.Second, t0)
if err != nil {
t.Fatalf("New: %v", err)
}
// a b -> b c -> c d, and "d" starts nothing.
if _, r := e.Submit(alice, "b c", t0); r != ReasonNone {
t.Fatalf("Submit b c: %s", r)
}
if _, r := e.Submit(bob, "c d", t0); r != ReasonNone {
t.Fatalf("Submit c d: %s", r)
}
// Carol is on turn with nothing to play, and loses it to the clock.
if !e.Timeout(t0.Add(21 * time.Second)) {
t.Fatal("Timeout did not fire")
}
if !e.Over() {
t.Fatal("a dead end left the game running with nobody able to answer it")
}
if e.Winner() != bob {
t.Errorf("Winner = %q, want %q — the player who closed the position", e.Winner(), bob)
}
// Carol was the one on the clock. Dave and Alice never got a turn they
// could have used, and go out with her rather than each spending one.
if got := e.Snapshot().Eliminated; !slices.Equal(got, []PlayerID{carol, dave, alice}) {
t.Errorf("Eliminated = %v, want [carol dave alice]", got)
}
if got := e.OutReason(dave); got != EndNoLegalMove {
t.Errorf("dave went out for %s, want %s", got, EndNoLegalMove)
}
}
// Past two seats a player may want out while somebody else is thinking, and
// holding them to a turn they have already given up on is not a rule worth
// having.
func TestResignOutOfTurnLeavesTheClockAlone(t *testing.T) {
e := fourPlayers(t)
deadline := e.Deadline()
if !e.Resign(carol, t0.Add(5*time.Second)) {
t.Fatal("Resign out of turn returned false")
}
if e.Over() {
t.Error("one player leaving ended a four-player game")
}
if e.Alive(carol) {
t.Error("the resigning player is still in the game")
}
if e.Turn() != alice {
t.Errorf("Turn = %q, want %q — resigning out of turn moved the turn", e.Turn(), alice)
}
if !e.Deadline().Equal(deadline) {
t.Error("resigning out of turn handed the player to act more time")
}
// And the turn skips the empty seat when it comes round.
if _, r := e.Submit(alice, "ngữ pháp", t0.Add(6*time.Second)); r != ReasonNone {
t.Fatalf("Submit: %s", r)
}
if e.Turn() != bob {
t.Fatalf("Turn = %q, want %q", e.Turn(), bob)
}
if _, r := e.Submit(bob, "pháp luật", t0.Add(7*time.Second)); r != ReasonNone {
t.Fatalf("Submit: %s", r)
}
if e.Turn() != dave {
t.Errorf("Turn = %q, want %q — the turn stopped at an eliminated seat", e.Turn(), dave)
}
}
// An eliminated player keeps their score and their words. Being out of the game
// is not being out of the record of it.
func TestAnEliminatedPlayerKeepsTheirScore(t *testing.T) {
e := fourPlayers(t)
if _, r := e.Submit(alice, "ngữ pháp", t0); r != ReasonNone {
t.Fatalf("Submit: %s", r)
}
scored := e.Score(alice)
if scored == 0 {
t.Fatal("the move scored nothing")
}
e.Resign(alice, t0.Add(time.Second))
if got := e.Score(alice); got != scored {
t.Errorf("score after elimination = %d, want %d", got, scored)
}
if state := e.Snapshot(); len(state.History) != 1 || state.History[0].Player != alice {
t.Errorf("the eliminated player's move is missing from the history: %+v", state.History)
}
if !slices.Contains(e.Players(), alice) {
t.Error("Players dropped the eliminated seat, which the transport still has to render")
}
}
// A submission from a player who is out is refused as a turn error, not
// silently applied to whoever is actually on turn.
func TestAnEliminatedPlayerCannotMove(t *testing.T) {
e := fourPlayers(t)
e.Resign(carol, t0)
if _, r := e.Submit(carol, "ngữ pháp", t0.Add(time.Second)); r != ReasonNotYourTurn {
t.Errorf("Submit from an eliminated player = %s, want %s", r, ReasonNotYourTurn)
}
if e.Resign(carol, t0.Add(time.Second)) {
t.Error("Resign succeeded twice for the same player")
}
}
+30 -5
View File
@@ -117,16 +117,41 @@ func (r EndReason) String() string {
return "unknown"
}
// Standing is one player's final placing.
//
// Rank 1 is whoever was still standing when the game ended; below them, a
// player eliminated later ranks above one eliminated earlier. Outlasting
// somebody is what beats them, so the ranking is finishing order and the score
// is reported beside it rather than deciding it.
type Standing struct {
Player PlayerID
Score int
Rank int
// Reason is how this player left the game, and EndNone for the winner,
// who did not.
Reason EndReason
}
// State is a snapshot for the transport layer to render. It copies everything
// it exposes, so a caller can hold it without touching engine state.
type State struct {
Current string
Turn PlayerID
Deadline time.Time
History []Move
Scores map[PlayerID]int
Current string
Turn PlayerID
Deadline time.Time
History []Move
Scores map[PlayerID]int
// Alive says who is still in the game. A player who has been eliminated
// keeps their score and their place in the history; they simply no longer
// get a turn.
Alive map[PlayerID]bool
// Eliminated is the order players went out, first out first. A caller that
// remembers its length can tell exactly who went out on the last input.
Eliminated []PlayerID
ChainLength int
Over bool
Winner PlayerID
EndReason EndReason
// Standings is the final table, best first. Meaningless while the game is
// in play, for the same reason Winner is.
Standings []Standing
}