mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
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:
1 parent
0b507c3537
commit
4e2e9e1433
54 files changed
+3692
-1129
No files matched your search
Generated
+534
-249
File diff suppressed because it is too large.
Load diff
@@ -61,7 +61,7 @@ func playRealGame(tb testing.TB, dict game.Dictionary, first, second Strategy, s
|
||||
// The dead end the bot walked into. The engine leaves a stuck
|
||||
// player their turn, so the harness settles it the way the room
|
||||
// settles a bot's: immediately, against the player to act.
|
||||
if !e.NoMove() {
|
||||
if !e.NoMove(time.Now()) {
|
||||
tb.Fatalf("bot had no move at %q but the engine says the position has one", e.Current())
|
||||
}
|
||||
break
|
||||
|
||||
@@ -119,7 +119,7 @@ func playGame(t *testing.T, dict game.Dictionary, first, second Strategy) game.P
|
||||
// A dead end no longer ends the game on its own: a human keeps the
|
||||
// turn and loses it to the clock. Two bots have no clock, so this
|
||||
// is where the position is settled -- exactly as the room does it.
|
||||
if !e.NoMove() {
|
||||
if !e.NoMove(time.Now()) {
|
||||
t.Fatalf("bot had no move at %q but the engine says the position has one", e.Current())
|
||||
}
|
||||
break
|
||||
|
||||
+165
-30
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -65,12 +65,6 @@ func moveRejectedMsg(reason noituv1.RejectReason, word string, turnSeq uint32) *
|
||||
}}
|
||||
}
|
||||
|
||||
func opponentLeftMsg(canReconnect bool, graceMs uint32) *noituv1.ServerMessage {
|
||||
return &noituv1.ServerMessage{Payload: &noituv1.ServerMessage_OpponentLeft{
|
||||
OpponentLeft: &noituv1.OpponentLeft{CanReconnect: canReconnect, GraceMs: graceMs},
|
||||
}}
|
||||
}
|
||||
|
||||
// errorMsg carries a UI key, never prose and never an internal error string.
|
||||
// The Vietnamese copy lives in the frontend so all wording stays in one place,
|
||||
// and a raw error would leak server internals to anyone with a socket.
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
// agree on everything they both know how to decode. Bump it when a change
|
||||
// would make an older client misread a frame rather than merely ignore an
|
||||
// unknown field.
|
||||
const ProtocolVersion uint32 = 1
|
||||
const ProtocolVersion uint32 = 2
|
||||
|
||||
// RejectReason maps an engine rejection onto the wire enum.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package wsapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
||||
)
|
||||
|
||||
// What a room of more than two people does that a pair does not: seat up to
|
||||
// four, refuse the fifth, wait for every guest before it starts, kick by name,
|
||||
// and outlive the first player it knocks out.
|
||||
|
||||
// roomOf seats n players and leaves them in the lobby. The first is the owner.
|
||||
//
|
||||
// It returns the owner's last view of the room as well as the clients: every
|
||||
// room_state the seating produced has been read by then, so a caller that
|
||||
// awaited another would wait for one nothing is going to send.
|
||||
func roomOf(t *testing.T, url string, n int) (clients []*testClient, state *noituv1.RoomState) {
|
||||
t.Helper()
|
||||
|
||||
names := []string{"Chủ phòng", "Khách", "Người thứ ba", "Người thứ tư", "Người thứ năm"}
|
||||
host := dial(t, url)
|
||||
host.hello(names[0])
|
||||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||||
state = host.await("room_state").GetRoomState()
|
||||
clients = append(clients, host)
|
||||
|
||||
for i := 1; i < n; i++ {
|
||||
c := dial(t, url)
|
||||
c.hello(names[i])
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: state.GetRoomCode()},
|
||||
}})
|
||||
clients = append(clients, c)
|
||||
// Everybody seated sees the arrival, which is also what keeps each
|
||||
// client's inbox drained before the next assertion reads from it.
|
||||
for _, seated := range clients {
|
||||
got := seated.await("room_state").GetRoomState()
|
||||
if seated == host {
|
||||
state = got
|
||||
}
|
||||
}
|
||||
}
|
||||
return clients, state
|
||||
}
|
||||
|
||||
func TestARoomSeatsFourAndRefusesTheFifth(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
_, state := roomOf(t, url, maxPlayers)
|
||||
|
||||
if got := len(state.GetPlayers()); got != maxPlayers {
|
||||
t.Fatalf("the room holds %d players, want %d", got, maxPlayers)
|
||||
}
|
||||
if state.GetMaxPlayers() != maxPlayers || state.GetMinPlayers() != minPlayers {
|
||||
t.Errorf("limits = %d/%d, want %d/%d",
|
||||
state.GetMinPlayers(), state.GetMaxPlayers(), minPlayers, maxPlayers)
|
||||
}
|
||||
// Exactly one row is the recipient's, and exactly one is the owner's.
|
||||
me, owners := 0, 0
|
||||
for _, p := range state.GetPlayers() {
|
||||
if p.GetIsMe() {
|
||||
me++
|
||||
}
|
||||
if p.GetIsOwner() {
|
||||
owners++
|
||||
}
|
||||
}
|
||||
if me != 1 || owners != 1 {
|
||||
t.Errorf("the room has %d rows marked mine and %d marked owner, want 1 of each", me, owners)
|
||||
}
|
||||
|
||||
fifth := dial(t, url)
|
||||
fifth.hello("Người thứ năm")
|
||||
fifth.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: state.GetRoomCode()},
|
||||
}})
|
||||
if got := fifth.await("error").GetError().GetCode(); got != "room_full" {
|
||||
t.Errorf("the fifth joiner got %q, want room_full", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Every guest, not merely the first: a room of four that starts on one yes has
|
||||
// dealt three people a turn they never agreed to.
|
||||
func TestStartWaitsForEveryGuest(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
clients, _ := roomOf(t, url, 3)
|
||||
host, second, third := clients[0], clients[1], clients[2]
|
||||
|
||||
second.setReady(true)
|
||||
for _, c := range clients {
|
||||
c.await("room_state")
|
||||
}
|
||||
|
||||
host.startGame()
|
||||
if got := host.await("error").GetError().GetCode(); got != "not_everyone_ready" {
|
||||
t.Fatalf("starting with one guest ready returned %q, want not_everyone_ready", got)
|
||||
}
|
||||
|
||||
third.setReady(true)
|
||||
for _, c := range clients {
|
||||
state := c.await("room_state").GetRoomState()
|
||||
if !state.GetCanStart() {
|
||||
t.Errorf("can_start is false with everybody ready: %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
host.startGame()
|
||||
for _, c := range clients {
|
||||
start := c.await("game_started").GetGameStarted()
|
||||
if got := len(start.GetPlayers()); got != 3 {
|
||||
t.Errorf("the game was dealt to %d players, want 3", got)
|
||||
}
|
||||
if start.GetTurnPlayerId() != "p1" {
|
||||
t.Errorf("turn_player_id = %q, want p1 — seat order is turn order", start.GetTurnPlayerId())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKickNamesASeat(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
clients, _ := roomOf(t, url, 3)
|
||||
host, second, third := clients[0], clients[1], clients[2]
|
||||
|
||||
// The owner cannot free their own seat: leaving is what an owner who wants
|
||||
// out does, and it hands the room on rather than dropping it.
|
||||
host.kickPlayer("p1")
|
||||
if got := host.await("error").GetError().GetCode(); got != "cannot_kick_self" {
|
||||
t.Errorf("kicking themselves returned %q, want cannot_kick_self", got)
|
||||
}
|
||||
|
||||
// Readiness is a commitment, per target rather than per room.
|
||||
second.setReady(true)
|
||||
for _, c := range clients {
|
||||
c.await("room_state")
|
||||
}
|
||||
host.kickPlayer("p2")
|
||||
if got := host.await("error").GetError().GetCode(); got != "player_is_ready" {
|
||||
t.Errorf("kicking a ready player returned %q, want player_is_ready", got)
|
||||
}
|
||||
|
||||
// The unready one goes, and the ready one is untouched.
|
||||
host.kickPlayer("p3")
|
||||
if got := third.await("error").GetError().GetCode(); got != "kicked" {
|
||||
t.Fatalf("the kicked player was told %q", got)
|
||||
}
|
||||
|
||||
state := host.await("room_state").GetRoomState()
|
||||
if got := len(state.GetPlayers()); got != 2 {
|
||||
t.Fatalf("the room holds %d players after one kick, want 2", got)
|
||||
}
|
||||
if slotFor(state, "p3") != nil {
|
||||
t.Error("the kicked seat is still occupied")
|
||||
}
|
||||
if seat := slotFor(state, "p2"); seat == nil || !seat.GetReady() {
|
||||
t.Error("kicking one player disturbed another's readiness")
|
||||
}
|
||||
}
|
||||
|
||||
// The room outlives the first player it knocks out: the rest carry on from the
|
||||
// same syllable, and the player who went out stays to watch.
|
||||
func TestAGameOutlivesItsFirstElimination(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second})
|
||||
clients, _ := roomOf(t, url, 3)
|
||||
host, second, third := clients[0], clients[1], clients[2]
|
||||
|
||||
second.setReady(true)
|
||||
third.setReady(true)
|
||||
for _, c := range clients {
|
||||
c.await("room_state")
|
||||
}
|
||||
|
||||
host.startGame()
|
||||
starts := map[*testClient]*noituv1.GameStarted{}
|
||||
for _, c := range clients {
|
||||
starts[c] = c.await("game_started").GetGameStarted()
|
||||
}
|
||||
|
||||
// The player on turn gives up. Two are left, so the game does not end.
|
||||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
||||
|
||||
for _, c := range clients {
|
||||
out := c.await("player_eliminated").GetPlayerEliminated()
|
||||
if out.GetPlayerId() != "p1" {
|
||||
t.Errorf("%q went out, want p1", out.GetPlayerId())
|
||||
}
|
||||
if got := out.GetIsMe(); got != (c == host) {
|
||||
t.Errorf("is_me = %v for the wrong recipient", got)
|
||||
}
|
||||
if c != host && len(out.GetSuggestions()) != 0 {
|
||||
t.Errorf("a player who is still in was sent suggestions %v", out.GetSuggestions())
|
||||
}
|
||||
}
|
||||
|
||||
// A turn update with no word: the position survived the player who left it.
|
||||
update := second.await("turn_update").GetTurnUpdate()
|
||||
if update.GetPlayed() != nil {
|
||||
t.Error("an elimination reported a word as played")
|
||||
}
|
||||
if update.GetCurrentSyllable() != starts[second].GetCurrentSyllable() {
|
||||
t.Errorf("the syllable moved on an elimination: %q -> %q",
|
||||
starts[second].GetCurrentSyllable(), update.GetCurrentSyllable())
|
||||
}
|
||||
if update.GetTurnPlayerId() != "p2" || !update.GetMyTurn() {
|
||||
t.Errorf("turn went to %q, want p2", update.GetTurnPlayerId())
|
||||
}
|
||||
if update.GetTurnSeq() == starts[second].GetTurnSeq() {
|
||||
t.Error("the turn sequence did not move, so a stale submission could still land")
|
||||
}
|
||||
|
||||
// The player who went out is still in the room and still being told what
|
||||
// is happening in it.
|
||||
watching := host.await("turn_update").GetTurnUpdate()
|
||||
if watching.GetMyTurn() {
|
||||
t.Error("an eliminated player was dealt a turn")
|
||||
}
|
||||
var eliminated bool
|
||||
for _, p := range watching.GetPlayers() {
|
||||
if p.GetPlayerId() == "p1" {
|
||||
eliminated = p.GetEliminated()
|
||||
}
|
||||
}
|
||||
if !eliminated {
|
||||
t.Error("the table does not show the eliminated player as out")
|
||||
}
|
||||
|
||||
// And they can still talk, which is the other half of staying in the room.
|
||||
host.say("chúc may mắn")
|
||||
if got := second.await("chat_message").GetChatMessage().GetText(); got != "chúc may mắn" {
|
||||
t.Errorf("an eliminated player's message arrived as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The last elimination ends it, and everybody is shown the same table from
|
||||
// their own side.
|
||||
func TestStandingsReachEverySeat(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second})
|
||||
clients, _ := roomOf(t, url, 3)
|
||||
host, second, third := clients[0], clients[1], clients[2]
|
||||
|
||||
second.setReady(true)
|
||||
third.setReady(true)
|
||||
for _, c := range clients {
|
||||
c.await("room_state")
|
||||
}
|
||||
host.startGame()
|
||||
for _, c := range clients {
|
||||
c.await("game_started")
|
||||
}
|
||||
|
||||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
||||
for _, c := range clients {
|
||||
c.await("player_eliminated")
|
||||
}
|
||||
second.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
||||
|
||||
winners := 0
|
||||
for _, c := range clients {
|
||||
over := c.await("game_over").GetGameOver()
|
||||
if over.GetIWon() {
|
||||
winners++
|
||||
}
|
||||
|
||||
standings := over.GetStandings()
|
||||
if len(standings) != 3 {
|
||||
t.Fatalf("standings has %d rows, want 3", len(standings))
|
||||
}
|
||||
// Finishing order: the survivor, then the players who went out, latest
|
||||
// first. Rank matches position, so the two cannot drift apart.
|
||||
want := []string{"p3", "p2", "p1"}
|
||||
mine := 0
|
||||
for i, row := range standings {
|
||||
if row.GetPlayerId() != want[i] {
|
||||
t.Errorf("rank %d is %q, want %q", i+1, row.GetPlayerId(), want[i])
|
||||
}
|
||||
if int(row.GetRank()) != i+1 {
|
||||
t.Errorf("%q has rank %d at position %d", row.GetPlayerId(), row.GetRank(), i+1)
|
||||
}
|
||||
if row.GetIsMe() {
|
||||
mine++
|
||||
}
|
||||
}
|
||||
if mine != 1 {
|
||||
t.Errorf("%d standings rows are marked mine, want 1", mine)
|
||||
}
|
||||
}
|
||||
if winners != 1 {
|
||||
t.Errorf("%d players were told they won, want 1", winners)
|
||||
}
|
||||
}
|
||||
|
||||
// Two people dropping at once is two windows, not one. The room has to hold
|
||||
// both seats and settle each on its own deadline.
|
||||
func TestSeveralReconnectWindowsRunAtOnce(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{GraceFor: 300 * time.Millisecond})
|
||||
clients, _ := roomOf(t, url, 3)
|
||||
host, second, third := clients[0], clients[1], clients[2]
|
||||
|
||||
second.conn.CloseNow()
|
||||
third.conn.CloseNow()
|
||||
|
||||
// Both seats are held first, then both are freed. The owner is the one
|
||||
// still here to watch it happen.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
state := host.await("room_state").GetRoomState()
|
||||
if len(state.GetPlayers()) == 1 {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("the room never freed the seats whose windows had run out")
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package wsapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
@@ -110,16 +112,25 @@ func capMarks(s string, maxMarks int) string {
|
||||
// defaultNickname is what an unusable name falls back to.
|
||||
const defaultNickname = "Người chơi"
|
||||
|
||||
// distinguish returns a name for the joining player that their opponent cannot
|
||||
// be confused with. Nicknames are the only way to tell two strangers apart, so
|
||||
// letting both sides render the same string defeats the point of having them.
|
||||
func distinguish(name, taken string) string {
|
||||
if name != taken {
|
||||
// distinguish returns a name the joining player cannot be confused with any of
|
||||
// the ones already in the room. Nicknames are the only way to tell strangers
|
||||
// apart, so letting two of them render the same string defeats the point of
|
||||
// having names at all.
|
||||
//
|
||||
// The counter is bounded by the room: taken holds at most one name per seat, so
|
||||
// a free suffix is always found within that many tries.
|
||||
func distinguish(name string, taken []string) string {
|
||||
if !slices.Contains(taken, name) {
|
||||
return name
|
||||
}
|
||||
suffix := " 2"
|
||||
if runes := []rune(name); len(runes)+len(suffix) > maxNicknameRunes {
|
||||
name = strings.TrimSpace(string(runes[:maxNicknameRunes-len(suffix)]))
|
||||
for n := 2; ; n++ {
|
||||
suffix := fmt.Sprintf(" %d", n)
|
||||
trimmed := name
|
||||
if runes := []rune(name); len(runes)+len(suffix) > maxNicknameRunes {
|
||||
trimmed = strings.TrimSpace(string(runes[:maxNicknameRunes-len(suffix)]))
|
||||
}
|
||||
if candidate := trimmed + suffix; !slices.Contains(taken, candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return name + suffix
|
||||
}
|
||||
@@ -109,8 +109,11 @@ func TestStrangerCannotResignForASeatedPlayer(t *testing.T) {
|
||||
stranger.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
if got := stranger.await("error").GetError().GetCode(); got != "room_full" {
|
||||
t.Fatalf("third joiner got %q, want room_full", got)
|
||||
// Not room_full: a four-seat room with two people in it has seats going
|
||||
// spare. Arriving in the middle of a game is what is refused, and a
|
||||
// stranger holding the code is refused it like anybody else.
|
||||
if got := stranger.await("error").GetError().GetCode(); got != "game_in_progress" {
|
||||
t.Fatalf("third joiner got %q, want game_in_progress", got)
|
||||
}
|
||||
|
||||
stranger.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
||||
@@ -244,6 +247,15 @@ func TestPvPGameRunsToAWinner(t *testing.T) {
|
||||
hostTurn := awaitMyTurn(t, host)
|
||||
host.submit("d e", hostTurn.GetTurnSeq())
|
||||
|
||||
// The elimination comes first and carries what the position had left, so
|
||||
// it has to be read before the result it caused.
|
||||
guestOut := guest.await("player_eliminated").GetPlayerEliminated()
|
||||
hostOut := host.await("player_eliminated").GetPlayerEliminated()
|
||||
|
||||
if !guestOut.GetIsMe() || hostOut.GetIsMe() {
|
||||
t.Error("is_me should be true only for the player who went out")
|
||||
}
|
||||
|
||||
hostOver := host.await("game_over").GetGameOver()
|
||||
guestOver := guest.await("game_over").GetGameOver()
|
||||
|
||||
@@ -258,12 +270,14 @@ func TestPvPGameRunsToAWinner(t *testing.T) {
|
||||
t.Errorf("reason = %v, want NO_LEGAL_MOVE", hostOver.GetReason())
|
||||
}
|
||||
|
||||
// Nothing could have been played, and the empty list is how the loser is
|
||||
// told so. The winner is never sent one.
|
||||
if got := guestOver.GetSuggestions(); len(got) != 0 {
|
||||
// Nothing could have been played, and the empty list is how the player who
|
||||
// was stuck is told so. It rides on the elimination rather than the
|
||||
// result: it describes the position they were looking at, which by the end
|
||||
// of a longer game is nobody else's position.
|
||||
if got := guestOut.GetSuggestions(); len(got) != 0 {
|
||||
t.Errorf("the losing player was offered %v out of a dead end, want nothing", got)
|
||||
}
|
||||
if got := hostOver.GetSuggestions(); len(got) != 0 {
|
||||
if got := hostOut.GetSuggestions(); len(got) != 0 {
|
||||
t.Errorf("the winner was sent suggestions %v, want none", got)
|
||||
}
|
||||
}
|
||||
@@ -278,16 +292,18 @@ func TestLosingPlayerIsToldWhatCouldHaveBeenPlayed(t *testing.T) {
|
||||
// The opening is "a b", so "b c" is still there to be played.
|
||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
||||
|
||||
guestOut := guest.await("player_eliminated").GetPlayerEliminated()
|
||||
hostOut := host.await("player_eliminated").GetPlayerEliminated()
|
||||
|
||||
guestOver := guest.await("game_over").GetGameOver()
|
||||
hostOver := host.await("game_over").GetGameOver()
|
||||
|
||||
if guestOver.GetIWon() {
|
||||
t.Fatal("the player who resigned was told they won")
|
||||
}
|
||||
if got := guestOver.GetSuggestions(); !slices.Equal(got, []string{"b c"}) {
|
||||
if got := guestOut.GetSuggestions(); !slices.Equal(got, []string{"b c"}) {
|
||||
t.Errorf("suggestions = %v, want [b c]", got)
|
||||
}
|
||||
if got := hostOver.GetSuggestions(); len(got) != 0 {
|
||||
if got := hostOut.GetSuggestions(); len(got) != 0 {
|
||||
t.Errorf("the winner was sent suggestions %v, want none", got)
|
||||
}
|
||||
}
|
||||
@@ -325,7 +341,7 @@ func TestOpponentNeverSeesAnUnsanitizedNickname(t *testing.T) {
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
|
||||
shown := host.await("room_state").GetRoomState().GetOpponentName()
|
||||
shown := otherSlot(host.await("room_state").GetRoomState()).GetName()
|
||||
for _, r := range []rune{nul, zeroWidthSpace, bidiOverride} {
|
||||
if strings.ContainsRune(shown, r) {
|
||||
t.Errorf("opponent name %q still carries the invisible rune %U", shown, r)
|
||||
@@ -412,7 +428,7 @@ func TestResumeAfterGameEndedLandsInTheLobby(t *testing.T) {
|
||||
if state.GetRoomCode() != code {
|
||||
t.Errorf("resumed into room %q, want %q", state.GetRoomCode(), code)
|
||||
}
|
||||
if !state.GetOpponentPresent() {
|
||||
if otherSlot(state) == nil {
|
||||
t.Errorf("the player who stayed is missing from the resumed lobby: %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
+462
-256
File diff suppressed because it is too large.
Load diff
@@ -460,7 +460,7 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error {
|
||||
s.toRoom(lobbyInput{sess: s, action: lobbyStart})
|
||||
|
||||
case *noituv1.ClientMessage_KickPlayer:
|
||||
s.toRoom(lobbyInput{sess: s, action: lobbyKick})
|
||||
s.toRoom(lobbyInput{sess: s, action: lobbyKick, target: playerIDFor(p.KickPlayer.GetPlayerId())})
|
||||
|
||||
case *noituv1.ClientMessage_LeaveRoom:
|
||||
s.toRoom(lobbyInput{sess: s, action: lobbyLeave})
|
||||
@@ -492,8 +492,8 @@ func (s *session) dispatch(msg *noituv1.ClientMessage) error {
|
||||
|
||||
// toRoom forwards one lobby action to the room this connection is seated in.
|
||||
//
|
||||
// Rate-limited like a submission: every accepted action is broadcast to both
|
||||
// seats, so an unbounded one lets a player flood the other's outbox until
|
||||
// Rate-limited like a submission: every accepted action is broadcast to every
|
||||
// seat, so an unbounded one lets a player flood the other's outbox until
|
||||
// their session is closed for falling behind. A dropped action would leave a
|
||||
// button that did nothing and no reason why, so every failure answers.
|
||||
func (s *session) toRoom(in lobbyInput) {
|
||||
|
||||
@@ -51,7 +51,9 @@ func clientVariants() map[string]*noituv1.ClientMessage {
|
||||
Ready: true,
|
||||
}}},
|
||||
"client_start_game": {Payload: &noituv1.ClientMessage_StartGame{StartGame: &noituv1.StartGame{}}},
|
||||
"client_kick_player": {Payload: &noituv1.ClientMessage_KickPlayer{KickPlayer: &noituv1.KickPlayer{}}},
|
||||
"client_kick_player": {Payload: &noituv1.ClientMessage_KickPlayer{KickPlayer: &noituv1.KickPlayer{
|
||||
PlayerId: "p3",
|
||||
}}},
|
||||
"client_leave_room": {Payload: &noituv1.ClientMessage_LeaveRoom{LeaveRoom: &noituv1.LeaveRoom{}}},
|
||||
"client_send_chat": {Payload: &noituv1.ClientMessage_SendChat{SendChat: &noituv1.SendChat{
|
||||
Text: "Chào bạn, ván này khó thật!",
|
||||
@@ -65,7 +67,7 @@ func serverVariants() map[string]*noituv1.ServerMessage {
|
||||
"server_welcome": {Payload: &noituv1.ServerMessage_Welcome{Welcome: &noituv1.Welcome{
|
||||
SessionId: "s-1a2b3c",
|
||||
ResumeToken: "r-8f2c",
|
||||
ProtocolVersion: 1,
|
||||
ProtocolVersion: ProtocolVersion,
|
||||
AcceptedNickname: "Người chơi ẩn danh",
|
||||
}}},
|
||||
"server_game_started": {Payload: &noituv1.ServerMessage_GameStarted{GameStarted: &noituv1.GameStarted{
|
||||
@@ -75,6 +77,8 @@ func serverVariants() map[string]*noituv1.ServerMessage {
|
||||
DeadlineUnixMs: 1756998020000,
|
||||
TurnSeq: 1,
|
||||
TurnLimitMs: 20000,
|
||||
Players: fixtureScores(),
|
||||
TurnPlayerId: "p1",
|
||||
}}},
|
||||
"server_turn_update": {Payload: &noituv1.ServerMessage_TurnUpdate{TurnUpdate: &noituv1.TurnUpdate{
|
||||
Played: &noituv1.PlayedWord{
|
||||
@@ -83,14 +87,15 @@ func serverVariants() map[string]*noituv1.ServerMessage {
|
||||
ByMe: false,
|
||||
Points: 2,
|
||||
Syllables: 2,
|
||||
PlayerId: "p2",
|
||||
},
|
||||
CurrentSyllable: "yên",
|
||||
MyTurn: true,
|
||||
DeadlineUnixMs: 1756998040000,
|
||||
TurnSeq: 2,
|
||||
MyScore: 3,
|
||||
OpponentScore: 5,
|
||||
ChainLength: 2,
|
||||
Players: fixtureScores(),
|
||||
TurnPlayerId: "p3",
|
||||
}}},
|
||||
"server_move_rejected": {Payload: &noituv1.ServerMessage_MoveRejected{MoveRejected: &noituv1.MoveRejected{
|
||||
Reason: noituv1.RejectReason_REJECT_REASON_WRONG_LINK,
|
||||
@@ -100,16 +105,26 @@ func serverVariants() map[string]*noituv1.ServerMessage {
|
||||
"server_game_over": {Payload: &noituv1.ServerMessage_GameOver{GameOver: &noituv1.GameOver{
|
||||
IWon: false,
|
||||
Reason: noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE,
|
||||
MyScore: 7,
|
||||
ChainLength: 11,
|
||||
// A repeated string of Vietnamese words: the one field in the
|
||||
// contract whose encoding is neither a scalar nor a submessage.
|
||||
Suggestions: []string{"sinh viên", "sinh sôi"},
|
||||
}}},
|
||||
"server_opponent_left": {Payload: &noituv1.ServerMessage_OpponentLeft{OpponentLeft: &noituv1.OpponentLeft{
|
||||
CanReconnect: true,
|
||||
GraceMs: 30000,
|
||||
// Ranked, so a fixture that quietly reordered the table would show
|
||||
// up as ranks that no longer match their positions.
|
||||
Standings: []*noituv1.PlayerScore{
|
||||
{PlayerId: "p2", Name: "Khách mời", Score: 42, Connected: true, Rank: 1},
|
||||
{PlayerId: "p1", Name: "Người chơi", IsMe: true, Score: 7, Eliminated: true, Connected: true, Rank: 2},
|
||||
{PlayerId: "p3", Name: "Khách 2", Score: 3, Eliminated: true, Rank: 3},
|
||||
},
|
||||
}}},
|
||||
"server_player_eliminated": {Payload: &noituv1.ServerMessage_PlayerEliminated{
|
||||
PlayerEliminated: &noituv1.PlayerEliminated{
|
||||
PlayerId: "p1",
|
||||
Name: "Người chơi",
|
||||
IsMe: true,
|
||||
Reason: noituv1.GameEndReason_GAME_END_REASON_TIMEOUT,
|
||||
// A repeated string of Vietnamese words: the one field in the
|
||||
// contract whose encoding is neither a scalar nor a submessage.
|
||||
Suggestions: []string{"sinh viên", "sinh sôi"},
|
||||
},
|
||||
}},
|
||||
"server_error": {Payload: &noituv1.ServerMessage_Error{Error: &noituv1.ServerError{
|
||||
Code: "room_not_found",
|
||||
Message: "room_not_found",
|
||||
@@ -137,21 +152,35 @@ func serverVariants() map[string]*noituv1.ServerMessage {
|
||||
{FromMe: false, Author: "", Text: "Tôi phải đi", SentUnixMs: 1756998000789},
|
||||
},
|
||||
}}},
|
||||
// An owner looking at two guests, one ready and here and one ready but
|
||||
// away. Every boolean in a slot is load-bearing in at least one row,
|
||||
// and no two rows agree on all of them.
|
||||
"server_room_state": {Payload: &noituv1.ServerMessage_RoomState{RoomState: &noituv1.RoomState{
|
||||
RoomCode: "K7QX",
|
||||
// An owner looking at a guest who is here, ready, and connected:
|
||||
// the one combination in which every boolean is load-bearing.
|
||||
IAmOwner: true,
|
||||
CanStart: true,
|
||||
IAmReady: false,
|
||||
OpponentPresent: true,
|
||||
OpponentName: "Khách mời",
|
||||
OpponentReady: true,
|
||||
OpponentConnected: true,
|
||||
RoomCode: "K7QX",
|
||||
CanStart: true,
|
||||
MaxPlayers: 4,
|
||||
MinPlayers: 2,
|
||||
GraceMs: 30000,
|
||||
Players: []*noituv1.PlayerSlot{
|
||||
{PlayerId: "p1", Name: "Người chơi", IsMe: true, IsOwner: true, Connected: true},
|
||||
{PlayerId: "p2", Name: "Khách mời", Ready: true, Connected: true},
|
||||
{PlayerId: "p3", Name: "Khách 2", Ready: true},
|
||||
},
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
// fixtureScores is the players table both in-game messages carry. Three seats
|
||||
// in three different states, so a fixture that dropped or reordered a row
|
||||
// cannot still decode to something plausible.
|
||||
func fixtureScores() []*noituv1.PlayerScore {
|
||||
return []*noituv1.PlayerScore{
|
||||
{PlayerId: "p1", Name: "Người chơi", IsMe: true, Score: 7, Connected: true},
|
||||
{PlayerId: "p2", Name: "Khách mời", Score: 42, Eliminated: true, Connected: true},
|
||||
{PlayerId: "p3", Name: "Khách 2", Score: 3},
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoundTripEveryVariant marshals and unmarshals each oneof arm. Equality
|
||||
// alone is not enough: an empty arm such as CreateRoom encodes to a payload of
|
||||
// zero bytes, so checking that the case survived is what proves the arm is
|
||||
|
||||
@@ -186,6 +186,59 @@ func (c *testClient) await(want string) *noituv1.ServerMessage {
|
||||
return nil
|
||||
}
|
||||
|
||||
// mySlot is the recipient's own row in a RoomState, which is the only place
|
||||
// their role and their readiness live now.
|
||||
func mySlot(state *noituv1.RoomState) *noituv1.PlayerSlot {
|
||||
for _, p := range state.GetPlayers() {
|
||||
if p.GetIsMe() {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// otherSlot is the one other player in a two-player room, or nil when the
|
||||
// recipient is alone in it. Most of the lobby tests below are about two
|
||||
// people, so this is the shape they read the list in.
|
||||
func otherSlot(state *noituv1.RoomState) *noituv1.PlayerSlot {
|
||||
for _, p := range state.GetPlayers() {
|
||||
if !p.GetIsMe() {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// slotFor finds one named seat, for the tests that seat more than two.
|
||||
func slotFor(state *noituv1.RoomState, id string) *noituv1.PlayerSlot {
|
||||
for _, p := range state.GetPlayers() {
|
||||
if p.GetPlayerId() == id {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// myScore and otherScore read a two-player turn update the way it used to
|
||||
// carry the numbers, out of the table that replaced the two fields.
|
||||
func myScore(u *noituv1.TurnUpdate) uint32 {
|
||||
for _, p := range u.GetPlayers() {
|
||||
if p.GetIsMe() {
|
||||
return p.GetScore()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func otherScore(u *noituv1.TurnUpdate) uint32 {
|
||||
for _, p := range u.GetPlayers() {
|
||||
if !p.GetIsMe() {
|
||||
return p.GetScore()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func payloadCase(m *noituv1.ServerMessage) string {
|
||||
switch m.GetPayload().(type) {
|
||||
case *noituv1.ServerMessage_Welcome:
|
||||
@@ -198,8 +251,8 @@ func payloadCase(m *noituv1.ServerMessage) string {
|
||||
return "move_rejected"
|
||||
case *noituv1.ServerMessage_GameOver:
|
||||
return "game_over"
|
||||
case *noituv1.ServerMessage_OpponentLeft:
|
||||
return "opponent_left"
|
||||
case *noituv1.ServerMessage_PlayerEliminated:
|
||||
return "player_eliminated"
|
||||
case *noituv1.ServerMessage_Error:
|
||||
return "error"
|
||||
case *noituv1.ServerMessage_Pong:
|
||||
@@ -326,9 +379,9 @@ func TestPvPGameAlternatesTurns(t *testing.T) {
|
||||
if !guestUpdate.GetMyTurn() {
|
||||
t.Error("opponent should now be on turn")
|
||||
}
|
||||
if hostUpdate.GetMyScore() != guestUpdate.GetOpponentScore() {
|
||||
if myScore(hostUpdate) != otherScore(guestUpdate) {
|
||||
t.Errorf("scores disagree across recipients: %d vs %d",
|
||||
hostUpdate.GetMyScore(), guestUpdate.GetOpponentScore())
|
||||
myScore(hostUpdate), otherScore(guestUpdate))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,8 +527,11 @@ func TestResumeWithinGraceRestoresGame(t *testing.T) {
|
||||
|
||||
_ = host.conn.Close(websocket.StatusGoingAway, "")
|
||||
|
||||
if left := guest.await("opponent_left").GetOpponentLeft(); !left.GetCanReconnect() {
|
||||
t.Error("opponent should be told the seat is being held")
|
||||
// Presence is part of the room's state now, so the seat being held is
|
||||
// something the other player reads there rather than in a message of its
|
||||
// own.
|
||||
if away := otherSlot(guest.await("room_state").GetRoomState()); away == nil || away.GetConnected() {
|
||||
t.Error("opponent should be shown as away while the seat is held")
|
||||
}
|
||||
|
||||
// Reconnect with the token and expect the position back.
|
||||
@@ -797,19 +853,31 @@ func TestSanitizeNicknameCountsRunesNotBytes(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestDistinguishSeparatesIdenticalNames covers the collision the fallback
|
||||
// creates — two players who both send nothing — and the one it always could:
|
||||
// two players who choose the same name.
|
||||
// creates — players who all send nothing — and the one it always could:
|
||||
// players who choose the same name.
|
||||
func TestDistinguishSeparatesIdenticalNames(t *testing.T) {
|
||||
if got := distinguish(defaultNickname, defaultNickname); got == defaultNickname {
|
||||
t.Error("two unnamed players must not render identically")
|
||||
}
|
||||
if got := distinguish("Minh", "Thuý"); got != "Minh" {
|
||||
if got := distinguish("Minh", []string{"Thuý"}); got != "Minh" {
|
||||
t.Errorf("distinct names should be left alone, got %q", got)
|
||||
}
|
||||
|
||||
// The suffix must not push the name past the cap.
|
||||
// A whole room of unnamed players, seated one at a time. Every one of them
|
||||
// has to end up with a name none of the others is already using: two
|
||||
// suffixed identically is the same failure as two unsuffixed.
|
||||
var taken []string
|
||||
for range maxPlayers {
|
||||
got := distinguish(defaultNickname, taken)
|
||||
if slices.Contains(taken, got) {
|
||||
t.Fatalf("distinguish returned %q, which is already in %v", got, taken)
|
||||
}
|
||||
if n := len([]rune(got)); n > maxNicknameRunes {
|
||||
t.Errorf("distinguished name %q is %d runes, over the %d cap", got, n, maxNicknameRunes)
|
||||
}
|
||||
taken = append(taken, got)
|
||||
}
|
||||
|
||||
// The suffix must not push a name that is already at the cap past it.
|
||||
long := strings.Repeat("a", maxNicknameRunes)
|
||||
if got := distinguish(long, long); len([]rune(got)) > maxNicknameRunes {
|
||||
if got := distinguish(long, []string{long}); len([]rune(got)) > maxNicknameRunes {
|
||||
t.Errorf("distinguished name is %d runes, over the %d cap", len([]rune(got)), maxNicknameRunes)
|
||||
}
|
||||
}
|
||||
@@ -988,9 +1056,11 @@ func (c *testClient) startGame() {
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_StartGame{StartGame: &noituv1.StartGame{}}})
|
||||
}
|
||||
|
||||
func (c *testClient) kickPlayer() {
|
||||
func (c *testClient) kickPlayer(target string) {
|
||||
c.t.Helper()
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_KickPlayer{KickPlayer: &noituv1.KickPlayer{}}})
|
||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_KickPlayer{
|
||||
KickPlayer: &noituv1.KickPlayer{PlayerId: target},
|
||||
}})
|
||||
}
|
||||
|
||||
func (c *testClient) say(text string) {
|
||||
@@ -1037,16 +1107,16 @@ func TestLobbyOpensWithNobodyReady(t *testing.T) {
|
||||
hostState := host.await("room_state").GetRoomState()
|
||||
guestState := guest.await("room_state").GetRoomState()
|
||||
|
||||
if !hostState.GetIAmOwner() {
|
||||
if !mySlot(hostState).GetIsOwner() {
|
||||
t.Error("the player who created the room does not own it")
|
||||
}
|
||||
if guestState.GetIAmOwner() {
|
||||
if mySlot(guestState).GetIsOwner() {
|
||||
t.Error("the player who joined was made owner")
|
||||
}
|
||||
if hostState.GetCanStart() || guestState.GetCanStart() {
|
||||
t.Error("a game can start with nobody ready")
|
||||
}
|
||||
if !hostState.GetOpponentPresent() || hostState.GetOpponentName() == "" {
|
||||
if otherSlot(hostState) == nil || otherSlot(hostState).GetName() == "" {
|
||||
t.Errorf("the owner cannot see who joined: %+v", hostState)
|
||||
}
|
||||
// Nothing starts on its own. Joining used to be the start signal, and a
|
||||
@@ -1065,10 +1135,10 @@ func TestReadyIsRenderedPerRecipient(t *testing.T) {
|
||||
guestState := guest.await("room_state").GetRoomState()
|
||||
hostState := host.await("room_state").GetRoomState()
|
||||
|
||||
if !guestState.GetIAmReady() || guestState.GetOpponentReady() {
|
||||
if !mySlot(guestState).GetReady() || otherSlot(guestState).GetReady() {
|
||||
t.Errorf("the guest should see only their own readiness, got %+v", guestState)
|
||||
}
|
||||
if hostState.GetIAmReady() || !hostState.GetOpponentReady() {
|
||||
if mySlot(hostState).GetReady() || !otherSlot(hostState).GetReady() {
|
||||
t.Errorf("the owner should see only the guest's readiness, got %+v", hostState)
|
||||
}
|
||||
if !hostState.GetCanStart() {
|
||||
@@ -1101,8 +1171,8 @@ func TestStartIsRefusedUntilTheGuestIsReady(t *testing.T) {
|
||||
|
||||
// Alone in the room.
|
||||
host.startGame()
|
||||
if got := host.await("error").GetError().GetCode(); got != "need_two_players" {
|
||||
t.Errorf("starting alone returned %q, want need_two_players", got)
|
||||
if got := host.await("error").GetError().GetCode(); got != "need_more_players" {
|
||||
t.Errorf("starting alone returned %q, want need_more_players", got)
|
||||
}
|
||||
|
||||
guest := dial(t, url)
|
||||
@@ -1141,7 +1211,7 @@ func TestOnlyTheOwnerStartsAndKicks(t *testing.T) {
|
||||
if got := guest.await("error").GetError().GetCode(); got != "not_the_owner" {
|
||||
t.Errorf("a guest starting the game returned %q, want not_the_owner", got)
|
||||
}
|
||||
guest.kickPlayer()
|
||||
guest.kickPlayer("p1")
|
||||
if got := guest.await("error").GetError().GetCode(); got != "not_the_owner" {
|
||||
t.Errorf("a guest kicking returned %q, want not_the_owner", got)
|
||||
}
|
||||
@@ -1155,7 +1225,7 @@ func TestNextGameNeedsAFreshReady(t *testing.T) {
|
||||
host, guest, first := pvpRoom(t, url)
|
||||
|
||||
hostState, guestState := resignAndSettle(t, host, guest)
|
||||
if hostState.GetOpponentReady() || guestState.GetIAmReady() {
|
||||
if otherSlot(hostState).GetReady() || mySlot(guestState).GetReady() {
|
||||
t.Error("the readiness that started the last game survived it")
|
||||
}
|
||||
if hostState.GetCanStart() {
|
||||
@@ -1194,7 +1264,7 @@ func TestLobbyActionsAreRefusedDuringAGame(t *testing.T) {
|
||||
if got := guest.await("error").GetError().GetCode(); got != "game_in_progress" {
|
||||
t.Errorf("unreadying mid-game returned %q, want game_in_progress", got)
|
||||
}
|
||||
host.kickPlayer()
|
||||
host.kickPlayer("p2")
|
||||
if got := host.await("error").GetError().GetCode(); got != "game_in_progress" {
|
||||
t.Errorf("kicking mid-game returned %q, want game_in_progress", got)
|
||||
}
|
||||
@@ -1226,10 +1296,10 @@ func TestLeavingNeedsAnUnreadyFirst(t *testing.T) {
|
||||
|
||||
// The room survives: the owner is still in it, now on their own.
|
||||
alone := host.await("room_state").GetRoomState()
|
||||
if alone.GetOpponentPresent() {
|
||||
if otherSlot(alone) != nil {
|
||||
t.Errorf("the owner still sees a guest who left: %+v", alone)
|
||||
}
|
||||
if !alone.GetIAmOwner() || alone.GetCanStart() {
|
||||
if !mySlot(alone).GetIsOwner() || alone.GetCanStart() {
|
||||
t.Errorf("the room the owner is left with is wrong: %+v", alone)
|
||||
}
|
||||
}
|
||||
@@ -1242,19 +1312,19 @@ func TestKickFreesAnUnreadySeatOnly(t *testing.T) {
|
||||
|
||||
guest.setReady(true)
|
||||
host.await("room_state")
|
||||
host.kickPlayer()
|
||||
host.kickPlayer("p2")
|
||||
if got := host.await("error").GetError().GetCode(); got != "player_is_ready" {
|
||||
t.Errorf("kicking a ready guest returned %q, want player_is_ready", got)
|
||||
}
|
||||
|
||||
guest.setReady(false)
|
||||
host.await("room_state")
|
||||
host.kickPlayer()
|
||||
host.kickPlayer("p2")
|
||||
|
||||
if got := guest.await("error").GetError().GetCode(); got != "kicked" {
|
||||
t.Errorf("the kicked player was told %q", got)
|
||||
}
|
||||
if got := host.await("room_state").GetRoomState(); got.GetOpponentPresent() {
|
||||
if got := host.await("room_state").GetRoomState(); otherSlot(got) != nil {
|
||||
t.Errorf("the kicked seat is still occupied: %+v", got)
|
||||
}
|
||||
|
||||
@@ -1262,7 +1332,7 @@ func TestKickFreesAnUnreadySeatOnly(t *testing.T) {
|
||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||
}})
|
||||
if got := guest.await("room_state").GetRoomState(); !got.GetOpponentPresent() {
|
||||
if got := guest.await("room_state").GetRoomState(); otherSlot(got) == nil {
|
||||
t.Errorf("a kicked player could not come back: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -1276,10 +1346,10 @@ func TestOwnerLeavingPromotesTheOtherPlayer(t *testing.T) {
|
||||
host.leaveRoom()
|
||||
|
||||
promoted := guest.await("room_state").GetRoomState()
|
||||
if !promoted.GetIAmOwner() {
|
||||
if !mySlot(promoted).GetIsOwner() {
|
||||
t.Errorf("the player left behind was not promoted: %+v", promoted)
|
||||
}
|
||||
if promoted.GetOpponentPresent() {
|
||||
if otherSlot(promoted) != nil {
|
||||
t.Errorf("the owner who left is still shown as present: %+v", promoted)
|
||||
}
|
||||
|
||||
@@ -1314,7 +1384,7 @@ func TestPromotedOwnerLosesTheirReadiness(t *testing.T) {
|
||||
host.leaveRoom()
|
||||
|
||||
promoted := guest.await("room_state").GetRoomState()
|
||||
if promoted.GetIAmReady() {
|
||||
if mySlot(promoted).GetReady() {
|
||||
t.Errorf("the promoted owner is still carrying a guest's readiness: %+v", promoted)
|
||||
}
|
||||
}
|
||||
@@ -1563,7 +1633,7 @@ func TestChatHistoryIsCappedAndOrdered(t *testing.T) {
|
||||
sess := offlineSession(t, chatHistoryLimit+64)
|
||||
r := &room{
|
||||
code: "TESTRM",
|
||||
seats: [2]*seat{{id: "p1", nickname: "Chủ phòng", sess: sess}},
|
||||
seats: [maxPlayers]*seat{{id: "p1", nickname: "Chủ phòng", sess: sess}},
|
||||
owner: "p1",
|
||||
}
|
||||
|
||||
@@ -1716,7 +1786,7 @@ func TestChatFromASeatlessConnectionIsRefused(t *testing.T) {
|
||||
_, url := newTestServer(t, chainDict(), Config{})
|
||||
host, guest, _ := pvpLobby(t, url)
|
||||
|
||||
host.kickPlayer()
|
||||
host.kickPlayer("p2")
|
||||
if got := guest.await("error").GetError().GetCode(); got != "kicked" {
|
||||
t.Fatalf("the guest was told %q rather than being kicked", got)
|
||||
}
|
||||
@@ -1889,7 +1959,7 @@ func TestChatFromAConnectionThatLostItsSeatIsRefused(t *testing.T) {
|
||||
|
||||
r := &room{
|
||||
code: "TESTRM",
|
||||
seats: [2]*seat{{id: "p1", nickname: "Chủ phòng", sess: evicted}},
|
||||
seats: [maxPlayers]*seat{{id: "p1", nickname: "Chủ phòng", sess: evicted}},
|
||||
owner: "p1",
|
||||
}
|
||||
|
||||
@@ -1925,7 +1995,7 @@ func TestChatToAFullOutboxIsDroppedNotFatal(t *testing.T) {
|
||||
|
||||
r := &room{
|
||||
code: "TESTRM",
|
||||
seats: [2]*seat{
|
||||
seats: [maxPlayers]*seat{
|
||||
{id: "p1", nickname: "Chủ phòng", sess: sender},
|
||||
{id: "p2", nickname: "Khách", sess: slow},
|
||||
},
|
||||
@@ -2002,10 +2072,10 @@ func TestResumeReclaimsALobbySeat(t *testing.T) {
|
||||
second.await("welcome")
|
||||
|
||||
back := second.await("room_state").GetRoomState()
|
||||
if !back.GetIAmOwner() {
|
||||
if !mySlot(back).GetIsOwner() {
|
||||
t.Errorf("the owner came back as a guest: %+v", back)
|
||||
}
|
||||
if !back.GetOpponentPresent() || back.GetRoomCode() != code {
|
||||
if otherSlot(back) == nil || back.GetRoomCode() != code {
|
||||
t.Errorf("the resumed lobby is not the one they left: %+v", back)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user