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:
tiennm99 committed 2026-09-08 13:57:51 +07:00
1 parent a72af1f0ad
commit 49f14a1f5a
7 files changed
+351 -148

No files matched your search

+65 -17
View File
@@ -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) {
+40 -37
View File
@@ -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)
}
}
+17 -2
View File
@@ -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 {
+90 -30
View File
@@ -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{