diff --git a/server/internal/wsapi/multiplayer_test.go b/server/internal/wsapi/multiplayer_test.go index ffcff67..f08b489 100644 --- a/server/internal/wsapi/multiplayer_test.go +++ b/server/internal/wsapi/multiplayer_test.go @@ -106,14 +106,27 @@ func TestStartWaitsForEveryGuest(t *testing.T) { } 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 { 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()) + switch { + 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. - 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 { out := c.await("player_eliminated").GetPlayerEliminated() - if out.GetPlayerId() != "p1" { - t.Errorf("%q went out, want p1", out.GetPlayerId()) + if out.GetPlayerId() != leadID { + 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) } - 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()) } } // 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 { 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", - starts[second].GetCurrentSyllable(), update.GetCurrentSyllable()) + starts[next].GetCurrentSyllable(), update.GetCurrentSyllable()) } - if update.GetTurnPlayerId() != "p2" || !update.GetMyTurn() { - t.Errorf("turn went to %q, want p2", update.GetTurnPlayerId()) + if update.GetTurnPlayerId() != nextID || !update.GetMyTurn() { + 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") } // 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() + watching := lead.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" { + if p.GetPlayerId() == leadID { 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. - host.say("chúc may mắn") - if got := second.await("chat_message").GetChatMessage().GetText(); got != "chúc may mắn" { + lead.say("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) } } +// 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 // their own side. func TestStandingsReachEverySeat(t *testing.T) { diff --git a/server/internal/wsapi/regression_test.go b/server/internal/wsapi/regression_test.go index 88752fe..692f7d5 100644 --- a/server/internal/wsapi/regression_test.go +++ b/server/internal/wsapi/regression_test.go @@ -49,15 +49,16 @@ func TestNicknamesCannotStackCombiningMarks(t *testing.T) { // each is a guard against the same mistake returning rather than a restatement // of behaviour already covered elsewhere. -// startPvP runs two clients up to the point where the game is live. -func startPvP(t *testing.T, url string) (host, guest *testClient, code string) { +// startPvP runs two clients up to the point where the game is live, and hands +// them back in turn order: lead moves first, waits answers. +func startPvP(t *testing.T, url string) (lead, waits *testClient, code string) { t.Helper() - host = dial(t, url) + host := dial(t, url) host.hello("Chủ phòng") host.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}}) code = host.await("room_state").GetRoomState().GetRoomCode() - guest = dial(t, url) + guest := dial(t, url) guest.hello("Khách") guest.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_JoinRoom{ JoinRoom: &noituv1.JoinRoom{RoomCode: code}, @@ -70,9 +71,11 @@ func startPvP(t *testing.T, url string) (host, guest *testClient, code string) { guest.setReady(true) host.await("room_state") host.startGame() - host.await("game_started") - guest.await("game_started") - return host, guest, code + // Returned in turn order rather than as owner and joiner: the first turn + // is drawn, so a test that plays a move has to be handed the player who + // 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 @@ -234,50 +237,50 @@ func TestRoomCreationIsRateLimited(t *testing.T) { // 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 guest is left holding a turn nobody could answer, and it is the clock -// that ends it. The turn limit is short because this test spends one. +// the second player is left holding a turn nobody could answer, and it is the +// clock that ends it. The turn limit is short because this test spends one. func TestPvPGameRunsToAWinner(t *testing.T) { _, 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. - host.submit("b c", 1) - guestTurn := awaitMyTurn(t, guest) - guest.submit("c d", guestTurn.GetTurnSeq()) - hostTurn := awaitMyTurn(t, host) - host.submit("d e", hostTurn.GetTurnSeq()) + // a b (opening) -> b c -> c d -> d e, after which waits has nothing. + lead.submit("b c", 1) + waitsTurn := awaitMyTurn(t, waits) + waits.submit("c d", waitsTurn.GetTurnSeq()) + leadTurn := awaitMyTurn(t, lead) + lead.submit("d e", leadTurn.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() + loserOut := waits.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") } - hostOver := host.await("game_over").GetGameOver() - guestOver := guest.await("game_over").GetGameOver() + winnerOver := lead.await("game_over").GetGameOver() + loserOver := waits.await("game_over").GetGameOver() - if hostOver.GetIWon() == guestOver.GetIWon() { - t.Fatalf("both players were told the same outcome: host=%v guest=%v", - hostOver.GetIWon(), guestOver.GetIWon()) + if winnerOver.GetIWon() == loserOver.GetIWon() { + t.Fatalf("both players were told the same outcome: lead=%v waits=%v", + winnerOver.GetIWon(), loserOver.GetIWon()) } - if !hostOver.GetIWon() { + if !winnerOver.GetIWon() { 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 { - t.Errorf("reason = %v, want NO_LEGAL_MOVE", hostOver.GetReason()) + if winnerOver.GetReason() != noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE { + t.Errorf("reason = %v, want NO_LEGAL_MOVE", winnerOver.GetReason()) } // 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 { + if got := loserOut.GetSuggestions(); len(got) != 0 { 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) } } @@ -287,23 +290,23 @@ func TestPvPGameRunsToAWinner(t *testing.T) { // shown some of them. func TestLosingPlayerIsToldWhatCouldHaveBeenPlayed(t *testing.T) { _, 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. - 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() - hostOut := host.await("player_eliminated").GetPlayerEliminated() + loserOut := waits.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") } - 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) } - 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) } } diff --git a/server/internal/wsapi/room.go b/server/internal/wsapi/room.go index 150da57..03d5151 100644 --- a/server/internal/wsapi/room.go +++ b/server/internal/wsapi/room.go @@ -5,6 +5,7 @@ import ( "iter" "log/slog" "math/rand/v2" + "slices" "time" noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" @@ -696,6 +697,19 @@ func (r *room) beginGame() error { ids = append(ids, s.id) } } + // Who leads is drawn rather than owned. Opening the game is an advantage — + // the first player picks from a whole syllable, everyone after them plays + // what is left of it — and giving it to whoever happened to create the + // room would make the same person favourite in every game of a series. + // + // Rotating rather than shuffling keeps the table intact: everybody still + // plays in the order they sat down, the cycle just starts somewhere else. + // A bot room is left alone; it has no table to be fair about, and the + // human opens. + if r.strategy == nil { + lead := rand.IntN(len(ids)) + ids = slices.Concat(ids[lead:], ids[:lead]) + } engine, err := game.New(r.dict, ids, opening, r.turnLimit, time.Now()) if err != nil { @@ -1452,8 +1466,9 @@ func (r *room) broadcastRoomState() { } } -// playerSlots renders the seating for one recipient, in seat order — which is -// also the turn order a game started from this lobby will use. +// playerSlots renders the seating for one recipient, in seat order. That is +// the order they will play in, but not who plays first: the lead is drawn when +// the game starts, and the table sent with it is the one in turn order. func (r *room) playerSlots(me game.PlayerID) []*noituv1.PlayerSlot { slots := make([]*noituv1.PlayerSlot, 0, maxPlayers) for _, s := range r.seats { diff --git a/server/internal/wsapi/wsapi_test.go b/server/internal/wsapi/wsapi_test.go index b94849b..e526420 100644 --- a/server/internal/wsapi/wsapi_test.go +++ b/server/internal/wsapi/wsapi_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "iter" + "math/rand/v2" "net/http/httptest" "runtime" "slices" @@ -15,6 +16,7 @@ import ( "github.com/coder/websocket" noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1" + "github.com/tiennm99dev/noitu/server/internal/bot" "github.com/tiennm99dev/noitu/server/internal/game" "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 // that each sees the other's move rendered from its own side. func TestPvPGameAlternatesTurns(t *testing.T) { @@ -351,37 +402,29 @@ func TestPvPGameAlternatesTurns(t *testing.T) { }}) readyAndStart(t, host, guest) - hostStart := host.await("game_started").GetGameStarted() - guestStart := guest.await("game_started").GetGameStarted() + lead, waits, start := awaitLead(t, host, guest) - if hostStart.GetMyTurn() == guestStart.GetMyTurn() { - 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()) + lead.submit("b c", start.GetTurnSeq()) // The same move, rendered per recipient: by_me flips, my_turn flips. - hostUpdate := host.await("turn_update").GetTurnUpdate() - guestUpdate := guest.await("turn_update").GetTurnUpdate() + moverUpdate := lead.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") } - if guestUpdate.GetPlayed().GetByMe() { + if otherUpdate.GetPlayed().GetByMe() { t.Error("opponent should see by_me = false") } - if hostUpdate.GetMyTurn() { + if moverUpdate.GetMyTurn() { t.Error("mover should not be on turn after moving") } - if !guestUpdate.GetMyTurn() { + if !otherUpdate.GetMyTurn() { 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", - myScore(hostUpdate), otherScore(guestUpdate)) + myScore(moverUpdate), otherScore(otherUpdate)) } } @@ -402,10 +445,9 @@ func TestTurnTimeoutEndsGameServerSide(t *testing.T) { }}) readyAndStart(t, host, guest) - host.await("game_started") - guest.await("game_started") + _, waits, _ := awaitLead(t, host, guest) - over := guest.await("game_over").GetGameOver() + over := waits.await("game_over").GetGameOver() if !over.GetIWon() { 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}, }}) readyAndStart(t, host, guest) - hostStart := host.await("game_started").GetGameStarted() - guest.await("game_started") + lead, waits, start := awaitLead(t, host, guest) // 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. - host.submit("b a", hostStart.GetTurnSeq()) - guestTurn := guest.await("turn_update").GetTurnUpdate() + lead.submit("b a", start.GetTurnSeq()) + 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 { t.Errorf("reason = %v, want ALREADY_USED", got.GetReason()) } @@ -522,7 +563,7 @@ func TestResumeWithinGraceRestoresGame(t *testing.T) { JoinRoom: &noituv1.JoinRoom{RoomCode: code}, }}) readyAndStart(t, host, guest) - host.await("game_started") + hostStart := host.await("game_started").GetGameStarted() guest.await("game_started") _ = host.conn.Close(websocket.StatusGoingAway, "") @@ -547,8 +588,8 @@ func TestResumeWithinGraceRestoresGame(t *testing.T) { if restored.GetOpeningWord() != "a b" { t.Errorf("resumed on %q, want the original opening", restored.GetOpeningWord()) } - if !restored.GetMyTurn() { - t.Error("the resumed player was on turn and should still be") + if restored.GetMyTurn() != hostStart.GetMyTurn() { + 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() } +// 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) { c.t.Helper() c.send(&noituv1.ClientMessage{Payload: &noituv1.ClientMessage_SetReady{ diff --git a/web/e2e/helpers.js b/web/e2e/helpers.js index 78eda33..7beeae8 100644 --- a/web/e2e/helpers.js +++ b/web/e2e/helpers.js @@ -31,6 +31,36 @@ export async function waitForMyTurn(page) { 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 * fills and submits exactly as a player would. diff --git a/web/e2e/pvp-game.spec.js b/web/e2e/pvp-game.spec.js index 3f002f9..f54005d 100644 --- a/web/e2e/pvp-game.spec.js +++ b/web/e2e/pvp-game.spec.js @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { + awaitTurn, board, chainWords, chat, @@ -48,14 +49,22 @@ async function joinRoom(page, nickname, code) { 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) { const pair = await twoPlayers(browser); const code = await createRoom(pair.host, 'Minh'); await joinRoom(pair.guest, 'Lan', code); await readyAndStart(pair.host, pair.guest); - await waitForMyTurn(pair.host); - return { ...pair, code }; + const { + lead, + waits: [second] + } = await awaitTurn(pair.host, pair.guest); + return { ...pair, code, lead, second }; } test.describe('playing a stranger', () => { @@ -69,11 +78,15 @@ test.describe('playing a stranger', () => { await joinRoom(guest, 'Lan', code); await readyAndStart(host, guest); - // Both boards come up, and exactly one player is on turn. - await waitForMyTurn(host); + // Both boards come up, and exactly one player is on turn — which of + // 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 // 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 // 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(guest.getByTestId('scoreboard').getByText('Minh')).toBeVisible(); - const used = new Set(await chainWords(host)); - const hostWord = await playLegalMove(host, used); + const used = new Set(await chainWords(lead)); + const opening = await playLegalMove(lead, used); // The move crosses to the other browser, and the turn goes with it. - await expect(guest.getByText(hostWord, { exact: true }).first()).toBeVisible(); - await waitForMyTurn(guest); + await expect(waiting.getByText(opening, { exact: true }).first()).toBeVisible(); + await waitForMyTurn(waiting); - const guestWord = await playLegalMove(guest, used); - await expect(host.getByText(guestWord, { exact: true }).first()).toBeVisible(); - await waitForMyTurn(host); + const reply = await playLegalMove(waiting, used); + await expect(lead.getByText(reply, { exact: true }).first()).toBeVisible(); + await waitForMyTurn(lead); await close(); }); @@ -107,7 +120,7 @@ test.describe('playing a stranger', () => { await expect(guest.getByTestId('ready')).toBeVisible(); await readyAndStart(host, guest); - await waitForMyTurn(host); + await awaitTurn(host, guest); await expect(board(guest).syllable).toBeVisible(); 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 // back to a single opening word. - await waitForMyTurn(host); + await awaitTurn(host, guest); await expect(host.getByText(code)).toBeVisible(); 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 // wide keeps it beside the board rather than folding it away. await readyAndStart(host, guest); - await waitForMyTurn(host); + await awaitTurn(host, guest); await expect(chat(host).log).toContainText('chào bạn'); await close(); @@ -294,7 +307,7 @@ test.describe('playing a stranger', () => { await expect(chat(host).log).toContainText('hai'); 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 // 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 }) => { - 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 - // field wants focus the moment a turn lands, and taking it here would - // drop the rest of the sentence into the game. - await chat(guest).input.click(); - await chat(guest).input.fill('đang gõ dở'); + // The player waiting starts typing while the other is still on turn. + // The word field wants focus the moment a turn lands, and taking it + // here would drop the rest of the sentence into the game. + await chat(second).input.click(); + await chat(second).input.fill('đang gõ dở'); - await playLegalMove(host, new Set(await chainWords(host))); - await waitForMyTurn(guest); + await playLegalMove(lead, new Set(await chainWords(lead))); + await waitForMyTurn(second); - await expect(chat(guest).input).toBeFocused(); - await expect(chat(guest).input).toHaveValue('đang gõ dở'); + await expect(chat(second).input).toBeFocused(); + await expect(chat(second).input).toHaveValue('đang gõ dở'); await close(); }); @@ -470,10 +483,17 @@ test.describe('playing a stranger', () => { await expect(host.getByTestId('start-game')).toBeDisabled(); await readyAndStart(host, ...players); - // Four boards, and exactly one player on turn. - await waitForMyTurn(host); - for (const page of [guest, ...players]) { - await expect(board(page).turn).toHaveText('Đến lượt Minh…'); + // Four boards, and exactly one player on turn — whoever the draw dealt + // it to. The other three are all told the same name. + const names = new Map([ + [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(); @@ -496,29 +516,41 @@ test.describe('playing a stranger', () => { await joinRoom(third, 'Nam', code); await readyAndStart(host, guest, third); - await waitForMyTurn(host); - host.on('dialog', (dialog) => dialog.accept()); - await host.getByRole('button', { name: 'Đầu hàng' }).click(); + // The player who drew the first turn is the one who gives it up, so + // 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 // carrying on in front of them. - await expect(host.getByTestId('eliminated')).toBeVisible(); - await expect(host.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(host.getByTestId('turn-indicator')).toHaveText('Đến lượt Lan…'); + await expect(lead.getByTestId('eliminated')).toBeVisible(); + await expect(lead.getByRole('textbox', { name: 'Nhập từ của bạn' })).toHaveCount(0); + await expect(lead.getByRole('heading', { name: 'Bạn thua.' })).toHaveCount(0); - // The others are told who went out, and one of them is now on turn. - await expect(guest.getByTestId('player-out')).toContainText('Minh'); - await waitForMyTurn(guest); + // The others are told who went out, and the next of them is on turn — + // which the player who left is shown by name. + 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. - guest.on('dialog', (dialog) => dialog.accept()); - await guest.getByRole('button', { name: 'Đầu hàng' }).click(); + next.on('dialog', (dialog) => dialog.accept()); + await next.getByRole('button', { name: 'Đầu hàng' }).click(); - await expect(third.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible(); - await expect(host.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible(); - await expect(host.getByTestId('standings').locator('li')).toHaveCount(3); + await expect(last.getByRole('heading', { name: 'Bạn thắng!' })).toBeVisible(); + await expect(lead.getByRole('heading', { name: 'Bạn thua.' })).toBeVisible(); + await expect(lead.getByTestId('standings').locator('li')).toHaveCount(3); await thirdContext.close(); await close(); diff --git a/web/e2e/reconnect.spec.js b/web/e2e/reconnect.spec.js index 59a053b..2cf888b 100644 --- a/web/e2e/reconnect.spec.js +++ b/web/e2e/reconnect.spec.js @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { + awaitTurn, board, chainWords, playLegalMove, @@ -19,6 +20,10 @@ import { cuttableSocket } from './socket-cut.js'; /** * 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 */ async function pvpRoom(browser) { @@ -37,12 +42,21 @@ async function pvpRoom(browser) { await guest.getByLabel('Mã phòng').fill(code); await guest.getByRole('button', { name: 'Vào phòng' }).click(); await readyAndStart(host, guest); - await waitForMyTurn(host); + const { + lead, + waits: [second] + } = await awaitTurn(host, guest); return { host, guest, 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() { await hostContext.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 ({ browser }) => { - const { host, guest, close } = await pvpRoom(browser); + const { lead, second, seatOf, nameOf, close } = await pvpRoom(browser); - const used = new Set(await chainWords(host)); - await playLegalMove(host, used); - await waitForMyTurn(guest); + // The player who drops is the one holding the turn, so what has to come + // back is a position somebody owes a move to. + 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 // sees when a player loses their connection. The resume token lives in // 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', { - timeout: 20_000 - }); + const away = lead.getByTestId(`away-${seatOf(second)}`); + await expect(away).toContainText(`${nameOf(second)} mất kết nối`, { timeout: 20_000 }); // 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 - // is looking at the same position rather than the lobby. - await expect(host.getByTestId('away-p2')).toHaveCount(0, { timeout: 20_000 }); - await expect(board(guest).syllable).toHaveText(hostSyllable, { timeout: 20_000 }); - await expect(board(guest).turn).toHaveText('Đến lượt bạn'); + // The seat is restored: the other player stops waiting, and the one who + // returned is looking at the same position rather than the lobby. + await expect(away).toHaveCount(0, { timeout: 20_000 }); + await expect(board(second).syllable).toHaveText(syllable, { timeout: 20_000 }); + await expect(board(second).turn).toHaveText('Đến lượt bạn'); await close(); });