Files
noitu/server/internal/wsapi/wire_test.go
T
tiennm99 4e2e9e1433 feat(online)!: seat two to four players in a room
A room holds up to four people and needs two to start. Both numbers are
server constants sent to the client in RoomState, so the lobby draws
whatever the server allows and widening a room is a server change alone.

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

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

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

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

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

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

366 lines
13 KiB
Go

package wsapi
import (
"flag"
"os"
"path/filepath"
"sort"
"testing"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
)
// -update rewrites the committed cross-language fixtures. The JS suite decodes
// the same bytes, so regenerating them is the deliberate act of changing the
// wire contract, not a side effect of running the Go tests.
var update = flag.Bool("update", false, "rewrite the cross-language fixtures in proto/testdata")
const fixtureDir = "../../../proto/testdata"
// clientVariants covers every arm of ClientMessage.payload.
//
// The values are not minimal on purpose: Vietnamese text with diacritics, a
// large turn_seq and a real millisecond timestamp all exercise encodings a
// zero value would skip — UTF-8, varint width, and int64, which the JavaScript
// runtime surfaces as a bigint.
func clientVariants() map[string]*noituv1.ClientMessage {
return map[string]*noituv1.ClientMessage{
"client_hello": {Payload: &noituv1.ClientMessage_Hello{Hello: &noituv1.Hello{
ProtocolVersion: 1,
ResumeToken: "r-8f2c",
Nickname: "Người chơi ẩn danh",
}}},
"client_start_bot_game": {Payload: &noituv1.ClientMessage_StartBotGame{StartBotGame: &noituv1.StartBotGame{
Difficulty: noituv1.Difficulty_DIFFICULTY_HARD,
}}},
"client_create_room": {Payload: &noituv1.ClientMessage_CreateRoom{CreateRoom: &noituv1.CreateRoom{}}},
"client_join_room": {Payload: &noituv1.ClientMessage_JoinRoom{JoinRoom: &noituv1.JoinRoom{
RoomCode: "K7QX",
}}},
"client_submit_word": {Payload: &noituv1.ClientMessage_SubmitWord{SubmitWord: &noituv1.SubmitWord{
Word: "hoà bình",
TurnSeq: 4242,
}}},
"client_resign": {Payload: &noituv1.ClientMessage_Resign{Resign: &noituv1.Resign{}}},
"client_ping": {Payload: &noituv1.ClientMessage_Ping{Ping: &noituv1.Ping{
ClientTimeMs: 1756998000123,
}}},
"client_set_ready": {Payload: &noituv1.ClientMessage_SetReady{SetReady: &noituv1.SetReady{
Ready: true,
}}},
"client_start_game": {Payload: &noituv1.ClientMessage_StartGame{StartGame: &noituv1.StartGame{}}},
"client_kick_player": {Payload: &noituv1.ClientMessage_KickPlayer{KickPlayer: &noituv1.KickPlayer{
PlayerId: "p3",
}}},
"client_leave_room": {Payload: &noituv1.ClientMessage_LeaveRoom{LeaveRoom: &noituv1.LeaveRoom{}}},
"client_send_chat": {Payload: &noituv1.ClientMessage_SendChat{SendChat: &noituv1.SendChat{
Text: "Chào bạn, ván này khó thật!",
}}},
}
}
// serverVariants covers every arm of ServerMessage.payload.
func serverVariants() map[string]*noituv1.ServerMessage {
return map[string]*noituv1.ServerMessage{
"server_welcome": {Payload: &noituv1.ServerMessage_Welcome{Welcome: &noituv1.Welcome{
SessionId: "s-1a2b3c",
ResumeToken: "r-8f2c",
ProtocolVersion: ProtocolVersion,
AcceptedNickname: "Người chơi ẩn danh",
}}},
"server_game_started": {Payload: &noituv1.ServerMessage_GameStarted{GameStarted: &noituv1.GameStarted{
OpeningWord: "hòa bình",
CurrentSyllable: "bình",
MyTurn: true,
DeadlineUnixMs: 1756998020000,
TurnSeq: 1,
TurnLimitMs: 20000,
Players: fixtureScores(),
TurnPlayerId: "p1",
}}},
"server_turn_update": {Payload: &noituv1.ServerMessage_TurnUpdate{TurnUpdate: &noituv1.TurnUpdate{
Played: &noituv1.PlayedWord{
Word: "bình yên",
Typed: "binh yên",
ByMe: false,
Points: 2,
Syllables: 2,
PlayerId: "p2",
},
CurrentSyllable: "yên",
MyTurn: true,
DeadlineUnixMs: 1756998040000,
TurnSeq: 2,
ChainLength: 2,
Players: fixtureScores(),
TurnPlayerId: "p3",
}}},
"server_move_rejected": {Payload: &noituv1.ServerMessage_MoveRejected{MoveRejected: &noituv1.MoveRejected{
Reason: noituv1.RejectReason_REJECT_REASON_WRONG_LINK,
Word: "cà phê",
TurnSeq: 2,
}}},
"server_game_over": {Payload: &noituv1.ServerMessage_GameOver{GameOver: &noituv1.GameOver{
IWon: false,
Reason: noituv1.GameEndReason_GAME_END_REASON_NO_LEGAL_MOVE,
ChainLength: 11,
// Ranked, so a fixture that quietly reordered the table would show
// up as ranks that no longer match their positions.
Standings: []*noituv1.PlayerScore{
{PlayerId: "p2", Name: "Khách mời", Score: 42, Connected: true, Rank: 1},
{PlayerId: "p1", Name: "Người chơi", IsMe: true, Score: 7, Eliminated: true, Connected: true, Rank: 2},
{PlayerId: "p3", Name: "Khách 2", Score: 3, Eliminated: true, Rank: 3},
},
}}},
"server_player_eliminated": {Payload: &noituv1.ServerMessage_PlayerEliminated{
PlayerEliminated: &noituv1.PlayerEliminated{
PlayerId: "p1",
Name: "Người chơi",
IsMe: true,
Reason: noituv1.GameEndReason_GAME_END_REASON_TIMEOUT,
// A repeated string of Vietnamese words: the one field in the
// contract whose encoding is neither a scalar nor a submessage.
Suggestions: []string{"sinh viên", "sinh sôi"},
},
}},
"server_error": {Payload: &noituv1.ServerMessage_Error{Error: &noituv1.ServerError{
Code: "room_not_found",
Message: "room_not_found",
}}},
"server_pong": {Payload: &noituv1.ServerMessage_Pong{Pong: &noituv1.Pong{
ClientTimeMs: 1756998000123,
ServerTimeMs: 1756998000456,
}}},
// Asymmetric on purpose: equal booleans would not catch the two fields
// being swapped, which is exactly the mistake that shows one player
// their opponent's answer as their own.
"server_chat_message": {Payload: &noituv1.ServerMessage_ChatMessage{ChatMessage: &noituv1.ChatMessage{
FromMe: false,
Author: "Khách mời",
Text: "Tiếng “sinh” khó nối lắm.",
SentUnixMs: 1756998000123,
}}},
// Two sides of a conversation plus a line whose author has left, which
// is the one combination that exercises the repeated field, both
// from_me values, and the empty-author convention at once.
"server_chat_history": {Payload: &noituv1.ServerMessage_ChatHistory{ChatHistory: &noituv1.ChatHistory{
Messages: []*noituv1.ChatMessage{
{FromMe: true, Author: "Người chơi", Text: "Bắt đầu nhé", SentUnixMs: 1756998000000},
{FromMe: false, Author: "Khách mời", Text: "Sẵn sàng", SentUnixMs: 1756998000456},
{FromMe: false, Author: "", Text: "Tôi phải đi", SentUnixMs: 1756998000789},
},
}}},
// An owner looking at two guests, one ready and here and one ready but
// away. Every boolean in a slot is load-bearing in at least one row,
// and no two rows agree on all of them.
"server_room_state": {Payload: &noituv1.ServerMessage_RoomState{RoomState: &noituv1.RoomState{
RoomCode: "K7QX",
CanStart: true,
MaxPlayers: 4,
MinPlayers: 2,
GraceMs: 30000,
Players: []*noituv1.PlayerSlot{
{PlayerId: "p1", Name: "Người chơi", IsMe: true, IsOwner: true, Connected: true},
{PlayerId: "p2", Name: "Khách mời", Ready: true, Connected: true},
{PlayerId: "p3", Name: "Khách 2", Ready: true},
},
}}},
}
}
// fixtureScores is the players table both in-game messages carry. Three seats
// in three different states, so a fixture that dropped or reordered a row
// cannot still decode to something plausible.
func fixtureScores() []*noituv1.PlayerScore {
return []*noituv1.PlayerScore{
{PlayerId: "p1", Name: "Người chơi", IsMe: true, Score: 7, Connected: true},
{PlayerId: "p2", Name: "Khách mời", Score: 42, Eliminated: true, Connected: true},
{PlayerId: "p3", Name: "Khách 2", Score: 3},
}
}
// TestRoundTripEveryVariant marshals and unmarshals each oneof arm. Equality
// alone is not enough: an empty arm such as CreateRoom encodes to a payload of
// zero bytes, so checking that the case survived is what proves the arm is
// distinguishable on the wire at all.
func TestRoundTripEveryVariant(t *testing.T) {
for name, msg := range clientVariants() {
t.Run(name, func(t *testing.T) {
var got noituv1.ClientMessage
roundTrip(t, msg, &got)
if got.GetPayload() == nil {
t.Fatal("payload case lost in round trip")
}
})
}
for name, msg := range serverVariants() {
t.Run(name, func(t *testing.T) {
var got noituv1.ServerMessage
roundTrip(t, msg, &got)
if got.GetPayload() == nil {
t.Fatal("payload case lost in round trip")
}
})
}
}
// TestVariantTablesCoverEveryOneofArm keeps the tables above honest: adding a
// message to either oneof without adding a case here fails the tests rather
// than shipping an untested arm.
func TestVariantTablesCoverEveryOneofArm(t *testing.T) {
client := make([]proto.Message, 0, len(clientVariants()))
for _, m := range clientVariants() {
client = append(client, m)
}
server := make([]proto.Message, 0, len(serverVariants()))
for _, m := range serverVariants() {
server = append(server, m)
}
assertCoversOneof(t, "ClientMessage", client)
assertCoversOneof(t, "ServerMessage", server)
}
// TestCrossLanguageFixtures asserts the committed bytes still decode to the
// messages above. The JavaScript suite reads the same files, so the two
// languages are checked against one artifact rather than against each other's
// assumptions.
func TestCrossLanguageFixtures(t *testing.T) {
if *update {
writeFixtures(t)
return
}
want := allVariants()
files := mustGlob(t)
if len(files) != len(want) {
t.Errorf("fixture count %d does not match variant count %d; run: go test ./internal/wsapi -update", len(files), len(want))
}
for _, f := range files {
name := stem(f)
expect, ok := want[name]
if !ok {
t.Errorf("stale fixture %s has no matching variant", f)
continue
}
delete(want, name)
raw, err := os.ReadFile(f)
if err != nil {
t.Errorf("read %s: %v", f, err)
continue
}
got := expect.ProtoReflect().New().Interface()
if err := proto.Unmarshal(raw, got); err != nil {
t.Errorf("decode %s: %v", f, err)
continue
}
if !proto.Equal(got, expect) {
t.Errorf("%s decoded to %v, want %v", f, got, expect)
}
}
for name := range want {
t.Errorf("no fixture for variant %q; run: go test ./internal/wsapi -update", name)
}
}
func writeFixtures(t *testing.T) {
t.Helper()
if err := os.MkdirAll(fixtureDir, 0o755); err != nil {
t.Fatalf("create fixture dir: %v", err)
}
// Remove first, so a renamed variant leaves no orphan behind for the JS
// suite to decode against a schema that no longer describes it.
for _, f := range mustGlob(t) {
if err := os.Remove(f); err != nil {
t.Fatalf("remove %s: %v", f, err)
}
}
all := allVariants()
for name, m := range all {
raw, err := proto.Marshal(m)
if err != nil {
t.Fatalf("marshal %s: %v", name, err)
}
if err := os.WriteFile(filepath.Join(fixtureDir, name+".bin"), raw, 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
t.Logf("wrote %d fixtures to %s", len(all), fixtureDir)
}
func allVariants() map[string]proto.Message {
all := map[string]proto.Message{}
for name, m := range clientVariants() {
all[name] = m
}
for name, m := range serverVariants() {
all[name] = m
}
return all
}
func roundTrip(t *testing.T, in, out proto.Message) {
t.Helper()
raw, err := proto.Marshal(in)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := proto.Unmarshal(raw, out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !proto.Equal(out, in) {
t.Fatalf("round trip changed the message:\n got %v\nwant %v", out, in)
}
}
// assertCoversOneof reads which arm each sample actually set, rather than
// trusting a list of names written alongside the table. A hardcoded list is
// the thing most likely to be updated in lockstep with the table and so to
// agree with it while both drift away from the schema.
func assertCoversOneof(t *testing.T, msg string, samples []proto.Message) {
t.Helper()
if len(samples) == 0 {
t.Fatalf("%s has no samples", msg)
}
covered := map[protoreflect.Name]bool{}
for _, s := range samples {
m := s.ProtoReflect()
oneof := m.Descriptor().Oneofs().ByName("payload")
if oneof == nil {
t.Fatalf("%s has no payload oneof", msg)
}
set := m.WhichOneof(oneof)
if set == nil {
t.Errorf("%s sample %v sets no payload arm", msg, s)
continue
}
covered[set.Name()] = true
}
fields := samples[0].ProtoReflect().Descriptor().Oneofs().ByName("payload").Fields()
for i := 0; i < fields.Len(); i++ {
if f := fields.Get(i).Name(); !covered[f] {
t.Errorf("%s.payload arm %q is not in the variant table", msg, f)
}
}
}
func mustGlob(t *testing.T) []string {
t.Helper()
files, err := filepath.Glob(filepath.Join(fixtureDir, "*.bin"))
if err != nil {
t.Fatalf("glob fixtures: %v", err)
}
sort.Strings(files)
return files
}
func stem(path string) string {
base := filepath.Base(path)
return base[:len(base)-len(filepath.Ext(base))]
}