Files
noitu/web/tests/game-wire.test.js
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

120 lines
5.0 KiB
JavaScript

// Cross-language wire check.
//
// These fixtures are binary messages produced by the Go server's own test
// suite (server/internal/wsapi/wire_test.go, regenerated with `-update`).
// Decoding them here proves the two generated clients agree on the actual
// bytes — a JS-only round trip would only prove this runtime is
// self-consistent, which is exactly the failure mode that ships a broken
// client.
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { PROTOCOL_VERSION } from '../src/lib/ws/messages.js';
import { fromBinary } from '@bufbuild/protobuf';
import {
ClientMessageSchema,
GameEndReason,
RejectReason,
ServerMessageSchema
} from '../src/lib/proto/noitu/v1/game_pb.js';
const fixtureDir = fileURLToPath(new URL('../../proto/testdata', import.meta.url));
const fixtures = readdirSync(fixtureDir).filter((f) => f.endsWith('.bin'));
/** Decode a fixture by name, choosing the schema from its client_/server_ prefix. */
function decode(name) {
const bytes = readFileSync(join(fixtureDir, `${name}.bin`));
const schema = name.startsWith('client_') ? ClientMessageSchema : ServerMessageSchema;
return fromBinary(schema, bytes);
}
describe('generated wire types', () => {
it('finds the fixtures the Go suite emits', () => {
expect(fixtures.length).toBeGreaterThan(0);
});
// Every arm must decode to a *named* case. An arm whose payload is empty on
// the wire (create_room, resign) still has to be distinguishable, so an
// undefined case here means the oneof tag was lost, not that the message
// was empty.
it.each(fixtures)('decodes %s to a known payload case', (file) => {
const msg = decode(file.replace(/\.bin$/, ''));
expect(msg.payload.case).toBeTypeOf('string');
});
it('preserves Vietnamese diacritics through UTF-8', () => {
const msg = decode('server_welcome');
expect(msg.payload.case).toBe('welcome');
expect(msg.payload.value.acceptedNickname).toBe('Người chơi ẩn danh');
expect(msg.payload.value.protocolVersion).toBe(PROTOCOL_VERSION);
});
// int64 is a bigint in this runtime. Reading it as a Number would silently
// lose precision on timestamps far smaller than the ones a real deadline
// carries, so the type itself is the assertion.
it('reads int64 timestamps as bigint', () => {
const msg = decode('server_pong');
expect(msg.payload.case).toBe('pong');
expect(msg.payload.value.clientTimeMs).toBe(1756998000123n);
expect(msg.payload.value.serverTimeMs).toBe(1756998000456n);
});
it('agrees with Go on enum numbering', () => {
const rejected = decode('server_move_rejected');
expect(rejected.payload.case).toBe('moveRejected');
expect(rejected.payload.value.reason).toBe(RejectReason.WRONG_LINK);
expect(rejected.payload.value.word).toBe('cà phê');
const over = decode('server_game_over');
expect(over.payload.case).toBe('gameOver');
expect(over.payload.value.reason).toBe(GameEndReason.NO_LEGAL_MOVE);
expect(over.payload.value.iWon).toBe(false);
// The final table, in the order the server ranked it.
expect(over.payload.value.standings.map((/** @type {any} */ p) => p.rank)).toEqual([1, 2, 3]);
const out = decode('server_player_eliminated');
// The only repeated string in the contract, and the one the losing
// player's screen is built from.
expect(out.payload.value.suggestions).toEqual(['sinh viên', 'sinh sôi']);
const chat = decode('server_chat_message');
expect(chat.payload.case).toBe('chatMessage');
expect(chat.payload.value.text).toBe('Tiếng “sinh” khó nối lắm.');
// int64, which this runtime hands over as a bigint. Anything that
// forgets to convert it fails here rather than as NaN on a screen.
expect(typeof chat.payload.value.sentUnixMs).toBe('bigint');
const history = decode('server_chat_history');
expect(history.payload.case).toBe('chatHistory');
const messages = history.payload.value.messages;
expect(messages).toHaveLength(3);
expect(messages[0].fromMe).toBe(true);
expect(messages[1].fromMe).toBe(false);
// An author who has left the room carries no name, which is how the
// client knows to label the line rather than attribute it.
expect(messages[2].author).toBe('');
});
// The canonical/typed pair is what lets the UI show that the server
// corrected a spelling instead of appearing to rewrite the player's text.
it('carries both the canonical word and the raw input', () => {
const msg = decode('server_turn_update');
expect(msg.payload.case).toBe('turnUpdate');
const turn = msg.payload.value;
expect(turn.played.word).toBe('bình yên');
expect(turn.played.typed).toBe('binh yên');
expect(turn.played.byMe).toBe(false);
expect(turn.currentSyllable).toBe('yên');
expect(turn.deadlineUnixMs).toBe(1756998040000n);
expect(turn.turnSeq).toBe(2);
});
it('decodes an empty client arm without losing its tag', () => {
const msg = decode('client_create_room');
expect(msg.payload.case).toBe('createRoom');
});
});