mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
feat(online): draw who takes the first turn
Opening the game is an advantage — the first player picks from a whole syllable, everyone after them plays what is left — and it went to whoever created the room, making them favourite in every game of a series. The seat list is rotated by a random offset before the engine sees it, so everybody still plays in the order they sat down and only the starting point moves. A bot room keeps the human opening.
This commit is contained in:
1 parent
a72af1f0ad
commit
49f14a1f5a
7 files changed
+351
-148
No files matched your search
@@ -106,14 +106,27 @@ func TestStartWaitsForEveryGuest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
host.startGame()
|
host.startGame()
|
||||||
|
// Who leads is drawn, so what has to hold is that the room agrees on one
|
||||||
|
// leader and that exactly one player was dealt the turn.
|
||||||
|
lead, onTurn := "", 0
|
||||||
for _, c := range clients {
|
for _, c := range clients {
|
||||||
start := c.await("game_started").GetGameStarted()
|
start := c.await("game_started").GetGameStarted()
|
||||||
if got := len(start.GetPlayers()); got != 3 {
|
if got := len(start.GetPlayers()); got != 3 {
|
||||||
t.Errorf("the game was dealt to %d players, want 3", got)
|
t.Errorf("the game was dealt to %d players, want 3", got)
|
||||||
}
|
}
|
||||||
if start.GetTurnPlayerId() != "p1" {
|
switch {
|
||||||
t.Errorf("turn_player_id = %q, want p1 — seat order is turn order", start.GetTurnPlayerId())
|
case lead == "":
|
||||||
|
lead = start.GetTurnPlayerId()
|
||||||
|
case start.GetTurnPlayerId() != lead:
|
||||||
|
t.Errorf("turn_player_id = %q, want %q — the room must agree on who leads",
|
||||||
|
start.GetTurnPlayerId(), lead)
|
||||||
}
|
}
|
||||||
|
if start.GetMyTurn() {
|
||||||
|
onTurn++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if onTurn != 1 {
|
||||||
|
t.Errorf("%d players were dealt the first turn, want exactly 1", onTurn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,46 +190,54 @@ func TestAGameOutlivesItsFirstElimination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The player on turn gives up. Two are left, so the game does not end.
|
// 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{}}})
|
// Which player that is, is drawn at the start, so the test follows the
|
||||||
|
// turn rather than assuming the owner has it. Players are listed in turn
|
||||||
|
// order from the leader, so the seat after them inherits the position.
|
||||||
|
lead := onTurnClient(t, clients, starts)
|
||||||
|
leadID := starts[lead].GetTurnPlayerId()
|
||||||
|
nextID := starts[lead].GetPlayers()[1].GetPlayerId()
|
||||||
|
next := clientWithID(t, clients, starts, nextID)
|
||||||
|
|
||||||
|
lead.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
||||||
|
|
||||||
for _, c := range clients {
|
for _, c := range clients {
|
||||||
out := c.await("player_eliminated").GetPlayerEliminated()
|
out := c.await("player_eliminated").GetPlayerEliminated()
|
||||||
if out.GetPlayerId() != "p1" {
|
if out.GetPlayerId() != leadID {
|
||||||
t.Errorf("%q went out, want p1", out.GetPlayerId())
|
t.Errorf("%q went out, want %q", out.GetPlayerId(), leadID)
|
||||||
}
|
}
|
||||||
if got := out.GetIsMe(); got != (c == host) {
|
if got := out.GetIsMe(); got != (c == lead) {
|
||||||
t.Errorf("is_me = %v for the wrong recipient", got)
|
t.Errorf("is_me = %v for the wrong recipient", got)
|
||||||
}
|
}
|
||||||
if c != host && len(out.GetSuggestions()) != 0 {
|
if c != lead && len(out.GetSuggestions()) != 0 {
|
||||||
t.Errorf("a player who is still in was sent suggestions %v", out.GetSuggestions())
|
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.
|
// A turn update with no word: the position survived the player who left it.
|
||||||
update := second.await("turn_update").GetTurnUpdate()
|
update := next.await("turn_update").GetTurnUpdate()
|
||||||
if update.GetPlayed() != nil {
|
if update.GetPlayed() != nil {
|
||||||
t.Error("an elimination reported a word as played")
|
t.Error("an elimination reported a word as played")
|
||||||
}
|
}
|
||||||
if update.GetCurrentSyllable() != starts[second].GetCurrentSyllable() {
|
if update.GetCurrentSyllable() != starts[next].GetCurrentSyllable() {
|
||||||
t.Errorf("the syllable moved on an elimination: %q -> %q",
|
t.Errorf("the syllable moved on an elimination: %q -> %q",
|
||||||
starts[second].GetCurrentSyllable(), update.GetCurrentSyllable())
|
starts[next].GetCurrentSyllable(), update.GetCurrentSyllable())
|
||||||
}
|
}
|
||||||
if update.GetTurnPlayerId() != "p2" || !update.GetMyTurn() {
|
if update.GetTurnPlayerId() != nextID || !update.GetMyTurn() {
|
||||||
t.Errorf("turn went to %q, want p2", update.GetTurnPlayerId())
|
t.Errorf("turn went to %q, want %q", update.GetTurnPlayerId(), nextID)
|
||||||
}
|
}
|
||||||
if update.GetTurnSeq() == starts[second].GetTurnSeq() {
|
if update.GetTurnSeq() == starts[next].GetTurnSeq() {
|
||||||
t.Error("the turn sequence did not move, so a stale submission could still land")
|
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
|
// The player who went out is still in the room and still being told what
|
||||||
// is happening in it.
|
// is happening in it.
|
||||||
watching := host.await("turn_update").GetTurnUpdate()
|
watching := lead.await("turn_update").GetTurnUpdate()
|
||||||
if watching.GetMyTurn() {
|
if watching.GetMyTurn() {
|
||||||
t.Error("an eliminated player was dealt a turn")
|
t.Error("an eliminated player was dealt a turn")
|
||||||
}
|
}
|
||||||
var eliminated bool
|
var eliminated bool
|
||||||
for _, p := range watching.GetPlayers() {
|
for _, p := range watching.GetPlayers() {
|
||||||
if p.GetPlayerId() == "p1" {
|
if p.GetPlayerId() == leadID {
|
||||||
eliminated = p.GetEliminated()
|
eliminated = p.GetEliminated()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,12 +246,39 @@ func TestAGameOutlivesItsFirstElimination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// And they can still talk, which is the other half of staying in the room.
|
// And they can still talk, which is the other half of staying in the room.
|
||||||
host.say("chúc may mắn")
|
lead.say("chúc may mắn")
|
||||||
if got := second.await("chat_message").GetChatMessage().GetText(); got != "chúc may mắn" {
|
if got := next.await("chat_message").GetChatMessage().GetText(); got != "chúc may mắn" {
|
||||||
t.Errorf("an eliminated player's message arrived as %q", got)
|
t.Errorf("an eliminated player's message arrived as %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// onTurnClient is the client that drew the first turn.
|
||||||
|
func onTurnClient(t *testing.T, clients []*testClient, starts map[*testClient]*noituv1.GameStarted) *testClient {
|
||||||
|
t.Helper()
|
||||||
|
for _, c := range clients {
|
||||||
|
if starts[c].GetMyTurn() {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("no client was dealt the first turn")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientWithID is the client seated at id, found through the is_me row it was
|
||||||
|
// sent — a client is told which seat is its own and nothing else identifies it.
|
||||||
|
func clientWithID(t *testing.T, clients []*testClient, starts map[*testClient]*noituv1.GameStarted, id string) *testClient {
|
||||||
|
t.Helper()
|
||||||
|
for _, c := range clients {
|
||||||
|
for _, p := range starts[c].GetPlayers() {
|
||||||
|
if p.GetIsMe() && p.GetPlayerId() == id {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no client is seated at %q", id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// The last elimination ends it, and everybody is shown the same table from
|
// The last elimination ends it, and everybody is shown the same table from
|
||||||
// their own side.
|
// their own side.
|
||||||
func TestStandingsReachEverySeat(t *testing.T) {
|
func TestStandingsReachEverySeat(t *testing.T) {
|
||||||
|
|||||||
@@ -49,15 +49,16 @@ func TestNicknamesCannotStackCombiningMarks(t *testing.T) {
|
|||||||
// each is a guard against the same mistake returning rather than a restatement
|
// each is a guard against the same mistake returning rather than a restatement
|
||||||
// of behaviour already covered elsewhere.
|
// of behaviour already covered elsewhere.
|
||||||
|
|
||||||
// startPvP runs two clients up to the point where the game is live.
|
// startPvP runs two clients up to the point where the game is live, and hands
|
||||||
func startPvP(t *testing.T, url string) (host, guest *testClient, code string) {
|
// them back in turn order: lead moves first, waits answers.
|
||||||
|
func startPvP(t *testing.T, url string) (lead, waits *testClient, code string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
host = dial(t, url)
|
host := dial(t, url)
|
||||||
host.hello("Chủ phòng")
|
host.hello("Chủ phòng")
|
||||||
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}})
|
||||||
code = host.await("room_state").GetRoomState().GetRoomCode()
|
code = host.await("room_state").GetRoomState().GetRoomCode()
|
||||||
|
|
||||||
guest = dial(t, url)
|
guest := dial(t, url)
|
||||||
guest.hello("Khách")
|
guest.hello("Khách")
|
||||||
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{
|
||||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||||
@@ -70,9 +71,11 @@ func startPvP(t *testing.T, url string) (host, guest *testClient, code string) {
|
|||||||
guest.setReady(true)
|
guest.setReady(true)
|
||||||
host.await("room_state")
|
host.await("room_state")
|
||||||
host.startGame()
|
host.startGame()
|
||||||
host.await("game_started")
|
// Returned in turn order rather than as owner and joiner: the first turn
|
||||||
guest.await("game_started")
|
// is drawn, so a test that plays a move has to be handed the player who
|
||||||
return host, guest, code
|
// may play it.
|
||||||
|
lead, waits, _ = awaitLead(t, host, guest)
|
||||||
|
return lead, waits, code
|
||||||
}
|
}
|
||||||
|
|
||||||
// silentFor asserts the client receives no message of the given kind within a
|
// silentFor asserts the client receives no message of the given kind within a
|
||||||
@@ -234,50 +237,50 @@ func TestRoomCreationIsRateLimited(t *testing.T) {
|
|||||||
// stopping after the first move as the alternation test does.
|
// stopping after the first move as the alternation test does.
|
||||||
//
|
//
|
||||||
// The dead end at the end of the chain does not decide the game by itself:
|
// The dead end at the end of the chain does not decide the game by itself:
|
||||||
// the guest is left holding a turn nobody could answer, and it is the clock
|
// the second player is left holding a turn nobody could answer, and it is the
|
||||||
// that ends it. The turn limit is short because this test spends one.
|
// clock that ends it. The turn limit is short because this test spends one.
|
||||||
func TestPvPGameRunsToAWinner(t *testing.T) {
|
func TestPvPGameRunsToAWinner(t *testing.T) {
|
||||||
_, url := newTestServer(t, chainDict(), Config{TurnLimit: time.Second})
|
_, url := newTestServer(t, chainDict(), Config{TurnLimit: time.Second})
|
||||||
host, guest, _ := startPvP(t, url)
|
lead, waits, _ := startPvP(t, url)
|
||||||
|
|
||||||
// a b (opening) -> b c -> c d -> d e, after which the guest has nothing.
|
// a b (opening) -> b c -> c d -> d e, after which waits has nothing.
|
||||||
host.submit("b c", 1)
|
lead.submit("b c", 1)
|
||||||
guestTurn := awaitMyTurn(t, guest)
|
waitsTurn := awaitMyTurn(t, waits)
|
||||||
guest.submit("c d", guestTurn.GetTurnSeq())
|
waits.submit("c d", waitsTurn.GetTurnSeq())
|
||||||
hostTurn := awaitMyTurn(t, host)
|
leadTurn := awaitMyTurn(t, lead)
|
||||||
host.submit("d e", hostTurn.GetTurnSeq())
|
lead.submit("d e", leadTurn.GetTurnSeq())
|
||||||
|
|
||||||
// The elimination comes first and carries what the position had left, so
|
// The elimination comes first and carries what the position had left, so
|
||||||
// it has to be read before the result it caused.
|
// it has to be read before the result it caused.
|
||||||
guestOut := guest.await("player_eliminated").GetPlayerEliminated()
|
loserOut := waits.await("player_eliminated").GetPlayerEliminated()
|
||||||
hostOut := host.await("player_eliminated").GetPlayerEliminated()
|
winnerOut := lead.await("player_eliminated").GetPlayerEliminated()
|
||||||
|
|
||||||
if !guestOut.GetIsMe() || hostOut.GetIsMe() {
|
if !loserOut.GetIsMe() || winnerOut.GetIsMe() {
|
||||||
t.Error("is_me should be true only for the player who went out")
|
t.Error("is_me should be true only for the player who went out")
|
||||||
}
|
}
|
||||||
|
|
||||||
hostOver := host.await("game_over").GetGameOver()
|
winnerOver := lead.await("game_over").GetGameOver()
|
||||||
guestOver := guest.await("game_over").GetGameOver()
|
loserOver := waits.await("game_over").GetGameOver()
|
||||||
|
|
||||||
if hostOver.GetIWon() == guestOver.GetIWon() {
|
if winnerOver.GetIWon() == loserOver.GetIWon() {
|
||||||
t.Fatalf("both players were told the same outcome: host=%v guest=%v",
|
t.Fatalf("both players were told the same outcome: lead=%v waits=%v",
|
||||||
hostOver.GetIWon(), guestOver.GetIWon())
|
winnerOver.GetIWon(), loserOver.GetIWon())
|
||||||
}
|
}
|
||||||
if !hostOver.GetIWon() {
|
if !winnerOver.GetIWon() {
|
||||||
t.Error("the player who left their opponent with no legal move should win")
|
t.Error("the player who left their opponent with no legal move should win")
|
||||||
}
|
}
|
||||||
if hostOver.GetReason() != noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE {
|
if winnerOver.GetReason() != noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE {
|
||||||
t.Errorf("reason = %v, want NO_LEGAL_MOVE", hostOver.GetReason())
|
t.Errorf("reason = %v, want NO_LEGAL_MOVE", winnerOver.GetReason())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nothing could have been played, and the empty list is how the player who
|
// 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
|
// 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
|
// result: it describes the position they were looking at, which by the end
|
||||||
// of a longer game is nobody else's position.
|
// of a longer game is nobody else's position.
|
||||||
if got := guestOut.GetSuggestions(); len(got) != 0 {
|
if got := loserOut.GetSuggestions(); len(got) != 0 {
|
||||||
t.Errorf("the losing player was offered %v out of a dead end, want nothing", got)
|
t.Errorf("the losing player was offered %v out of a dead end, want nothing", got)
|
||||||
}
|
}
|
||||||
if got := hostOut.GetSuggestions(); len(got) != 0 {
|
if got := winnerOut.GetSuggestions(); len(got) != 0 {
|
||||||
t.Errorf("the winner was sent suggestions %v, want none", got)
|
t.Errorf("the winner was sent suggestions %v, want none", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,23 +290,23 @@ func TestPvPGameRunsToAWinner(t *testing.T) {
|
|||||||
// shown some of them.
|
// shown some of them.
|
||||||
func TestLosingPlayerIsToldWhatCouldHaveBeenPlayed(t *testing.T) {
|
func TestLosingPlayerIsToldWhatCouldHaveBeenPlayed(t *testing.T) {
|
||||||
_, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second})
|
_, url := newTestServer(t, chainDict(), Config{TurnLimit: 10 * time.Second})
|
||||||
host, guest, _ := startPvP(t, url)
|
lead, waits, _ := startPvP(t, url)
|
||||||
|
|
||||||
// The opening is "a b", so "b c" is still there to be played.
|
// 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{}}})
|
waits.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}})
|
||||||
|
|
||||||
guestOut := guest.await("player_eliminated").GetPlayerEliminated()
|
loserOut := waits.await("player_eliminated").GetPlayerEliminated()
|
||||||
hostOut := host.await("player_eliminated").GetPlayerEliminated()
|
winnerOut := lead.await("player_eliminated").GetPlayerEliminated()
|
||||||
|
|
||||||
guestOver := guest.await("game_over").GetGameOver()
|
loserOver := waits.await("game_over").GetGameOver()
|
||||||
|
|
||||||
if guestOver.GetIWon() {
|
if loserOver.GetIWon() {
|
||||||
t.Fatal("the player who resigned was told they won")
|
t.Fatal("the player who resigned was told they won")
|
||||||
}
|
}
|
||||||
if got := guestOut.GetSuggestions(); !slices.Equal(got, []string{"b c"}) {
|
if got := loserOut.GetSuggestions(); !slices.Equal(got, []string{"b c"}) {
|
||||||
t.Errorf("suggestions = %v, want [b c]", got)
|
t.Errorf("suggestions = %v, want [b c]", got)
|
||||||
}
|
}
|
||||||
if got := hostOut.GetSuggestions(); len(got) != 0 {
|
if got := winnerOut.GetSuggestions(); len(got) != 0 {
|
||||||
t.Errorf("the winner was sent suggestions %v, want none", got)
|
t.Errorf("the winner was sent suggestions %v, want none", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"iter"
|
"iter"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
||||||
@@ -696,6 +697,19 @@ func (r *room) beginGame() error {
|
|||||||
ids = append(ids, s.id)
|
ids = append(ids, s.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Who leads is drawn rather than owned. Opening the game is an advantage —
|
||||||
|
// the first player picks from a whole syllable, everyone after them plays
|
||||||
|
// what is left of it — and giving it to whoever happened to create the
|
||||||
|
// room would make the same person favourite in every game of a series.
|
||||||
|
//
|
||||||
|
// Rotating rather than shuffling keeps the table intact: everybody still
|
||||||
|
// plays in the order they sat down, the cycle just starts somewhere else.
|
||||||
|
// A bot room is left alone; it has no table to be fair about, and the
|
||||||
|
// human opens.
|
||||||
|
if r.strategy == nil {
|
||||||
|
lead := rand.IntN(len(ids))
|
||||||
|
ids = slices.Concat(ids[lead:], ids[:lead])
|
||||||
|
}
|
||||||
|
|
||||||
engine, err := game.New(r.dict, ids, opening, r.turnLimit, time.Now())
|
engine, err := game.New(r.dict, ids, opening, r.turnLimit, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1452,8 +1466,9 @@ func (r *room) broadcastRoomState() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// playerSlots renders the seating for one recipient, in seat order — which is
|
// playerSlots renders the seating for one recipient, in seat order. That is
|
||||||
// also the turn order a game started from this lobby will use.
|
// the order they will play in, but not who plays first: the lead is drawn when
|
||||||
|
// the game starts, and the table sent with it is the one in turn order.
|
||||||
func (r *room) playerSlots(me game.PlayerID) []*noituv1.PlayerSlot {
|
func (r *room) playerSlots(me game.PlayerID) []*noituv1.PlayerSlot {
|
||||||
slots := make([]*noituv1.PlayerSlot, 0, maxPlayers)
|
slots := make([]*noituv1.PlayerSlot, 0, maxPlayers)
|
||||||
for _, s := range r.seats {
|
for _, s := range r.seats {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"iter"
|
"iter"
|
||||||
|
"math/rand/v2"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
||||||
|
"github.com/tiennm99dev/noitu/server/internal/bot"
|
||||||
"github.com/tiennm99dev/noitu/server/internal/game"
|
"github.com/tiennm99dev/noitu/server/internal/game"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
@@ -334,6 +336,55 @@ func TestBotGamePlaysToCompletion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTheFirstTurnIsDrawn checks that opening the room is not the same as
|
||||||
|
// opening the game. Moving first is an advantage, and handing it to the owner
|
||||||
|
// every time would make them favourite in every game of a series.
|
||||||
|
func TestTheFirstTurnIsDrawn(t *testing.T) {
|
||||||
|
// Started here rather than over a pair of sockets: a series long enough to
|
||||||
|
// tell a draw from a fixed lead is far more starts than a lobby's rate
|
||||||
|
// limiter allows, and none of what is being checked is on the wire.
|
||||||
|
r := &room{dict: chainDict(), turnLimit: time.Second}
|
||||||
|
r.seats[0] = &seat{id: "p1"}
|
||||||
|
r.seats[1] = &seat{id: "p2"}
|
||||||
|
|
||||||
|
// There are two outcomes, so a series that only ever shows one of them is
|
||||||
|
// a lead that is not being drawn. A fixed lead fails this every time; a
|
||||||
|
// fair draw fails it about once in five hundred million runs.
|
||||||
|
const games = 30
|
||||||
|
led := map[game.PlayerID]int{}
|
||||||
|
for range games {
|
||||||
|
if err := r.beginGame(); err != nil {
|
||||||
|
t.Fatalf("beginGame: %v", err)
|
||||||
|
}
|
||||||
|
led[r.engine.Turn()]++
|
||||||
|
}
|
||||||
|
|
||||||
|
if led["p1"] == 0 || led["p2"] == 0 {
|
||||||
|
t.Errorf("over %d games p1 led %d and p2 %d; the first turn is not being drawn",
|
||||||
|
games, led["p1"], led["p2"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one room where the lead is not drawn: the human opens against the bot.
|
||||||
|
func TestABotGameOpensWithTheHuman(t *testing.T) {
|
||||||
|
strategy, err := bot.New(bot.Easy, rand.New(rand.NewPCG(1, 2)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bot.New: %v", err)
|
||||||
|
}
|
||||||
|
r := &room{dict: chainDict(), turnLimit: time.Second, strategy: strategy}
|
||||||
|
r.seats[0] = &seat{id: "p1"}
|
||||||
|
r.seats[1] = &seat{id: botPlayerID}
|
||||||
|
|
||||||
|
for range 20 {
|
||||||
|
if err := r.beginGame(); err != nil {
|
||||||
|
t.Fatalf("beginGame: %v", err)
|
||||||
|
}
|
||||||
|
if got := r.engine.Turn(); got != "p1" {
|
||||||
|
t.Fatalf("the bot game opened on %q, want the human", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestPvPGameAlternatesTurns runs two clients through a full game and checks
|
// TestPvPGameAlternatesTurns runs two clients through a full game and checks
|
||||||
// that each sees the other's move rendered from its own side.
|
// that each sees the other's move rendered from its own side.
|
||||||
func TestPvPGameAlternatesTurns(t *testing.T) {
|
func TestPvPGameAlternatesTurns(t *testing.T) {
|
||||||
@@ -351,37 +402,29 @@ func TestPvPGameAlternatesTurns(t *testing.T) {
|
|||||||
}})
|
}})
|
||||||
readyAndStart(t, host, guest)
|
readyAndStart(t, host, guest)
|
||||||
|
|
||||||
hostStart := host.await("game_started").GetGameStarted()
|
lead, waits, start := awaitLead(t, host, guest)
|
||||||
guestStart := guest.await("game_started").GetGameStarted()
|
|
||||||
|
|
||||||
if hostStart.GetMyTurn() == guestStart.GetMyTurn() {
|
lead.submit("b c", start.GetTurnSeq())
|
||||||
t.Fatal("exactly one player must be on turn")
|
|
||||||
}
|
|
||||||
if !hostStart.GetMyTurn() {
|
|
||||||
t.Fatal("the room creator takes the first turn")
|
|
||||||
}
|
|
||||||
|
|
||||||
host.submit("b c", hostStart.GetTurnSeq())
|
|
||||||
|
|
||||||
// The same move, rendered per recipient: by_me flips, my_turn flips.
|
// The same move, rendered per recipient: by_me flips, my_turn flips.
|
||||||
hostUpdate := host.await("turn_update").GetTurnUpdate()
|
moverUpdate := lead.await("turn_update").GetTurnUpdate()
|
||||||
guestUpdate := guest.await("turn_update").GetTurnUpdate()
|
otherUpdate := waits.await("turn_update").GetTurnUpdate()
|
||||||
|
|
||||||
if !hostUpdate.GetPlayed().GetByMe() {
|
if !moverUpdate.GetPlayed().GetByMe() {
|
||||||
t.Error("mover should see by_me = true")
|
t.Error("mover should see by_me = true")
|
||||||
}
|
}
|
||||||
if guestUpdate.GetPlayed().GetByMe() {
|
if otherUpdate.GetPlayed().GetByMe() {
|
||||||
t.Error("opponent should see by_me = false")
|
t.Error("opponent should see by_me = false")
|
||||||
}
|
}
|
||||||
if hostUpdate.GetMyTurn() {
|
if moverUpdate.GetMyTurn() {
|
||||||
t.Error("mover should not be on turn after moving")
|
t.Error("mover should not be on turn after moving")
|
||||||
}
|
}
|
||||||
if !guestUpdate.GetMyTurn() {
|
if !otherUpdate.GetMyTurn() {
|
||||||
t.Error("opponent should now be on turn")
|
t.Error("opponent should now be on turn")
|
||||||
}
|
}
|
||||||
if myScore(hostUpdate) != otherScore(guestUpdate) {
|
if myScore(moverUpdate) != otherScore(otherUpdate) {
|
||||||
t.Errorf("scores disagree across recipients: %d vs %d",
|
t.Errorf("scores disagree across recipients: %d vs %d",
|
||||||
myScore(hostUpdate), otherScore(guestUpdate))
|
myScore(moverUpdate), otherScore(otherUpdate))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,10 +445,9 @@ func TestTurnTimeoutEndsGameServerSide(t *testing.T) {
|
|||||||
}})
|
}})
|
||||||
readyAndStart(t, host, guest)
|
readyAndStart(t, host, guest)
|
||||||
|
|
||||||
host.await("game_started")
|
_, waits, _ := awaitLead(t, host, guest)
|
||||||
guest.await("game_started")
|
|
||||||
|
|
||||||
over := guest.await("game_over").GetGameOver()
|
over := waits.await("game_over").GetGameOver()
|
||||||
if !over.GetIWon() {
|
if !over.GetIWon() {
|
||||||
t.Error("the player who did not time out should win")
|
t.Error("the player who did not time out should win")
|
||||||
}
|
}
|
||||||
@@ -471,17 +513,16 @@ func TestReplayingAWordIsRejected(t *testing.T) {
|
|||||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||||
}})
|
}})
|
||||||
readyAndStart(t, host, guest)
|
readyAndStart(t, host, guest)
|
||||||
hostStart := host.await("game_started").GetGameStarted()
|
lead, waits, start := awaitLead(t, host, guest)
|
||||||
guest.await("game_started")
|
|
||||||
|
|
||||||
// Opening "a b" is used and current syllable is "b". Play "b a" so the
|
// Opening "a b" is used and current syllable is "b". Play "b a" so the
|
||||||
// syllable returns to "a", where the opening word now links legally.
|
// syllable returns to "a", where the opening word now links legally.
|
||||||
host.submit("b a", hostStart.GetTurnSeq())
|
lead.submit("b a", start.GetTurnSeq())
|
||||||
guestTurn := guest.await("turn_update").GetTurnUpdate()
|
replyTurn := waits.await("turn_update").GetTurnUpdate()
|
||||||
|
|
||||||
guest.submit("a b", guestTurn.GetTurnSeq())
|
waits.submit("a b", replyTurn.GetTurnSeq())
|
||||||
|
|
||||||
got := guest.await("move_rejected").GetMoveRejected()
|
got := waits.await("move_rejected").GetMoveRejected()
|
||||||
if got.GetReason() != noituv1.RejectReason_REJECT_REASON_ALREADY_USED {
|
if got.GetReason() != noituv1.RejectReason_REJECT_REASON_ALREADY_USED {
|
||||||
t.Errorf("reason = %v, want ALREADY_USED", got.GetReason())
|
t.Errorf("reason = %v, want ALREADY_USED", got.GetReason())
|
||||||
}
|
}
|
||||||
@@ -522,7 +563,7 @@ func TestResumeWithinGraceRestoresGame(t *testing.T) {
|
|||||||
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
JoinRoom: &noituv1.JoinRoom{RoomCode: code},
|
||||||
}})
|
}})
|
||||||
readyAndStart(t, host, guest)
|
readyAndStart(t, host, guest)
|
||||||
host.await("game_started")
|
hostStart := host.await("game_started").GetGameStarted()
|
||||||
guest.await("game_started")
|
guest.await("game_started")
|
||||||
|
|
||||||
_ = host.conn.Close(websocket.StatusGoingAway, "")
|
_ = host.conn.Close(websocket.StatusGoingAway, "")
|
||||||
@@ -547,8 +588,8 @@ func TestResumeWithinGraceRestoresGame(t *testing.T) {
|
|||||||
if restored.GetOpeningWord() != "a b" {
|
if restored.GetOpeningWord() != "a b" {
|
||||||
t.Errorf("resumed on %q, want the original opening", restored.GetOpeningWord())
|
t.Errorf("resumed on %q, want the original opening", restored.GetOpeningWord())
|
||||||
}
|
}
|
||||||
if !restored.GetMyTurn() {
|
if restored.GetMyTurn() != hostStart.GetMyTurn() {
|
||||||
t.Error("the resumed player was on turn and should still be")
|
t.Error("the resumed player should come back to the turn they left")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,6 +1085,25 @@ func readyAndStart(t *testing.T, host, guest *testClient) {
|
|||||||
host.startGame()
|
host.startGame()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// awaitLead reads both game_started messages and sorts the pair into the one
|
||||||
|
// that drew the first turn and the one that waits.
|
||||||
|
//
|
||||||
|
// The lead is random per game, so a test that needs to play a move has to ask
|
||||||
|
// who may play it rather than assume the room's creator. It returns the
|
||||||
|
// leader's GameStarted for its turn_seq.
|
||||||
|
func awaitLead(t *testing.T, host, guest *testClient) (lead, waits *testClient, started *noituv1.GameStarted) {
|
||||||
|
t.Helper()
|
||||||
|
hostStart := host.await("game_started").GetGameStarted()
|
||||||
|
guestStart := guest.await("game_started").GetGameStarted()
|
||||||
|
if hostStart.GetMyTurn() == guestStart.GetMyTurn() {
|
||||||
|
t.Fatal("exactly one player must be on turn")
|
||||||
|
}
|
||||||
|
if hostStart.GetMyTurn() {
|
||||||
|
return host, guest, hostStart
|
||||||
|
}
|
||||||
|
return guest, host, guestStart
|
||||||
|
}
|
||||||
|
|
||||||
func (c *testClient) setReady(ready bool) {
|
func (c *testClient) setReady(ready bool) {
|
||||||
c.t.Helper()
|
c.t.Helper()
|
||||||
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_SetReady{
|
c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_SetReady{
|
||||||
|
|||||||
@@ -31,6 +31,36 @@ export async function waitForMyTurn(page) {
|
|||||||
await expect(board(page).turn).toHaveText('Đến lượt bạn', { timeout: 30_000 });
|
await expect(board(page).turn).toHaveText('Đến lượt bạn', { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits until one of these players is on turn, and sorts them into the one who
|
||||||
|
* has it and the ones who do not.
|
||||||
|
*
|
||||||
|
* Who moves first is drawn when the game starts, so a test that plays a move
|
||||||
|
* has to ask rather than assume the room's owner. It polls the indicators
|
||||||
|
* together instead of waiting on one page, because the state being waited for
|
||||||
|
* belongs to the room and not to any single player.
|
||||||
|
*
|
||||||
|
* @param {import('@playwright/test').Page[]} pages
|
||||||
|
* @returns {Promise<{lead: import('@playwright/test').Page, waits: import('@playwright/test').Page[]}>}
|
||||||
|
*/
|
||||||
|
export async function awaitTurn(...pages) {
|
||||||
|
let onTurn = -1;
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const shown = await Promise.all(
|
||||||
|
pages.map((page) => board(page).turn.textContent().catch(() => null))
|
||||||
|
);
|
||||||
|
onTurn = shown.findIndex((text) => (text ?? '').trim() === 'Đến lượt bạn');
|
||||||
|
return onTurn;
|
||||||
|
},
|
||||||
|
{ timeout: 30_000, message: 'nobody was dealt the turn' }
|
||||||
|
)
|
||||||
|
.toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
return { lead: pages[onTurn], waits: pages.filter((_, i) => i !== onTurn) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Types a word and sends it. The field is uncontrolled on purpose, so this
|
* Types a word and sends it. The field is uncontrolled on purpose, so this
|
||||||
* fills and submits exactly as a player would.
|
* fills and submits exactly as a player would.
|
||||||
|
|||||||
+78
-46
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
|
awaitTurn,
|
||||||
board,
|
board,
|
||||||
chainWords,
|
chainWords,
|
||||||
chat,
|
chat,
|
||||||
@@ -48,14 +49,22 @@ async function joinRoom(page, nickname, code) {
|
|||||||
await page.getByRole('button', { name: 'Vào phòng' }).click();
|
await page.getByRole('button', { name: 'Vào phòng' }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seats a pair and plays them into a game, which is where most tests start. */
|
/**
|
||||||
|
* Seats a pair and plays them into a game, which is where most tests start.
|
||||||
|
*
|
||||||
|
* Also handed back as lead and second: the first turn is drawn, so a test that
|
||||||
|
* plays a move takes the player who may play it rather than the owner.
|
||||||
|
*/
|
||||||
async function playingPair(browser) {
|
async function playingPair(browser) {
|
||||||
const pair = await twoPlayers(browser);
|
const pair = await twoPlayers(browser);
|
||||||
const code = await createRoom(pair.host, 'Minh');
|
const code = await createRoom(pair.host, 'Minh');
|
||||||
await joinRoom(pair.guest, 'Lan', code);
|
await joinRoom(pair.guest, 'Lan', code);
|
||||||
await readyAndStart(pair.host, pair.guest);
|
await readyAndStart(pair.host, pair.guest);
|
||||||
await waitForMyTurn(pair.host);
|
const {
|
||||||
return { ...pair, code };
|
lead,
|
||||||
|
waits: [second]
|
||||||
|
} = await awaitTurn(pair.host, pair.guest);
|
||||||
|
return { ...pair, code, lead, second };
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('playing a stranger', () => {
|
test.describe('playing a stranger', () => {
|
||||||
@@ -69,11 +78,15 @@ test.describe('playing a stranger', () => {
|
|||||||
await joinRoom(guest, 'Lan', code);
|
await joinRoom(guest, 'Lan', code);
|
||||||
await readyAndStart(host, guest);
|
await readyAndStart(host, guest);
|
||||||
|
|
||||||
// Both boards come up, and exactly one player is on turn.
|
// Both boards come up, and exactly one player is on turn — which of
|
||||||
await waitForMyTurn(host);
|
// them is drawn when the game starts.
|
||||||
|
const {
|
||||||
|
lead,
|
||||||
|
waits: [waiting]
|
||||||
|
} = await awaitTurn(host, guest);
|
||||||
// Whose turn it is, by name: with four seats "the opponent" would stop
|
// Whose turn it is, by name: with four seats "the opponent" would stop
|
||||||
// naming anybody.
|
// naming anybody.
|
||||||
await expect(board(guest).turn).toHaveText('Đến lượt Minh…');
|
await expect(board(waiting).turn).toHaveText(`Đến lượt ${lead === host ? 'Minh' : 'Lan'}…`);
|
||||||
|
|
||||||
// Each side is shown the other's server-sanitized name. Scoped to the
|
// Each side is shown the other's server-sanitized name. Scoped to the
|
||||||
// scoreboard: the turn indicator names a player too, so an unscoped
|
// scoreboard: the turn indicator names a player too, so an unscoped
|
||||||
@@ -81,16 +94,16 @@ test.describe('playing a stranger', () => {
|
|||||||
await expect(host.getByTestId('scoreboard').getByText('Lan')).toBeVisible();
|
await expect(host.getByTestId('scoreboard').getByText('Lan')).toBeVisible();
|
||||||
await expect(guest.getByTestId('scoreboard').getByText('Minh')).toBeVisible();
|
await expect(guest.getByTestId('scoreboard').getByText('Minh')).toBeVisible();
|
||||||
|
|
||||||
const used = new Set(await chainWords(host));
|
const used = new Set(await chainWords(lead));
|
||||||
const hostWord = await playLegalMove(host, used);
|
const opening = await playLegalMove(lead, used);
|
||||||
|
|
||||||
// The move crosses to the other browser, and the turn goes with it.
|
// The move crosses to the other browser, and the turn goes with it.
|
||||||
await expect(guest.getByText(hostWord, { exact: true }).first()).toBeVisible();
|
await expect(waiting.getByText(opening, { exact: true }).first()).toBeVisible();
|
||||||
await waitForMyTurn(guest);
|
await waitForMyTurn(waiting);
|
||||||
|
|
||||||
const guestWord = await playLegalMove(guest, used);
|
const reply = await playLegalMove(waiting, used);
|
||||||
await expect(host.getByText(guestWord, { exact: true }).first()).toBeVisible();
|
await expect(lead.getByText(reply, { exact: true }).first()).toBeVisible();
|
||||||
await waitForMyTurn(host);
|
await waitForMyTurn(lead);
|
||||||
|
|
||||||
await close();
|
await close();
|
||||||
});
|
});
|
||||||
@@ -107,7 +120,7 @@ test.describe('playing a stranger', () => {
|
|||||||
await expect(guest.getByTestId('ready')).toBeVisible();
|
await expect(guest.getByTestId('ready')).toBeVisible();
|
||||||
await readyAndStart(host, guest);
|
await readyAndStart(host, guest);
|
||||||
|
|
||||||
await waitForMyTurn(host);
|
await awaitTurn(host, guest);
|
||||||
await expect(board(guest).syllable).toBeVisible();
|
await expect(board(guest).syllable).toBeVisible();
|
||||||
|
|
||||||
await close();
|
await close();
|
||||||
@@ -145,7 +158,7 @@ test.describe('playing a stranger', () => {
|
|||||||
|
|
||||||
// A new game in the same room: the code is unchanged and the board is
|
// A new game in the same room: the code is unchanged and the board is
|
||||||
// back to a single opening word.
|
// back to a single opening word.
|
||||||
await waitForMyTurn(host);
|
await awaitTurn(host, guest);
|
||||||
await expect(host.getByText(code)).toBeVisible();
|
await expect(host.getByText(code)).toBeVisible();
|
||||||
expect(await chainWords(host)).toHaveLength(1);
|
expect(await chainWords(host)).toHaveLength(1);
|
||||||
|
|
||||||
@@ -270,7 +283,7 @@ test.describe('playing a stranger', () => {
|
|||||||
// And the conversation follows them into the game, where a screen this
|
// And the conversation follows them into the game, where a screen this
|
||||||
// wide keeps it beside the board rather than folding it away.
|
// wide keeps it beside the board rather than folding it away.
|
||||||
await readyAndStart(host, guest);
|
await readyAndStart(host, guest);
|
||||||
await waitForMyTurn(host);
|
await awaitTurn(host, guest);
|
||||||
await expect(chat(host).log).toContainText('chào bạn');
|
await expect(chat(host).log).toContainText('chào bạn');
|
||||||
|
|
||||||
await close();
|
await close();
|
||||||
@@ -294,7 +307,7 @@ test.describe('playing a stranger', () => {
|
|||||||
await expect(chat(host).log).toContainText('hai');
|
await expect(chat(host).log).toContainText('hai');
|
||||||
|
|
||||||
await readyAndStart(host, guest);
|
await readyAndStart(host, guest);
|
||||||
await waitForMyTurn(host);
|
await awaitTurn(host, guest);
|
||||||
|
|
||||||
// The panel that folds on the way into the game is the one that was
|
// The panel that folds on the way into the game is the one that was
|
||||||
// open in the lobby. What it had already shown is not new mail.
|
// open in the lobby. What it had already shown is not new mail.
|
||||||
@@ -314,19 +327,19 @@ test.describe('playing a stranger', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a turn arriving does not take the chat field away', async ({ browser }) => {
|
test('a turn arriving does not take the chat field away', async ({ browser }) => {
|
||||||
const { host, guest, close } = await playingPair(browser);
|
const { lead, second, close } = await playingPair(browser);
|
||||||
|
|
||||||
// The guest starts typing while the host is still on turn. The word
|
// The player waiting starts typing while the other is still on turn.
|
||||||
// field wants focus the moment a turn lands, and taking it here would
|
// The word field wants focus the moment a turn lands, and taking it
|
||||||
// drop the rest of the sentence into the game.
|
// here would drop the rest of the sentence into the game.
|
||||||
await chat(guest).input.click();
|
await chat(second).input.click();
|
||||||
await chat(guest).input.fill('đang gõ dở');
|
await chat(second).input.fill('đang gõ dở');
|
||||||
|
|
||||||
await playLegalMove(host, new Set(await chainWords(host)));
|
await playLegalMove(lead, new Set(await chainWords(lead)));
|
||||||
await waitForMyTurn(guest);
|
await waitForMyTurn(second);
|
||||||
|
|
||||||
await expect(chat(guest).input).toBeFocused();
|
await expect(chat(second).input).toBeFocused();
|
||||||
await expect(chat(guest).input).toHaveValue('đang gõ dở');
|
await expect(chat(second).input).toHaveValue('đang gõ dở');
|
||||||
|
|
||||||
await close();
|
await close();
|
||||||
});
|
});
|
||||||
@@ -470,10 +483,17 @@ test.describe('playing a stranger', () => {
|
|||||||
await expect(host.getByTestId('start-game')).toBeDisabled();
|
await expect(host.getByTestId('start-game')).toBeDisabled();
|
||||||
await readyAndStart(host, ...players);
|
await readyAndStart(host, ...players);
|
||||||
|
|
||||||
// Four boards, and exactly one player on turn.
|
// Four boards, and exactly one player on turn — whoever the draw dealt
|
||||||
await waitForMyTurn(host);
|
// it to. The other three are all told the same name.
|
||||||
for (const page of [guest, ...players]) {
|
const names = new Map([
|
||||||
await expect(board(page).turn).toHaveText('Đến lượt Minh…');
|
[host, 'Minh'],
|
||||||
|
[guest, 'Lan'],
|
||||||
|
[players[0], 'Nam'],
|
||||||
|
[players[1], 'Hà']
|
||||||
|
]);
|
||||||
|
const { lead, waits } = await awaitTurn(host, guest, ...players);
|
||||||
|
for (const page of waits) {
|
||||||
|
await expect(board(page).turn).toHaveText(`Đến lượt ${names.get(lead)}…`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await fifthContext.close();
|
await fifthContext.close();
|
||||||
@@ -496,29 +516,41 @@ test.describe('playing a stranger', () => {
|
|||||||
await joinRoom(third, 'Nam', code);
|
await joinRoom(third, 'Nam', code);
|
||||||
|
|
||||||
await readyAndStart(host, guest, third);
|
await readyAndStart(host, guest, third);
|
||||||
await waitForMyTurn(host);
|
|
||||||
|
|
||||||
host.on('dialog', (dialog) => dialog.accept());
|
// The player who drew the first turn is the one who gives it up, so
|
||||||
await host.getByRole('button', { name: 'Đầu hàng' }).click();
|
// the position is inherited rather than merely passed over.
|
||||||
|
const names = new Map([
|
||||||
|
[host, 'Minh'],
|
||||||
|
[guest, 'Lan'],
|
||||||
|
[third, 'Nam']
|
||||||
|
]);
|
||||||
|
const { lead, waits } = await awaitTurn(host, guest, third);
|
||||||
|
|
||||||
|
lead.on('dialog', (dialog) => dialog.accept());
|
||||||
|
await lead.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||||
|
|
||||||
// Out, but still in the room: no input, no result screen, and the game
|
// Out, but still in the room: no input, no result screen, and the game
|
||||||
// carrying on in front of them.
|
// carrying on in front of them.
|
||||||
await expect(host.getByTestId('eliminated')).toBeVisible();
|
await expect(lead.getByTestId('eliminated')).toBeVisible();
|
||||||
await expect(host.getByRole('textbox', { name: 'Nhập từ của bạn' })).toHaveCount(0);
|
await expect(lead.getByRole('textbox', { name: 'Nhập từ của bạn' })).toHaveCount(0);
|
||||||
await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toHaveCount(0);
|
await expect(lead.getByRole('heading', { name: 'Bạn thua.' })).toHaveCount(0);
|
||||||
await expect(host.getByTestId('turn-indicator')).toHaveText('Đến lượt Lan…');
|
|
||||||
|
|
||||||
// The others are told who went out, and one of them is now on turn.
|
// The others are told who went out, and the next of them is on turn —
|
||||||
await expect(guest.getByTestId('player-out')).toContainText('Minh');
|
// which the player who left is shown by name.
|
||||||
await waitForMyTurn(guest);
|
const {
|
||||||
|
lead: next,
|
||||||
|
waits: [last]
|
||||||
|
} = await awaitTurn(...waits);
|
||||||
|
await expect(lead.getByTestId('turn-indicator')).toHaveText(`Đến lượt ${names.get(next)}…`);
|
||||||
|
await expect(next.getByTestId('player-out')).toContainText(names.get(lead));
|
||||||
|
|
||||||
// The last two settle it, and everybody sees the same table.
|
// The last two settle it, and everybody sees the same table.
|
||||||
guest.on('dialog', (dialog) => dialog.accept());
|
next.on('dialog', (dialog) => dialog.accept());
|
||||||
await guest.getByRole('button', { name: 'Đầu hàng' }).click();
|
await next.getByRole('button', { name: 'Đầu hàng' }).click();
|
||||||
|
|
||||||
await expect(third.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible();
|
await expect(last.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible();
|
||||||
await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible();
|
await expect(lead.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible();
|
||||||
await expect(host.getByTestId('standings').locator('li')).toHaveCount(3);
|
await expect(lead.getByTestId('standings').locator('li')).toHaveCount(3);
|
||||||
|
|
||||||
await thirdContext.close();
|
await thirdContext.close();
|
||||||
await close();
|
await close();
|
||||||
|
|||||||
+31
-16
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
|
awaitTurn,
|
||||||
board,
|
board,
|
||||||
chainWords,
|
chainWords,
|
||||||
playLegalMove,
|
playLegalMove,
|
||||||
@@ -19,6 +20,10 @@ import { cuttableSocket } from './socket-cut.js';
|
|||||||
/**
|
/**
|
||||||
* Seats two players in a room and returns them with the room code.
|
* Seats two players in a room and returns them with the room code.
|
||||||
*
|
*
|
||||||
|
* The pair comes back twice over: as owner and joiner, which is what the seat
|
||||||
|
* ids and the names follow, and as lead and second, which is who the first
|
||||||
|
* turn was drawn for.
|
||||||
|
*
|
||||||
* @param {import('@playwright/test').Browser} browser
|
* @param {import('@playwright/test').Browser} browser
|
||||||
*/
|
*/
|
||||||
async function pvpRoom(browser) {
|
async function pvpRoom(browser) {
|
||||||
@@ -37,12 +42,21 @@ async function pvpRoom(browser) {
|
|||||||
await guest.getByLabel('Mã phòng').fill(code);
|
await guest.getByLabel('Mã phòng').fill(code);
|
||||||
await guest.getByRole('button', { name: 'Vào phòng' }).click();
|
await guest.getByRole('button', { name: 'Vào phòng' }).click();
|
||||||
await readyAndStart(host, guest);
|
await readyAndStart(host, guest);
|
||||||
await waitForMyTurn(host);
|
const {
|
||||||
|
lead,
|
||||||
|
waits: [second]
|
||||||
|
} = await awaitTurn(host, guest);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
host,
|
host,
|
||||||
guest,
|
guest,
|
||||||
code,
|
code,
|
||||||
|
lead,
|
||||||
|
second,
|
||||||
|
/** @param {import('@playwright/test').Page} page */
|
||||||
|
seatOf: (page) => (page === host ? 'p1' : 'p2'),
|
||||||
|
/** @param {import('@playwright/test').Page} page */
|
||||||
|
nameOf: (page) => (page === host ? 'Minh' : 'Lan'),
|
||||||
async close() {
|
async close() {
|
||||||
await hostContext.close();
|
await hostContext.close();
|
||||||
await guestContext.close();
|
await guestContext.close();
|
||||||
@@ -54,31 +68,32 @@ test.describe('losing the connection', () => {
|
|||||||
test('the opponent is told, and a return inside the window resumes the game', async ({
|
test('the opponent is told, and a return inside the window resumes the game', async ({
|
||||||
browser
|
browser
|
||||||
}) => {
|
}) => {
|
||||||
const { host, guest, close } = await pvpRoom(browser);
|
const { lead, second, seatOf, nameOf, close } = await pvpRoom(browser);
|
||||||
|
|
||||||
const used = new Set(await chainWords(host));
|
// The player who drops is the one holding the turn, so what has to come
|
||||||
await playLegalMove(host, used);
|
// back is a position somebody owes a move to.
|
||||||
await waitForMyTurn(guest);
|
const used = new Set(await chainWords(lead));
|
||||||
|
await playLegalMove(lead, used);
|
||||||
|
await waitForMyTurn(second);
|
||||||
|
|
||||||
const hostSyllable = (await board(host).syllable.textContent())?.trim() ?? '';
|
const syllable = (await board(lead).syllable.textContent())?.trim() ?? '';
|
||||||
|
|
||||||
// Navigating away takes the socket with it, which is what the server
|
// Navigating away takes the socket with it, which is what the server
|
||||||
// sees when a player loses their connection. The resume token lives in
|
// sees when a player loses their connection. The resume token lives in
|
||||||
// this tab's session storage, so coming back can reclaim the seat.
|
// this tab's session storage, so coming back can reclaim the seat.
|
||||||
await guest.goto('about:blank');
|
await second.goto('about:blank');
|
||||||
|
|
||||||
await expect(host.getByTestId('away-p2')).toContainText('Lan mất kết nối', {
|
const away = lead.getByTestId(`away-${seatOf(second)}`);
|
||||||
timeout: 20_000
|
await expect(away).toContainText(`${nameOf(second)} mất kết nối`, { timeout: 20_000 });
|
||||||
});
|
|
||||||
|
|
||||||
// Back inside the grace window.
|
// Back inside the grace window.
|
||||||
await guest.goto('/online');
|
await second.goto('/online');
|
||||||
|
|
||||||
// The seat is restored: the host stops waiting, and the returning player
|
// The seat is restored: the other player stops waiting, and the one who
|
||||||
// is looking at the same position rather than the lobby.
|
// returned is looking at the same position rather than the lobby.
|
||||||
await expect(host.getByTestId('away-p2')).toHaveCount(0, { timeout: 20_000 });
|
await expect(away).toHaveCount(0, { timeout: 20_000 });
|
||||||
await expect(board(guest).syllable).toHaveText(hostSyllable, { timeout: 20_000 });
|
await expect(board(second).syllable).toHaveText(syllable, { timeout: 20_000 });
|
||||||
await expect(board(guest).turn).toHaveText('Đến lượt bạn');
|
await expect(board(second).turn).toHaveText('Đến lượt bạn');
|
||||||
|
|
||||||
await close();
|
await close();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user